Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

1036
internal/migrate/import.go Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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
}