package migrate import ( "database/sql" "encoding/json" "fmt" _ "github.com/mattn/go-sqlite3" ) // SonarrSeries represents a series from Sonarr's SQLite database. type SonarrSeries struct { ID int64 TVDBID int64 Title string SortTitle string Year int Status string Monitored bool QualityProfileID int64 RootFolderPath string Tags string // JSON array of tag IDs Overview string Images string // JSON Runtime int } // SonarrEpisode represents an episode from Sonarr's SQLite database. type SonarrEpisode struct { ID int64 SeriesID int64 SeasonNumber int EpisodeNumber int Title string AirDate string Monitored bool HasFile bool } // EpisodeFile represents an episode file from Sonarr's SQLite database. type EpisodeFile struct { ID int64 SeriesID int64 SeasonNumber int RelativePath string Path string Size int64 Quality string // JSON DateAdded string } // RadarrMovie represents a movie from Radarr's SQLite database. type RadarrMovie struct { ID int64 TMDBID int64 Title string SortTitle string Year int Status string Monitored bool QualityProfileID int64 RootFolderPath string HasFile bool MovieFileID int64 Overview string Images string // JSON Runtime int } // MovieFile represents a movie file from Radarr's SQLite database. type MovieFile struct { ID int64 MovieID int64 RelativePath string Path string Size int64 Quality string // JSON DateAdded string } // LidarrArtist represents an artist from Lidarr's SQLite database. type LidarrArtist struct { ID int64 ForeignArtistID string // MusicBrainz ID Name string Status string Monitored bool QualityProfileID int64 RootFolderPath string Overview string Images string } // LidarrAlbum represents an album from Lidarr's SQLite database. type LidarrAlbum struct { ID int64 ArtistID int64 ForeignAlbumID string // MusicBrainz ID Title string Year int Monitored bool AlbumType string } // ReadarrBook represents a book from Readarr's SQLite database. type ReadarrBook struct { ID int64 ForeignBookID string // ISBN or Goodreads ID Title string AuthorID int64 AuthorName string Monitored bool QualityProfileID int64 Overview string Images string } // ProwlarrIndexer represents an indexer from Prowlarr's SQLite database. type ProwlarrIndexer struct { ID int64 Name string Implementation string Settings string // JSON with url, apiKey Enable bool Priority int } // ArrQualityProfile represents a quality profile from any arr app. type ArrQualityProfile struct { ID int64 Name string Items string // JSON Cutoff int64 } // ArrRootFolder represents a root folder from any arr app. type ArrRootFolder struct { ID int64 Path string } // ArrBlocklistEntry represents a blocklist entry from any arr app. type ArrBlocklistEntry struct { ID int64 Title string Quality string SourceTitle string Date string TorrentHash string Size int64 Protocol string Message string } // ArrTag represents a tag from any arr app. type ArrTag struct { ID int64 Label string } // TrackFile represents a music track file from Lidarr's SQLite database. type TrackFile struct { ID int64 ArtistID int64 AlbumID int64 RelativePath string Path string Size int64 Quality string // JSON DateAdded string } // BookFile represents a book file from Readarr's SQLite database. type BookFile struct { ID int64 BookID int64 RelativePath string Path string Size int64 Quality string // JSON DateAdded string } // QualityItem represents a node in the arr quality profile Items JSON tree. type QualityItem struct { Quality *QualityDef `json:"quality"` Items []QualityItem `json:"items"` Allowed bool `json:"allowed"` ID int64 `json:"id"` Name string `json:"name"` } // QualityDef represents a quality definition from arr. type QualityDef struct { ID int64 `json:"id"` Name string `json:"name"` Source string `json:"source"` Resolution int `json:"resolution"` } // ArrReader reads from arr SQLite databases. type ArrReader struct { db *sql.DB } // NewArrReader opens a read-only connection to an arr SQLite database. func NewArrReader(dbPath string) (*ArrReader, error) { db, err := sql.Open("sqlite3", dbPath+"?mode=ro") if err != nil { return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err) } if err := db.Ping(); err != nil { db.Close() return nil, fmt.Errorf("ping sqlite %s: %w", dbPath, err) } return &ArrReader{db: db}, nil } // Close closes the SQLite connection. func (r *ArrReader) Close() error { if r.db != nil { return r.db.Close() } return nil } // ReadSonarrSeries reads all series from a Sonarr database. func (r *ArrReader) ReadSonarrSeries() ([]SonarrSeries, error) { rows, err := r.db.Query(` SELECT Id, TvdbId, Title, COALESCE(SortTitle, Title), COALESCE(Year, 0), COALESCE(Status, ''), Monitored, COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''), COALESCE(Tags, '[]'), COALESCE(Overview, ''), COALESCE(Images, '[]'), COALESCE(Runtime, 0) FROM Series`) if err != nil { return nil, fmt.Errorf("query sonarr series: %w", err) } defer rows.Close() var series []SonarrSeries for rows.Next() { var s SonarrSeries if err := rows.Scan(&s.ID, &s.TVDBID, &s.Title, &s.SortTitle, &s.Year, &s.Status, &s.Monitored, &s.QualityProfileID, &s.RootFolderPath, &s.Tags, &s.Overview, &s.Images, &s.Runtime); err != nil { continue } series = append(series, s) } return series, nil } // ReadSonarrEpisodes reads episodes for a specific series. func (r *ArrReader) ReadSonarrEpisodes(seriesID int64) ([]SonarrEpisode, error) { rows, err := r.db.Query(` SELECT Id, SeriesId, SeasonNumber, EpisodeNumber, Title, COALESCE(AirDate, ''), Monitored, COALESCE(HasFile, 0) FROM Episodes WHERE SeriesId = ?`, seriesID) if err != nil { return nil, fmt.Errorf("query sonarr episodes: %w", err) } defer rows.Close() var episodes []SonarrEpisode for rows.Next() { var e SonarrEpisode if err := rows.Scan(&e.ID, &e.SeriesID, &e.SeasonNumber, &e.EpisodeNumber, &e.Title, &e.AirDate, &e.Monitored, &e.HasFile); err != nil { continue } episodes = append(episodes, e) } return episodes, nil } // ReadEpisodeFiles reads all episode files from a Sonarr database. func (r *ArrReader) ReadEpisodeFiles() ([]EpisodeFile, error) { rows, err := r.db.Query(` SELECT Id, SeriesId, SeasonNumber, COALESCE(RelativePath, ''), COALESCE(Path, ''), COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '') FROM EpisodeFiles`) if err != nil { return nil, fmt.Errorf("query episode files: %w", err) } defer rows.Close() var files []EpisodeFile for rows.Next() { var f EpisodeFile if err := rows.Scan(&f.ID, &f.SeriesID, &f.SeasonNumber, &f.RelativePath, &f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil { continue } files = append(files, f) } return files, nil } // ReadRadarrMovies reads all movies from a Radarr database. func (r *ArrReader) ReadRadarrMovies() ([]RadarrMovie, error) { rows, err := r.db.Query(` SELECT Id, TmdbId, Title, COALESCE(SortTitle, Title), COALESCE(Year, 0), COALESCE(Status, ''), Monitored, COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''), COALESCE(HasFile, 0), COALESCE(MovieFileId, 0), COALESCE(Overview, ''), COALESCE(Images, '[]'), COALESCE(Runtime, 0) FROM Movies`) if err != nil { return nil, fmt.Errorf("query radarr movies: %w", err) } defer rows.Close() var movies []RadarrMovie for rows.Next() { var m RadarrMovie if err := rows.Scan(&m.ID, &m.TMDBID, &m.Title, &m.SortTitle, &m.Year, &m.Status, &m.Monitored, &m.QualityProfileID, &m.RootFolderPath, &m.HasFile, &m.MovieFileID, &m.Overview, &m.Images, &m.Runtime); err != nil { continue } movies = append(movies, m) } return movies, nil } // ReadMovieFiles reads all movie files from a Radarr database. func (r *ArrReader) ReadMovieFiles() ([]MovieFile, error) { rows, err := r.db.Query(` SELECT Id, MovieId, COALESCE(RelativePath, ''), COALESCE(Path, ''), COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '') FROM MovieFiles`) if err != nil { return nil, fmt.Errorf("query movie files: %w", err) } defer rows.Close() var files []MovieFile for rows.Next() { var f MovieFile if err := rows.Scan(&f.ID, &f.MovieID, &f.RelativePath, &f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil { continue } files = append(files, f) } return files, nil } // ReadLidarrArtists reads all artists from a Lidarr database. func (r *ArrReader) ReadLidarrArtists() ([]LidarrArtist, error) { rows, err := r.db.Query(` SELECT Id, COALESCE(ForeignArtistId, ''), Name, COALESCE(Status, ''), Monitored, COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''), COALESCE(Overview, ''), COALESCE(Images, '[]') FROM Artists`) if err != nil { return nil, fmt.Errorf("query lidarr artists: %w", err) } defer rows.Close() var artists []LidarrArtist for rows.Next() { var a LidarrArtist if err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.Status, &a.Monitored, &a.QualityProfileID, &a.RootFolderPath, &a.Overview, &a.Images); err != nil { continue } artists = append(artists, a) } return artists, nil } // ReadLidarrAlbums reads all albums from a Lidarr database. func (r *ArrReader) ReadLidarrAlbums() ([]LidarrAlbum, error) { rows, err := r.db.Query(` SELECT Id, COALESCE(ArtistId, 0), COALESCE(ForeignAlbumId, ''), Title, COALESCE(Year, 0), Monitored, COALESCE(AlbumType, '') FROM Albums`) if err != nil { return nil, fmt.Errorf("query lidarr albums: %w", err) } defer rows.Close() var albums []LidarrAlbum for rows.Next() { var a LidarrAlbum if err := rows.Scan(&a.ID, &a.ArtistID, &a.ForeignAlbumID, &a.Title, &a.Year, &a.Monitored, &a.AlbumType); err != nil { continue } albums = append(albums, a) } return albums, nil } // ReadReadarrBooks reads all books from a Readarr database. func (r *ArrReader) ReadReadarrBooks() ([]ReadarrBook, error) { rows, err := r.db.Query(` SELECT b.Id, COALESCE(b.ForeignBookId, ''), b.Title, COALESCE(b.AuthorId, 0), COALESCE(a.Name, ''), b.Monitored, COALESCE(b.QualityProfileId, 0), COALESCE(b.Overview, ''), COALESCE(b.Images, '[]') FROM Books b LEFT JOIN Authors a ON b.AuthorId = a.Id`) if err != nil { return nil, fmt.Errorf("query readarr books: %w", err) } defer rows.Close() var books []ReadarrBook for rows.Next() { var b ReadarrBook if err := rows.Scan(&b.ID, &b.ForeignBookID, &b.Title, &b.AuthorID, &b.AuthorName, &b.Monitored, &b.QualityProfileID, &b.Overview, &b.Images); err != nil { continue } books = append(books, b) } return books, nil } // ReadProwlarrIndexers reads all indexers from a Prowlarr database. func (r *ArrReader) ReadProwlarrIndexers() ([]ProwlarrIndexer, error) { rows, err := r.db.Query(` SELECT Id, Name, COALESCE(Implementation, ''), COALESCE(Settings, '{}'), COALESCE(Enable, 1), COALESCE(Priority, 0) FROM Indexers`) if err != nil { return nil, fmt.Errorf("query prowlarr indexers: %w", err) } defer rows.Close() var indexers []ProwlarrIndexer for rows.Next() { var idx ProwlarrIndexer if err := rows.Scan(&idx.ID, &idx.Name, &idx.Implementation, &idx.Settings, &idx.Enable, &idx.Priority); err != nil { continue } indexers = append(indexers, idx) } return indexers, nil } // ReadQualityProfiles reads quality profiles from any arr database. func (r *ArrReader) ReadQualityProfiles() ([]ArrQualityProfile, error) { rows, err := r.db.Query(` SELECT Id, Name, COALESCE(Items, '[]'), COALESCE(Cutoff, 0) FROM QualityProfiles`) if err != nil { return nil, fmt.Errorf("query quality profiles: %w", err) } defer rows.Close() var profiles []ArrQualityProfile for rows.Next() { var p ArrQualityProfile if err := rows.Scan(&p.ID, &p.Name, &p.Items, &p.Cutoff); err != nil { continue } profiles = append(profiles, p) } return profiles, nil } // ReadRootFolders reads root folders from any arr database. func (r *ArrReader) ReadRootFolders() ([]ArrRootFolder, error) { rows, err := r.db.Query(`SELECT Id, Path FROM RootFolders`) if err != nil { return nil, fmt.Errorf("query root folders: %w", err) } defer rows.Close() var folders []ArrRootFolder for rows.Next() { var f ArrRootFolder if err := rows.Scan(&f.ID, &f.Path); err != nil { continue } folders = append(folders, f) } return folders, nil } // ReadBlocklist reads blocklist entries from any arr database. func (r *ArrReader) ReadBlocklist() ([]ArrBlocklistEntry, error) { rows, err := r.db.Query(` SELECT Id, COALESCE(SeriesTitle, COALESCE(SourceTitle, '')), COALESCE(Quality, '{}'), COALESCE(SourceTitle, ''), COALESCE(Date, ''), COALESCE(TorrentHash, ''), COALESCE(Size, 0), COALESCE(Protocol, 'torrent'), COALESCE(Message, '') FROM Blocklist`) if err != nil { return nil, fmt.Errorf("query blocklist: %w", err) } defer rows.Close() var entries []ArrBlocklistEntry for rows.Next() { var e ArrBlocklistEntry if err := rows.Scan(&e.ID, &e.Title, &e.Quality, &e.SourceTitle, &e.Date, &e.TorrentHash, &e.Size, &e.Protocol, &e.Message); err != nil { continue } entries = append(entries, e) } return entries, nil } // ReadTags reads tags from any arr database. func (r *ArrReader) ReadTags() ([]ArrTag, error) { rows, err := r.db.Query(`SELECT Id, Label FROM Tags`) if err != nil { return nil, fmt.Errorf("query tags: %w", err) } defer rows.Close() var tags []ArrTag for rows.Next() { var t ArrTag if err := rows.Scan(&t.ID, &t.Label); err != nil { continue } tags = append(tags, t) } return tags, nil } // ReadTrackFiles reads track files from a Lidarr database. func (r *ArrReader) ReadTrackFiles() ([]TrackFile, error) { rows, err := r.db.Query(` SELECT Id, COALESCE(ArtistId, 0), COALESCE(AlbumId, 0), COALESCE(RelativePath, ''), COALESCE(Path, ''), COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '') FROM TrackFiles`) if err != nil { return nil, fmt.Errorf("query track files: %w", err) } defer rows.Close() var files []TrackFile for rows.Next() { var f TrackFile if err := rows.Scan(&f.ID, &f.ArtistID, &f.AlbumID, &f.RelativePath, &f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil { continue } files = append(files, f) } return files, nil } // ReadBookFiles reads book files from a Readarr database. func (r *ArrReader) ReadBookFiles() ([]BookFile, error) { rows, err := r.db.Query(` SELECT Id, COALESCE(BookId, 0), COALESCE(RelativePath, ''), COALESCE(Path, ''), COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '') FROM BookFiles`) if err != nil { return nil, fmt.Errorf("query book files: %w", err) } defer rows.Close() var files []BookFile for rows.Next() { var f BookFile if err := rows.Scan(&f.ID, &f.BookID, &f.RelativePath, &f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil { continue } files = append(files, f) } return files, nil } // ParseIndexerSettings extracts URL and API key from Prowlarr indexer settings JSON. func ParseIndexerSettings(settingsJSON string) (url, apiKey string) { var settings map[string]interface{} if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { return "", "" } if u, ok := settings["url"].(string); ok { url = u } if k, ok := settings["apiKey"].(string); ok { apiKey = k } return url, apiKey } // ExtractAllowedQualities recursively extracts allowed quality names from arr Items JSON. func ExtractAllowedQualities(items []QualityItem) []string { var names []string for _, item := range items { if item.Allowed && item.Quality != nil && item.Quality.Name != "" { names = append(names, item.Quality.Name) } if len(item.Items) > 0 { names = append(names, ExtractAllowedQualities(item.Items)...) } } return names } // FindCutoffName finds the quality name matching the cutoff ID in the Items tree. func FindCutoffName(items []QualityItem, cutoffID int64) string { for _, item := range items { if item.Quality != nil && item.Quality.ID == cutoffID { return item.Quality.Name } if len(item.Items) > 0 { if name := FindCutoffName(item.Items, cutoffID); name != "" { return name } } } return "" } // ParseQualityItems parses the Items JSON from an arr quality profile. func ParseQualityItems(itemsJSON string) []QualityItem { var items []QualityItem if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil { return nil } return items }