Files
2026-04-24 10:45:19 -07:00

1037 lines
26 KiB
Go

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
}