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 }