package migrate import ( "context" "encoding/json" "fmt" "log/slog" "path/filepath" "strings" "github.com/jackc/pgx/v5" ) // importProwlarr reads Prowlarr indexers and imports them into UMM. func (m *Migrator) importProwlarr(ctx context.Context) error { reader, err := NewArrReader(m.sources.Prowlarr) if err != nil { return fmt.Errorf("open prowlarr db: %w", err) } defer reader.Close() indexers, err := reader.ReadProwlarrIndexers() if err != nil { return fmt.Errorf("read prowlarr indexers: %w", err) } for _, idx := range indexers { if !idx.Enable { m.report.Indexers.Skipped++ continue } url, apiKey := ParseIndexerSettings(idx.Settings) if url == "" { m.report.Indexers.Skipped++ continue } impl := idx.Implementation if impl == "" { impl = "torznab" } var apiKeyPtr *string if apiKey != "" { apiKeyPtr = &apiKey } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO indexers (name, implementation, url, api_key, enabled, priority) VALUES ($1, $2, $3, $4, true, $5) ON CONFLICT (name) DO NOTHING RETURNING id`, idx.Name, impl, url, apiKeyPtr, idx.Priority).Scan(&id) if err != nil { if err == pgx.ErrNoRows { m.report.Indexers.Skipped++ continue } slog.Error("failed to import indexer", "name", idx.Name, "error", err) m.report.Errors++ continue } m.report.Indexers.Imported++ } return nil } // importSonarr reads Sonarr series, quality profiles, root folders, tags, blocklist, and files. func (m *Migrator) importSonarr(ctx context.Context, dbPath string, isAnime bool) error { reader, err := NewArrReader(dbPath) if err != nil { return fmt.Errorf("open sonarr db: %w", err) } defer reader.Close() // Import supporting data first profileMap, err := m.importQualityProfiles(ctx, reader, "series") if err != nil { slog.Error("failed to import sonarr quality profiles", "error", err) m.report.Errors++ } folderMap, err := m.importRootFolders(ctx, reader, "series") if err != nil { slog.Error("failed to import sonarr root folders", "error", err) m.report.Errors++ } tagMap, err := m.importTags(ctx, reader) if err != nil { slog.Error("failed to import sonarr tags", "error", err) m.report.Errors++ } // Ensure anime tag exists if needed var animeTagID int64 if isAnime { animeTagID, _ = m.ensureTag(ctx, "anime") } // Read and import series series, err := reader.ReadSonarrSeries() if err != nil { return fmt.Errorf("read sonarr series: %w", err) } seriesMap := m.sonarrSeriesMap if isAnime { seriesMap = m.sonarrAnimeSeriesMap } for _, s := range series { if s.TVDBID == 0 || s.Title == "" { m.report.Series.Skipped++ continue } extIDs := fmt.Sprintf(`{"tvdb": %d}`, s.TVDBID) // Check for existing media with same external ID (for anime dedup) if isAnime { existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "series") if found { // Add anime tag to existing item if animeTagID > 0 { m.addTagToMedia(ctx, existingID, "series", animeTagID) } m.report.Series.Skipped++ slog.Debug("anime dedup: series already exists", "title", s.Title, "tvdb", s.TVDBID) continue } } else { // Check if already imported (non-anime) existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "series") if found { seriesMap[s.ID] = existingID m.report.Series.Skipped++ continue } } // Map quality profile and root folder qpID := m.mapProfileID(profileMap, s.QualityProfileID) rfID := m.mapFolderID(folderMap, s.RootFolderPath) // Determine status status := "unavailable" if s.Status == "continuing" || s.Status == "ended" { status = "unavailable" // will be updated to 'available' if files exist } sortTitle := s.SortTitle if sortTitle == "" { sortTitle = s.Title } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media (media_type, title, sort_title, year, status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id) VALUES ('series', $1, $2, $3, $4, $5, $6::jsonb, '{}', $7::jsonb, $8, $9) ON CONFLICT DO NOTHING RETURNING id`, s.Title, sortTitle, zeroIfNil(s.Year), status, s.Monitored, extIDs, safeJSON(s.Images), qpID, rfID).Scan(&id) if err != nil { if err == pgx.ErrNoRows { m.report.Series.Skipped++ continue } slog.Error("failed to import series", "title", s.Title, "error", err) m.report.Errors++ continue } seriesMap[s.ID] = id // Add anime tag if needed if isAnime && animeTagID > 0 { m.addTagToMedia(ctx, id, "series", animeTagID) } // Map arr tags to UMM tags m.applyArrTags(ctx, s.Tags, id, "series", tagMap) m.report.Series.Imported++ } // Import episode files files, err := reader.ReadEpisodeFiles() if err != nil { slog.Error("failed to read episode files", "error", err) m.report.Errors++ return nil } for _, f := range files { ummSeriesID, ok := seriesMap[f.SeriesID] if !ok { continue } fileName := f.RelativePath if fileName == "" { fileName = filepath.Base(f.Path) } filePath := f.Path if filePath == "" { filePath = f.RelativePath } var fileID int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality) VALUES ($1, 'series', $2, $3, $4, $5::jsonb) ON CONFLICT (media_id, media_type, path) DO NOTHING RETURNING id`, ummSeriesID, filePath, fileName, f.Size, safeJSON(f.Quality)).Scan(&fileID) if err != nil { if err == pgx.ErrNoRows { continue } slog.Error("failed to import episode file", "path", f.Path, "error", err) m.report.Errors++ continue } m.report.Files.Imported++ // Update series status to available m.db.Pool.Exec(ctx, `UPDATE media SET status = 'available' WHERE id = $1 AND media_type = 'series'`, ummSeriesID) } // Import blocklist m.importBlocklistFromReader(ctx, reader) return nil } // importRadarr reads Radarr movies, quality profiles, root folders, tags, blocklist, and files. func (m *Migrator) importRadarr(ctx context.Context, dbPath string, isAnime bool) error { reader, err := NewArrReader(dbPath) if err != nil { return fmt.Errorf("open radarr db: %w", err) } defer reader.Close() // Import supporting data profileMap, err := m.importQualityProfiles(ctx, reader, "movie") if err != nil { slog.Error("failed to import radarr quality profiles", "error", err) m.report.Errors++ } folderMap, err := m.importRootFolders(ctx, reader, "movie") if err != nil { slog.Error("failed to import radarr root folders", "error", err) m.report.Errors++ } tagMap, err := m.importTags(ctx, reader) if err != nil { slog.Error("failed to import radarr tags", "error", err) m.report.Errors++ } var animeTagID int64 if isAnime { animeTagID, _ = m.ensureTag(ctx, "anime") } // Read and import movies movies, err := reader.ReadRadarrMovies() if err != nil { return fmt.Errorf("read radarr movies: %w", err) } movieMap := m.radarrMovieMap if isAnime { movieMap = m.radarrAnimeMovieMap } for _, mv := range movies { if mv.TMDBID == 0 || mv.Title == "" { m.report.Movies.Skipped++ continue } extIDs := fmt.Sprintf(`{"tmdb": %d}`, mv.TMDBID) // Check for existing media with same external ID if isAnime { existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "movie") if found { if animeTagID > 0 { m.addTagToMedia(ctx, existingID, "movie", animeTagID) } m.report.Movies.Skipped++ slog.Debug("anime dedup: movie already exists", "title", mv.Title, "tmdb", mv.TMDBID) continue } } else { existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "movie") if found { movieMap[mv.ID] = existingID m.report.Movies.Skipped++ continue } } qpID := m.mapProfileID(profileMap, mv.QualityProfileID) rfID := m.mapFolderID(folderMap, mv.RootFolderPath) status := "unavailable" if mv.HasFile { status = "available" } sortTitle := mv.SortTitle if sortTitle == "" { sortTitle = mv.Title } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media (media_type, title, sort_title, year, status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id) VALUES ('movie', $1, $2, $3, $4, $5, $6::jsonb, '{}', $7::jsonb, $8, $9) ON CONFLICT DO NOTHING RETURNING id`, mv.Title, sortTitle, zeroIfNil(mv.Year), status, mv.Monitored, extIDs, safeJSON(mv.Images), qpID, rfID).Scan(&id) if err != nil { if err == pgx.ErrNoRows { m.report.Movies.Skipped++ continue } slog.Error("failed to import movie", "title", mv.Title, "error", err) m.report.Errors++ continue } movieMap[mv.ID] = id if isAnime && animeTagID > 0 { m.addTagToMedia(ctx, id, "movie", animeTagID) } m.applyArrTags(ctx, "", id, "movie", tagMap) m.report.Movies.Imported++ } // Import movie files files, err := reader.ReadMovieFiles() if err != nil { slog.Error("failed to read movie files", "error", err) m.report.Errors++ return nil } for _, f := range files { ummMovieID, ok := movieMap[f.MovieID] if !ok { continue } fileName := f.RelativePath if fileName == "" { fileName = filepath.Base(f.Path) } filePath := f.Path if filePath == "" { filePath = f.RelativePath } var fileID int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality) VALUES ($1, 'movie', $2, $3, $4, $5::jsonb) ON CONFLICT (media_id, media_type, path) DO NOTHING RETURNING id`, ummMovieID, filePath, fileName, f.Size, safeJSON(f.Quality)).Scan(&fileID) if err != nil { if err == pgx.ErrNoRows { continue } slog.Error("failed to import movie file", "path", f.Path, "error", err) m.report.Errors++ continue } m.report.Files.Imported++ // Update movie status to available m.db.Pool.Exec(ctx, `UPDATE media SET status = 'available' WHERE id = $1 AND media_type = 'movie'`, ummMovieID) } // Import blocklist m.importBlocklistFromReader(ctx, reader) return nil } // importLidarr reads Lidarr artists and albums. func (m *Migrator) importLidarr(ctx context.Context) error { reader, err := NewArrReader(m.sources.Lidarr) if err != nil { return fmt.Errorf("open lidarr db: %w", err) } defer reader.Close() // Import supporting data _, err = m.importQualityProfiles(ctx, reader, "album") if err != nil { slog.Error("failed to import lidarr quality profiles", "error", err) m.report.Errors++ } folderMap, err := m.importRootFolders(ctx, reader, "album") if err != nil { slog.Error("failed to import lidarr root folders", "error", err) m.report.Errors++ } _, err = m.importTags(ctx, reader) if err != nil { slog.Error("failed to import lidarr tags", "error", err) m.report.Errors++ } // Read artists (used as parent entities) artists, err := reader.ReadLidarrArtists() if err != nil { return fmt.Errorf("read lidarr artists: %w", err) } artistMap := make(map[int64]int64) // lidarr artist ID → UMM media ID for _, a := range artists { if a.ForeignArtistID == "" || a.Name == "" { continue } extIDs := fmt.Sprintf(`{"musicbrainz": "%s"}`, a.ForeignArtistID) // Check if already exists existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "music") if found { artistMap[a.ID] = existingID continue } rfID := m.mapFolderID(folderMap, a.RootFolderPath) var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media (media_type, title, sort_title, status, monitored, external_ids, metadata, images, root_folder_id) VALUES ('music', $1, $1, $2, $3, $4::jsonb, '{}', $5::jsonb, $6) ON CONFLICT DO NOTHING RETURNING id`, a.Name, statusFromMonitored(a.Monitored), a.Monitored, extIDs, safeJSON(a.Images), rfID).Scan(&id) if err != nil { if err == pgx.ErrNoRows { continue } slog.Error("failed to import artist", "name", a.Name, "error", err) m.report.Errors++ continue } artistMap[a.ID] = id } // Read albums albums, err := reader.ReadLidarrAlbums() if err != nil { return fmt.Errorf("read lidarr albums: %w", err) } for _, al := range albums { if al.ForeignAlbumID == "" || al.Title == "" { m.report.Albums.Skipped++ continue } extIDs := fmt.Sprintf(`{"musicbrainz": "%s"}`, al.ForeignAlbumID) existingID, _, found := m.findMediaByExternalID(ctx, extIDs, "album") if found { m.lidarrAlbumMap[al.ID] = existingID m.report.Albums.Skipped++ continue } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media (media_type, title, sort_title, year, status, monitored, external_ids, metadata, images) VALUES ('album', $1, $1, $2, $3, $4, $5::jsonb, '{}', '[]') ON CONFLICT DO NOTHING RETURNING id`, al.Title, zeroIfNil(al.Year), statusFromMonitored(al.Monitored), al.Monitored, extIDs).Scan(&id) if err != nil { if err == pgx.ErrNoRows { m.report.Albums.Skipped++ continue } slog.Error("failed to import album", "title", al.Title, "error", err) m.report.Errors++ continue } m.lidarrAlbumMap[al.ID] = id // Create relation: artist → album if artistID, ok := artistMap[al.ArtistID]; ok { m.db.Pool.Exec(ctx, ` INSERT INTO media_relations (parent_id, child_id, relation) VALUES ($1, $2, 'artist_album') ON CONFLICT (parent_id, child_id, relation) DO NOTHING`, artistID, id) } m.report.Albums.Imported++ } // Import track files trackFiles, err := reader.ReadTrackFiles() if err != nil { slog.Error("failed to read track files", "error", err) m.report.Errors++ return nil } for _, tf := range trackFiles { ummAlbumID, ok := m.lidarrAlbumMap[tf.AlbumID] if !ok { continue } fileName := tf.RelativePath if fileName == "" { fileName = filepath.Base(tf.Path) } filePath := tf.Path if filePath == "" { filePath = tf.RelativePath } var fileID int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality) VALUES ($1, 'album', $2, $3, $4, $5::jsonb) ON CONFLICT (media_id, media_type, path) DO NOTHING RETURNING id`, ummAlbumID, filePath, fileName, tf.Size, safeJSON(tf.Quality)).Scan(&fileID) if err != nil { if err == pgx.ErrNoRows { continue } continue } m.report.Files.Imported++ m.db.Pool.Exec(ctx, `UPDATE media SET status = 'available' WHERE id = $1 AND media_type = 'album'`, ummAlbumID) } return nil } // importReadarr reads Readarr books. func (m *Migrator) importReadarr(ctx context.Context) error { reader, err := NewArrReader(m.sources.Readarr) if err != nil { return fmt.Errorf("open readarr db: %w", err) } defer reader.Close() // Import supporting data _, err = m.importQualityProfiles(ctx, reader, "other") if err != nil { slog.Error("failed to import readarr quality profiles", "error", err) m.report.Errors++ } folderMap, err := m.importRootFolders(ctx, reader, "other") if err != nil { slog.Error("failed to import readarr root folders", "error", err) m.report.Errors++ } _, err = m.importTags(ctx, reader) if err != nil { slog.Error("failed to import readarr tags", "error", err) m.report.Errors++ } books, err := reader.ReadReadarrBooks() if err != nil { return fmt.Errorf("read readarr books: %w", err) } for _, b := range books { if b.Title == "" { m.report.Books.Skipped++ continue } // Build external IDs — could be ISBN, Goodreads, or other extIDs := "{}" if b.ForeignBookID != "" { if isISBN(b.ForeignBookID) { extIDs = fmt.Sprintf(`{"isbn": "%s"}`, b.ForeignBookID) } else { extIDs = fmt.Sprintf(`{"goodreads": "%s"}`, b.ForeignBookID) } } // Check if already exists if extIDs != "{}" { _, _, found := m.findMediaByExternalID(ctx, extIDs, "other") if found { m.report.Books.Skipped++ continue } } title := b.Title if b.AuthorName != "" { title = fmt.Sprintf("%s - %s", b.AuthorName, b.Title) } rfID := m.mapFolderID(folderMap, "") var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media (media_type, title, sort_title, status, monitored, external_ids, metadata, images, root_folder_id) VALUES ('other', $1, $1, $2, $3, $4::jsonb, '{}', $5::jsonb, $6) ON CONFLICT DO NOTHING RETURNING id`, title, statusFromMonitored(b.Monitored), b.Monitored, extIDs, safeJSON(b.Images), rfID).Scan(&id) if err != nil { if err == pgx.ErrNoRows { m.report.Books.Skipped++ continue } slog.Error("failed to import book", "title", b.Title, "error", err) m.report.Errors++ continue } m.readarrBookMap[b.ID] = id m.report.Books.Imported++ } // Import book files bookFiles, err := reader.ReadBookFiles() if err != nil { slog.Error("failed to read book files", "error", err) m.report.Errors++ return nil } for _, bf := range bookFiles { ummBookID, ok := m.readarrBookMap[bf.BookID] if !ok { continue } fileName := bf.RelativePath if fileName == "" { fileName = filepath.Base(bf.Path) } filePath := bf.Path if filePath == "" { filePath = bf.RelativePath } var fileID int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality) VALUES ($1, 'other', $2, $3, $4, $5::jsonb) ON CONFLICT (media_id, media_type, path) DO NOTHING RETURNING id`, ummBookID, filePath, fileName, bf.Size, safeJSON(bf.Quality)).Scan(&fileID) if err != nil { if err == pgx.ErrNoRows { continue } continue } m.report.Files.Imported++ m.db.Pool.Exec(ctx, `UPDATE media SET status = 'available' WHERE id = $1 AND media_type = 'other'`, ummBookID) } return nil } // resetSequences resets PostgreSQL sequences to match the max ID in each table. func (m *Migrator) resetSequences(ctx context.Context) error { sequences := []struct { seq string table string }{ {"media_id_seq", "media"}, {"media_files_id_seq", "media_files"}, {"media_relations_id_seq", "media_relations"}, {"download_queue_id_seq", "download_queue"}, {"blocklist_id_seq", "blocklist"}, {"requests_id_seq", "requests"}, } for _, s := range sequences { _, err := m.db.Pool.Exec(ctx, fmt.Sprintf( `SELECT setval('%s', COALESCE((SELECT MAX(id) FROM %s), 0))`, s.seq, s.table)) if err != nil { slog.Error("failed to reset sequence", "sequence", s.seq, "error", err) // Not fatal — continue with other sequences } else { slog.Debug("reset sequence", "sequence", s.seq) } } return nil } // --- Helper methods --- // ensureTag creates a tag if it doesn't exist and returns its ID. func (m *Migrator) ensureTag(ctx context.Context, name string) (int64, error) { // Try to find existing var id int64 err := m.db.Pool.QueryRow(ctx, `SELECT id FROM tags WHERE name = $1`, name).Scan(&id) if err == nil { return id, nil } // Create new err = m.db.Pool.QueryRow(ctx, `INSERT INTO tags (name, color) VALUES ($1, '#ef4444') ON CONFLICT (name) DO NOTHING RETURNING id`, name).Scan(&id) if err != nil { if err == pgx.ErrNoRows { // Race condition — someone else created it err = m.db.Pool.QueryRow(ctx, `SELECT id FROM tags WHERE name = $1`, name).Scan(&id) } if err != nil { return 0, fmt.Errorf("ensure tag %s: %w", name, err) } } m.report.Tags.Imported++ return id, nil } // importQualityProfiles imports quality profiles from an arr database and returns a mapping // from arr profile ID to UMM profile ID. func (m *Migrator) importQualityProfiles(ctx context.Context, reader *ArrReader, mediaType string) (map[int64]int64, error) { profiles, err := reader.ReadQualityProfiles() if err != nil { return nil, err } profileMap := make(map[int64]int64) for _, p := range profiles { items := ParseQualityItems(p.Items) allowed := ExtractAllowedQualities(items) cutoffName := FindCutoffName(items, p.Cutoff) if len(allowed) == 0 { continue } // Build JSON arrays mtJSON, _ := json.Marshal([]string{mediaType}) cutoffJSON, _ := json.Marshal(cutoffName) allowedJSON, _ := json.Marshal(allowed) var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO quality_profiles (name, media_types, cutoff_quality, allowed_qualities) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING id`, p.Name, mtJSON, cutoffJSON, allowedJSON).Scan(&id) if err != nil { if err == pgx.ErrNoRows { // Profile with this name already exists — look it up err = m.db.Pool.QueryRow(ctx, `SELECT id FROM quality_profiles WHERE name = $1`, p.Name).Scan(&id) if err != nil { continue } } else { slog.Error("failed to import quality profile", "name", p.Name, "error", err) continue } } else { m.report.Profiles.Imported++ } profileMap[p.ID] = id } return profileMap, nil } // importRootFolders imports root folders from an arr database and returns a mapping // from arr root folder path to UMM root folder ID. func (m *Migrator) importRootFolders(ctx context.Context, reader *ArrReader, mediaType string) (map[string]int64, error) { folders, err := reader.ReadRootFolders() if err != nil { return nil, err } folderMap := make(map[string]int64) for _, f := range folders { if f.Path == "" { continue } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO root_folders (path, media_type) VALUES ($1, $2) ON CONFLICT (path) DO NOTHING RETURNING id`, f.Path, mediaType).Scan(&id) if err != nil { if err == pgx.ErrNoRows { err = m.db.Pool.QueryRow(ctx, `SELECT id FROM root_folders WHERE path = $1`, f.Path).Scan(&id) if err != nil { continue } } else { continue } } else { m.report.RootFolders.Imported++ } folderMap[f.Path] = id } return folderMap, nil } // importTags imports tags from an arr database and returns a mapping from arr tag ID to UMM tag ID. func (m *Migrator) importTags(ctx context.Context, reader *ArrReader) (map[int64]int64, error) { tags, err := reader.ReadTags() if err != nil { return nil, err } tagMap := make(map[int64]int64) for _, t := range tags { if t.Label == "" { continue } var id int64 err := m.db.Pool.QueryRow(ctx, ` INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id`, t.Label).Scan(&id) if err != nil { if err == pgx.ErrNoRows { err = m.db.Pool.QueryRow(ctx, `SELECT id FROM tags WHERE name = $1`, t.Label).Scan(&id) if err != nil { continue } } else { continue } } else { m.report.Tags.Imported++ } tagMap[t.ID] = id } return tagMap, nil } // importBlocklistFromReader imports blocklist entries from an arr database. func (m *Migrator) importBlocklistFromReader(ctx context.Context, reader *ArrReader) { entries, err := reader.ReadBlocklist() if err != nil { slog.Error("failed to read blocklist", "error", err) return } for _, e := range entries { if e.Title == "" { continue } var sourceTitle *string if e.SourceTitle != "" { sourceTitle = &e.SourceTitle } var torrentHash *string if e.TorrentHash != "" { torrentHash = &e.TorrentHash } var message *string if e.Message != "" { message = &e.Message } var size *int64 if e.Size > 0 { size = &e.Size } protocol := e.Protocol if protocol == "" { protocol = "torrent" } _, err := m.db.Pool.Exec(ctx, ` INSERT INTO blocklist (release_title, source_title, quality, protocol, torrent_hash, size, message, block_reason) VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, 'imported')`, e.Title, sourceTitle, safeJSON(e.Quality), protocol, torrentHash, size, message) if err != nil { slog.Error("failed to import blocklist entry", "title", e.Title, "error", err) continue } m.report.Blocklist.Imported++ } } // findMediaByExternalID checks if media with the given external IDs JSON exists. // Returns the UMM media ID, media type, and whether it was found. func (m *Migrator) findMediaByExternalID(ctx context.Context, extIDs string, mediaType string) (int64, string, bool) { var id int64 var mt string err := m.db.Pool.QueryRow(ctx, ` SELECT id, media_type FROM media WHERE external_ids @> $1::jsonb AND media_type = $2 AND deleted_at IS NULL LIMIT 1`, extIDs, mediaType).Scan(&id, &mt) if err != nil { return 0, "", false } return id, mt, true } // addTagToMedia adds a tag to a media item. func (m *Migrator) addTagToMedia(ctx context.Context, mediaID int64, mediaType string, tagID int64) { _, err := m.db.Pool.Exec(ctx, ` INSERT INTO media_tags (media_id, media_type, tag_id) VALUES ($1, $2, $3) ON CONFLICT (media_id, media_type, tag_id) DO NOTHING`, mediaID, mediaType, tagID) if err != nil { slog.Error("failed to add tag to media", "media_id", mediaID, "tag_id", tagID, "error", err) } } // applyArrTags applies arr tag IDs (from the JSON tags column) to UMM media. func (m *Migrator) applyArrTags(ctx context.Context, tagsJSON string, mediaID int64, mediaType string, tagMap map[int64]int64) { if tagsJSON == "" || tagsJSON == "[]" { return } var tagIDs []int64 if err := json.Unmarshal([]byte(tagsJSON), &tagIDs); err != nil { return } for _, arrTagID := range tagIDs { ummTagID, ok := tagMap[arrTagID] if !ok { continue } m.addTagToMedia(ctx, mediaID, mediaType, ummTagID) } } // mapProfileID maps an arr quality profile ID to a UMM quality profile ID. func (m *Migrator) mapProfileID(profileMap map[int64]int64, arrID int64) *int64 { if profileMap == nil || arrID == 0 { return nil } if ummID, ok := profileMap[arrID]; ok { return &ummID } return nil } // mapFolderID maps an arr root folder path to a UMM root folder ID. func (m *Migrator) mapFolderID(folderMap map[string]int64, path string) *int64 { if folderMap == nil || path == "" { return nil } if ummID, ok := folderMap[path]; ok { return &ummID } return nil } // safeJSON returns valid JSON or '{}' if the input is empty. func safeJSON(s string) string { if s == "" || s == "null" { return "{}" } s = strings.TrimSpace(s) if !strings.HasPrefix(s, "{") && !strings.HasPrefix(s, "[") { return "{}" } return s } // zeroIfNil returns 0 for a zero value (for nullable ints). func zeroIfNil(n int) *int { if n == 0 { return nil } return &n } // statusFromMonitored returns a media status based on the monitored flag. func statusFromMonitored(monitored bool) string { if monitored { return "unavailable" } return "unavailable" } // isISBN checks if a string looks like an ISBN. func isISBN(s string) bool { s = strings.ReplaceAll(s, "-", "") s = strings.ReplaceAll(s, " ", "") return len(s) == 10 || len(s) == 13 }