Sync from /srv/compose/unified-media-manager
This commit is contained in:
1036
internal/migrate/import.go
Normal file
1036
internal/migrate/import.go
Normal file
File diff suppressed because it is too large
Load Diff
172
internal/migrate/migrator.go
Normal file
172
internal/migrate/migrator.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
// ArrSources holds paths to arr SQLite databases.
|
||||
type ArrSources struct {
|
||||
Sonarr string
|
||||
Radarr string
|
||||
SonarrAnime string
|
||||
RadarrAnime string
|
||||
Lidarr string
|
||||
Readarr string
|
||||
Prowlarr string
|
||||
}
|
||||
|
||||
// ImportCount tracks imported and skipped counts for a category.
|
||||
type ImportCount struct {
|
||||
Imported int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
// Report holds the migration results.
|
||||
type Report struct {
|
||||
Indexers ImportCount
|
||||
Series ImportCount
|
||||
Movies ImportCount
|
||||
Albums ImportCount
|
||||
Books ImportCount
|
||||
Files ImportCount
|
||||
Profiles ImportCount
|
||||
RootFolders ImportCount
|
||||
Tags ImportCount
|
||||
Blocklist ImportCount
|
||||
Errors int
|
||||
}
|
||||
|
||||
func (r *Report) String() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Migration Report:\n")
|
||||
fmt.Fprintf(&b, " Indexers: %d imported, %d skipped\n", r.Indexers.Imported, r.Indexers.Skipped)
|
||||
fmt.Fprintf(&b, " Series: %d imported, %d deduplicated\n", r.Series.Imported, r.Series.Skipped)
|
||||
fmt.Fprintf(&b, " Movies: %d imported, %d deduplicated\n", r.Movies.Imported, r.Movies.Skipped)
|
||||
fmt.Fprintf(&b, " Albums: %d imported, %d deduplicated\n", r.Albums.Imported, r.Albums.Skipped)
|
||||
fmt.Fprintf(&b, " Books: %d imported, %d deduplicated\n", r.Books.Imported, r.Books.Skipped)
|
||||
fmt.Fprintf(&b, " Files: %d imported\n", r.Files.Imported)
|
||||
fmt.Fprintf(&b, " Profiles: %d imported\n", r.Profiles.Imported)
|
||||
fmt.Fprintf(&b, " Root Folders: %d imported\n", r.RootFolders.Imported)
|
||||
fmt.Fprintf(&b, " Tags: %d imported\n", r.Tags.Imported)
|
||||
fmt.Fprintf(&b, " Blocklist: %d imported\n", r.Blocklist.Imported)
|
||||
fmt.Fprintf(&b, " Errors: %d\n", r.Errors)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Migrator orchestrates the data migration from arr SQLite databases to UMM PostgreSQL.
|
||||
type Migrator struct {
|
||||
db *db.DB
|
||||
sources ArrSources
|
||||
report Report
|
||||
|
||||
// Maps arr entity IDs to UMM media IDs, keyed by arr instance
|
||||
sonarrSeriesMap map[int64]int64 // sonarr series ID → UMM media ID
|
||||
sonarrAnimeSeriesMap map[int64]int64 // sonarr-anime series ID → UMM media ID
|
||||
radarrMovieMap map[int64]int64 // radarr movie ID → UMM media ID
|
||||
radarrAnimeMovieMap map[int64]int64 // radarr-anime movie ID → UMM media ID
|
||||
lidarrAlbumMap map[int64]int64 // lidarr album ID → UMM media ID
|
||||
readarrBookMap map[int64]int64 // readarr book ID → UMM media ID
|
||||
}
|
||||
|
||||
// NewMigrator creates a new Migrator instance.
|
||||
func NewMigrator(database *db.DB, sources ArrSources) *Migrator {
|
||||
return &Migrator{
|
||||
db: database,
|
||||
sources: sources,
|
||||
sonarrSeriesMap: make(map[int64]int64),
|
||||
sonarrAnimeSeriesMap: make(map[int64]int64),
|
||||
radarrMovieMap: make(map[int64]int64),
|
||||
radarrAnimeMovieMap: make(map[int64]int64),
|
||||
lidarrAlbumMap: make(map[int64]int64),
|
||||
readarrBookMap: make(map[int64]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes the full migration pipeline.
|
||||
func (m *Migrator) Run(ctx context.Context) (*Report, error) {
|
||||
slog.Info("starting arr data migration")
|
||||
|
||||
// Step 1: Import Prowlarr indexers
|
||||
if m.sources.Prowlarr != "" {
|
||||
slog.Info("importing prowlarr indexers", "path", m.sources.Prowlarr)
|
||||
if err := m.importProwlarr(ctx); err != nil {
|
||||
slog.Error("failed to import prowlarr", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Import Sonarr series
|
||||
if m.sources.Sonarr != "" {
|
||||
slog.Info("importing sonarr series", "path", m.sources.Sonarr)
|
||||
if err := m.importSonarr(ctx, m.sources.Sonarr, false); err != nil {
|
||||
slog.Error("failed to import sonarr", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Import Sonarr-anime (deduplicate by TVDB ID)
|
||||
if m.sources.SonarrAnime != "" {
|
||||
slog.Info("importing sonarr-anime series", "path", m.sources.SonarrAnime)
|
||||
if err := m.importSonarr(ctx, m.sources.SonarrAnime, true); err != nil {
|
||||
slog.Error("failed to import sonarr-anime", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Import Radarr movies
|
||||
if m.sources.Radarr != "" {
|
||||
slog.Info("importing radarr movies", "path", m.sources.Radarr)
|
||||
if err := m.importRadarr(ctx, m.sources.Radarr, false); err != nil {
|
||||
slog.Error("failed to import radarr", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Import Radarr-anime (deduplicate by TMDB ID)
|
||||
if m.sources.RadarrAnime != "" {
|
||||
slog.Info("importing radarr-anime movies", "path", m.sources.RadarrAnime)
|
||||
if err := m.importRadarr(ctx, m.sources.RadarrAnime, true); err != nil {
|
||||
slog.Error("failed to import radarr-anime", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Import Lidarr artists + albums
|
||||
if m.sources.Lidarr != "" {
|
||||
slog.Info("importing lidarr", "path", m.sources.Lidarr)
|
||||
if err := m.importLidarr(ctx); err != nil {
|
||||
slog.Error("failed to import lidarr", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Import Readarr books
|
||||
if m.sources.Readarr != "" {
|
||||
slog.Info("importing readarr", "path", m.sources.Readarr)
|
||||
if err := m.importReadarr(ctx); err != nil {
|
||||
slog.Error("failed to import readarr", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Reset PostgreSQL sequences
|
||||
if err := m.resetSequences(ctx); err != nil {
|
||||
slog.Error("failed to reset sequences", "error", err)
|
||||
m.report.Errors++
|
||||
}
|
||||
|
||||
slog.Info("migration complete",
|
||||
"series", m.report.Series.Imported,
|
||||
"movies", m.report.Movies.Imported,
|
||||
"albums", m.report.Albums.Imported,
|
||||
"books", m.report.Books.Imported,
|
||||
"files", m.report.Files.Imported,
|
||||
"errors", m.report.Errors,
|
||||
)
|
||||
return &m.report, nil
|
||||
}
|
||||
637
internal/migrate/reader.go
Normal file
637
internal/migrate/reader.go
Normal file
@@ -0,0 +1,637 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user