638 lines
17 KiB
Go
638 lines
17 KiB
Go
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
|
|
}
|