1037 lines
26 KiB
Go
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
|
|
}
|