Files
unified-media-manager/internal/migrate/migrator.go
2026-04-24 10:45:19 -07:00

173 lines
5.5 KiB
Go

package migrate
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
// ArrSources holds paths to arr SQLite databases.
type ArrSources struct {
Sonarr string
Radarr string
SonarrAnime string
RadarrAnime string
Lidarr string
Readarr string
Prowlarr string
}
// ImportCount tracks imported and skipped counts for a category.
type ImportCount struct {
Imported int
Skipped int
}
// Report holds the migration results.
type Report struct {
Indexers ImportCount
Series ImportCount
Movies ImportCount
Albums ImportCount
Books ImportCount
Files ImportCount
Profiles ImportCount
RootFolders ImportCount
Tags ImportCount
Blocklist ImportCount
Errors int
}
func (r *Report) String() string {
var b strings.Builder
b.WriteString("Migration Report:\n")
fmt.Fprintf(&b, " Indexers: %d imported, %d skipped\n", r.Indexers.Imported, r.Indexers.Skipped)
fmt.Fprintf(&b, " Series: %d imported, %d deduplicated\n", r.Series.Imported, r.Series.Skipped)
fmt.Fprintf(&b, " Movies: %d imported, %d deduplicated\n", r.Movies.Imported, r.Movies.Skipped)
fmt.Fprintf(&b, " Albums: %d imported, %d deduplicated\n", r.Albums.Imported, r.Albums.Skipped)
fmt.Fprintf(&b, " Books: %d imported, %d deduplicated\n", r.Books.Imported, r.Books.Skipped)
fmt.Fprintf(&b, " Files: %d imported\n", r.Files.Imported)
fmt.Fprintf(&b, " Profiles: %d imported\n", r.Profiles.Imported)
fmt.Fprintf(&b, " Root Folders: %d imported\n", r.RootFolders.Imported)
fmt.Fprintf(&b, " Tags: %d imported\n", r.Tags.Imported)
fmt.Fprintf(&b, " Blocklist: %d imported\n", r.Blocklist.Imported)
fmt.Fprintf(&b, " Errors: %d\n", r.Errors)
return b.String()
}
// Migrator orchestrates the data migration from arr SQLite databases to UMM PostgreSQL.
type Migrator struct {
db *db.DB
sources ArrSources
report Report
// Maps arr entity IDs to UMM media IDs, keyed by arr instance
sonarrSeriesMap map[int64]int64 // sonarr series ID → UMM media ID
sonarrAnimeSeriesMap map[int64]int64 // sonarr-anime series ID → UMM media ID
radarrMovieMap map[int64]int64 // radarr movie ID → UMM media ID
radarrAnimeMovieMap map[int64]int64 // radarr-anime movie ID → UMM media ID
lidarrAlbumMap map[int64]int64 // lidarr album ID → UMM media ID
readarrBookMap map[int64]int64 // readarr book ID → UMM media ID
}
// NewMigrator creates a new Migrator instance.
func NewMigrator(database *db.DB, sources ArrSources) *Migrator {
return &Migrator{
db: database,
sources: sources,
sonarrSeriesMap: make(map[int64]int64),
sonarrAnimeSeriesMap: make(map[int64]int64),
radarrMovieMap: make(map[int64]int64),
radarrAnimeMovieMap: make(map[int64]int64),
lidarrAlbumMap: make(map[int64]int64),
readarrBookMap: make(map[int64]int64),
}
}
// Run executes the full migration pipeline.
func (m *Migrator) Run(ctx context.Context) (*Report, error) {
slog.Info("starting arr data migration")
// Step 1: Import Prowlarr indexers
if m.sources.Prowlarr != "" {
slog.Info("importing prowlarr indexers", "path", m.sources.Prowlarr)
if err := m.importProwlarr(ctx); err != nil {
slog.Error("failed to import prowlarr", "error", err)
m.report.Errors++
}
}
// Step 2: Import Sonarr series
if m.sources.Sonarr != "" {
slog.Info("importing sonarr series", "path", m.sources.Sonarr)
if err := m.importSonarr(ctx, m.sources.Sonarr, false); err != nil {
slog.Error("failed to import sonarr", "error", err)
m.report.Errors++
}
}
// Step 3: Import Sonarr-anime (deduplicate by TVDB ID)
if m.sources.SonarrAnime != "" {
slog.Info("importing sonarr-anime series", "path", m.sources.SonarrAnime)
if err := m.importSonarr(ctx, m.sources.SonarrAnime, true); err != nil {
slog.Error("failed to import sonarr-anime", "error", err)
m.report.Errors++
}
}
// Step 4: Import Radarr movies
if m.sources.Radarr != "" {
slog.Info("importing radarr movies", "path", m.sources.Radarr)
if err := m.importRadarr(ctx, m.sources.Radarr, false); err != nil {
slog.Error("failed to import radarr", "error", err)
m.report.Errors++
}
}
// Step 5: Import Radarr-anime (deduplicate by TMDB ID)
if m.sources.RadarrAnime != "" {
slog.Info("importing radarr-anime movies", "path", m.sources.RadarrAnime)
if err := m.importRadarr(ctx, m.sources.RadarrAnime, true); err != nil {
slog.Error("failed to import radarr-anime", "error", err)
m.report.Errors++
}
}
// Step 6: Import Lidarr artists + albums
if m.sources.Lidarr != "" {
slog.Info("importing lidarr", "path", m.sources.Lidarr)
if err := m.importLidarr(ctx); err != nil {
slog.Error("failed to import lidarr", "error", err)
m.report.Errors++
}
}
// Step 7: Import Readarr books
if m.sources.Readarr != "" {
slog.Info("importing readarr", "path", m.sources.Readarr)
if err := m.importReadarr(ctx); err != nil {
slog.Error("failed to import readarr", "error", err)
m.report.Errors++
}
}
// Step 8: Reset PostgreSQL sequences
if err := m.resetSequences(ctx); err != nil {
slog.Error("failed to reset sequences", "error", err)
m.report.Errors++
}
slog.Info("migration complete",
"series", m.report.Series.Imported,
"movies", m.report.Movies.Imported,
"albums", m.report.Albums.Imported,
"books", m.report.Books.Imported,
"files", m.report.Files.Imported,
"errors", m.report.Errors,
)
return &m.report, nil
}