diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go index 0081b1da..0041214b 100644 --- a/internal/api/admin_lidarr.go +++ b/internal/api/admin_lidarr.go @@ -13,11 +13,12 @@ import ( // lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config // and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set. type lidarrConfigView struct { - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - DefaultQualityProfileID int `json:"default_quality_profile_id"` - DefaultRootFolderPath string `json:"default_root_folder_path"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultMetadataProfileID int `json:"default_metadata_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` } // maskAPIKey converts an api_key for external response: non-empty keys become @@ -33,11 +34,12 @@ func maskAPIKey(key string) string { // api_key masked. func configToView(cfg lidarrconfig.Config) lidarrConfigView { return lidarrConfigView{ - Enabled: cfg.Enabled, - BaseURL: cfg.BaseURL, - APIKey: maskAPIKey(cfg.APIKey), - DefaultQualityProfileID: cfg.DefaultQualityProfileID, - DefaultRootFolderPath: cfg.DefaultRootFolderPath, + Enabled: cfg.Enabled, + BaseURL: cfg.BaseURL, + APIKey: maskAPIKey(cfg.APIKey), + DefaultQualityProfileID: cfg.DefaultQualityProfileID, + DefaultMetadataProfileID: cfg.DefaultMetadataProfileID, + DefaultRootFolderPath: cfg.DefaultRootFolderPath, } } @@ -55,11 +57,12 @@ func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) // putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config. type putLidarrConfigBody struct { - Enabled bool `json:"enabled"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - DefaultQualityProfileID int `json:"default_quality_profile_id"` - DefaultRootFolderPath string `json:"default_root_folder_path"` + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultMetadataProfileID int `json:"default_metadata_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` } // handlePutLidarrConfig implements PUT /api/admin/lidarr/config. @@ -95,21 +98,24 @@ func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) return } // Defaults must be present so future Approve calls don't dispatch - // invalid POST bodies to Lidarr (which produces 5xx with no useful - // detail). The frontend pre-selects first values so this almost + // invalid POST bodies to Lidarr (which produces 4xx with field + // errors). The frontend pre-selects first values so this almost // never fires for non-malicious traffic, but it's the right gate. - if body.DefaultQualityProfileID == 0 || body.DefaultRootFolderPath == "" { + if body.DefaultQualityProfileID == 0 || + body.DefaultMetadataProfileID == 0 || + body.DefaultRootFolderPath == "" { writeAdminJSONErr(w, http.StatusBadRequest, "missing_defaults") return } } cfg := lidarrconfig.Config{ - Enabled: body.Enabled, - BaseURL: body.BaseURL, - APIKey: apiKey, - DefaultQualityProfileID: body.DefaultQualityProfileID, - DefaultRootFolderPath: body.DefaultRootFolderPath, + Enabled: body.Enabled, + BaseURL: body.BaseURL, + APIKey: apiKey, + DefaultQualityProfileID: body.DefaultQualityProfileID, + DefaultMetadataProfileID: body.DefaultMetadataProfileID, + DefaultRootFolderPath: body.DefaultRootFolderPath, } if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil { h.logger.Error("admin: put lidarr config: save", "err", err) @@ -210,6 +216,32 @@ func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Requ writeJSON(w, http.StatusOK, out) } +// metadataProfileView is the JSON shape for a single metadata profile in the +// GET /api/admin/lidarr/metadata-profiles response. +type metadataProfileView struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// handleListMetadataProfiles implements GET /api/admin/lidarr/metadata-profiles. +func (h *handlers) handleListMetadataProfiles(w http.ResponseWriter, r *http.Request) { + _, client, ok := h.lidarrClientFromConfig(w, r) + if !ok { + return + } + + profiles, err := client.ListMetadataProfiles(r.Context()) + if err != nil { + writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err)) + return + } + out := make([]metadataProfileView, len(profiles)) + for i, p := range profiles { + out[i] = metadataProfileView{ID: p.ID, Name: p.Name} + } + writeJSON(w, http.StatusOK, out) +} + // rootFolderView is the JSON shape for a single root folder in the // GET /api/admin/lidarr/root-folders response. type rootFolderView struct { diff --git a/internal/api/api.go b/internal/api/api.go index 8b70c5f2..bb45cceb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -82,6 +82,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Put("/lidarr/config", h.handlePutLidarrConfig) admin.Post("/lidarr/test", h.handleTestLidarrConnection) admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) + admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles) admin.Get("/lidarr/root-folders", h.handleListRootFolders) admin.Get("/requests", h.handleListAdminRequests) admin.Post("/requests/{id}/approve", h.handleApproveRequest) diff --git a/internal/db/dbq/lidarr_config.sql.go b/internal/db/dbq/lidarr_config.sql.go index a6fa50b3..0f849d4a 100644 --- a/internal/db/dbq/lidarr_config.sql.go +++ b/internal/db/dbq/lidarr_config.sql.go @@ -7,24 +7,40 @@ package dbq import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const getLidarrConfig = `-- name: GetLidarrConfig :one SELECT id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at + default_metadata_profile_id, default_root_folder_path, + created_at, updated_at FROM lidarr_config WHERE id = 1 ` -func (q *Queries) GetLidarrConfig(ctx context.Context) (LidarrConfig, error) { +type GetLidarrConfigRow struct { + ID int16 + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultMetadataProfileID *int32 + DefaultRootFolderPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) GetLidarrConfig(ctx context.Context) (GetLidarrConfigRow, error) { row := q.db.QueryRow(ctx, getLidarrConfig) - var i LidarrConfig + var i GetLidarrConfigRow err := row.Scan( &i.ID, &i.Enabled, &i.BaseUrl, &i.ApiKey, &i.DefaultQualityProfileID, + &i.DefaultMetadataProfileID, &i.DefaultRootFolderPath, &i.CreatedAt, &i.UpdatedAt, @@ -34,40 +50,57 @@ func (q *Queries) GetLidarrConfig(ctx context.Context) (LidarrConfig, error) { const updateLidarrConfig = `-- name: UpdateLidarrConfig :one UPDATE lidarr_config - SET enabled = $1, - base_url = $2, - api_key = $3, - default_quality_profile_id = $4, - default_root_folder_path = $5, - updated_at = now() + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_metadata_profile_id = $5, + default_root_folder_path = $6, + updated_at = now() WHERE id = 1 RETURNING id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at + default_metadata_profile_id, default_root_folder_path, + created_at, updated_at ` type UpdateLidarrConfigParams struct { - Enabled bool - BaseUrl *string - ApiKey *string - DefaultQualityProfileID *int32 - DefaultRootFolderPath *string + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultMetadataProfileID *int32 + DefaultRootFolderPath *string } -func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (LidarrConfig, error) { +type UpdateLidarrConfigRow struct { + ID int16 + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultMetadataProfileID *int32 + DefaultRootFolderPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (UpdateLidarrConfigRow, error) { row := q.db.QueryRow(ctx, updateLidarrConfig, arg.Enabled, arg.BaseUrl, arg.ApiKey, arg.DefaultQualityProfileID, + arg.DefaultMetadataProfileID, arg.DefaultRootFolderPath, ) - var i LidarrConfig + var i UpdateLidarrConfigRow err := row.Scan( &i.ID, &i.Enabled, &i.BaseUrl, &i.ApiKey, &i.DefaultQualityProfileID, + &i.DefaultMetadataProfileID, &i.DefaultRootFolderPath, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index be014e20..0fa5b8f8 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -254,14 +254,15 @@ type GeneralLikesArtist struct { } type LidarrConfig struct { - ID int16 - Enabled bool - BaseUrl *string - ApiKey *string - DefaultQualityProfileID *int32 - DefaultRootFolderPath *string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + ID int16 + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultRootFolderPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + DefaultMetadataProfileID *int32 } type LidarrQuarantine struct { diff --git a/internal/db/migrations/0013_lidarr_metadata_profile.down.sql b/internal/db/migrations/0013_lidarr_metadata_profile.down.sql new file mode 100644 index 00000000..4f97bdba --- /dev/null +++ b/internal/db/migrations/0013_lidarr_metadata_profile.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE lidarr_config + DROP COLUMN default_metadata_profile_id; diff --git a/internal/db/migrations/0013_lidarr_metadata_profile.up.sql b/internal/db/migrations/0013_lidarr_metadata_profile.up.sql new file mode 100644 index 00000000..97ac5913 --- /dev/null +++ b/internal/db/migrations/0013_lidarr_metadata_profile.up.sql @@ -0,0 +1,9 @@ +-- M6a follow-up: persist the operator's metadata-profile choice alongside +-- quality profile + root folder. Lidarr POST /api/v1/artist requires +-- metadataProfileId; without storing it we'd refetch and pick first on +-- every Approve, denying the operator a choice. Nullable: existing rows +-- (and operators upgrading from before this migration) get NULL and the +-- service falls back to "fetch list and pick first" until they save. + +ALTER TABLE lidarr_config + ADD COLUMN default_metadata_profile_id integer; diff --git a/internal/db/queries/lidarr_config.sql b/internal/db/queries/lidarr_config.sql index 29f93172..5b31c633 100644 --- a/internal/db/queries/lidarr_config.sql +++ b/internal/db/queries/lidarr_config.sql @@ -1,17 +1,20 @@ -- name: GetLidarrConfig :one SELECT id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at + default_metadata_profile_id, default_root_folder_path, + created_at, updated_at FROM lidarr_config WHERE id = 1; -- name: UpdateLidarrConfig :one UPDATE lidarr_config - SET enabled = $1, - base_url = $2, - api_key = $3, - default_quality_profile_id = $4, - default_root_folder_path = $5, - updated_at = now() + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_metadata_profile_id = $5, + default_root_folder_path = $6, + updated_at = now() WHERE id = 1 RETURNING id, enabled, base_url, api_key, default_quality_profile_id, - default_root_folder_path, created_at, updated_at; + default_metadata_profile_id, default_root_folder_path, + created_at, updated_at; diff --git a/internal/lidarrconfig/service.go b/internal/lidarrconfig/service.go index fc047e1c..05e02693 100644 --- a/internal/lidarrconfig/service.go +++ b/internal/lidarrconfig/service.go @@ -15,11 +15,12 @@ import ( // Config is the typed projection of lidarr_config (no NULL fields exposed // to callers — empty strings / zero ints carry the "unset" meaning). type Config struct { - Enabled bool - BaseURL string - APIKey string - DefaultQualityProfileID int - DefaultRootFolderPath string + Enabled bool + BaseURL string + APIKey string + DefaultQualityProfileID int + DefaultMetadataProfileID int + DefaultRootFolderPath string } // Service reads and writes the singleton. @@ -44,6 +45,9 @@ func (s *Service) Get(ctx context.Context) (Config, error) { if row.DefaultQualityProfileID != nil { cfg.DefaultQualityProfileID = int(*row.DefaultQualityProfileID) } + if row.DefaultMetadataProfileID != nil { + cfg.DefaultMetadataProfileID = int(*row.DefaultMetadataProfileID) + } if row.DefaultRootFolderPath != nil { cfg.DefaultRootFolderPath = *row.DefaultRootFolderPath } @@ -57,14 +61,16 @@ func (s *Service) Save(ctx context.Context, cfg Config) error { baseURL = strPtr(cfg.BaseURL) apiKey = strPtr(cfg.APIKey) qpID = int32Ptr(cfg.DefaultQualityProfileID) + mpID = int32Ptr(cfg.DefaultMetadataProfileID) rootPath = strPtr(cfg.DefaultRootFolderPath) ) _, err := dbq.New(s.pool).UpdateLidarrConfig(ctx, dbq.UpdateLidarrConfigParams{ - Enabled: cfg.Enabled, - BaseUrl: baseURL, - ApiKey: apiKey, - DefaultQualityProfileID: qpID, - DefaultRootFolderPath: rootPath, + Enabled: cfg.Enabled, + BaseUrl: baseURL, + ApiKey: apiKey, + DefaultQualityProfileID: qpID, + DefaultMetadataProfileID: mpID, + DefaultRootFolderPath: rootPath, }) if err != nil { return fmt.Errorf("lidarrconfig: %w", err) diff --git a/internal/lidarrrequests/service.go b/internal/lidarrrequests/service.go index 52b1a07a..9b0b43bd 100644 --- a/internal/lidarrrequests/service.go +++ b/internal/lidarrrequests/service.go @@ -179,20 +179,21 @@ func (s *Service) Approve(ctx context.Context, requestID pgtype.UUID, adminID pg } // Lidarr requires a metadata profile id on POST /api/v1/artist (and - // on /api/v1/album when it has to create the parent artist). We - // don't store this in lidarr_config yet — fetch the available - // profiles per-Approve and pick the first as a sensible default. - // Lidarr installs ship "Standard" at id=1 OOB, so this works for - // vanilla setups; operators with custom profiles get whichever was - // saved first. A picker on /admin/integrations is a follow-up. - mdProfiles, err := client.ListMetadataProfiles(ctx) - if err != nil { - return dbq.LidarrRequest{}, fmt.Errorf("approve: list metadata profiles: %w", err) + // on /api/v1/album when it has to create the parent artist). Prefer + // the operator's pinned choice from cfg; fall back to fetching the + // list and using the first when nothing's been saved yet (operators + // upgrading from before metadata-profile support landed). + mdProfileID := cfg.DefaultMetadataProfileID + if mdProfileID == 0 { + mdProfiles, mdErr := client.ListMetadataProfiles(ctx) + if mdErr != nil { + return dbq.LidarrRequest{}, fmt.Errorf("approve: list metadata profiles: %w", mdErr) + } + if len(mdProfiles) == 0 { + return dbq.LidarrRequest{}, fmt.Errorf("approve: no metadata profiles configured in lidarr") + } + mdProfileID = mdProfiles[0].ID } - if len(mdProfiles) == 0 { - return dbq.LidarrRequest{}, fmt.Errorf("approve: no metadata profiles configured in lidarr") - } - mdProfileID := mdProfiles[0].ID switch row.Kind { case dbq.LidarrRequestKindArtist: diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts index dcbed704..2654ed42 100644 --- a/web/src/lib/api/admin.test.ts +++ b/web/src/lib/api/admin.test.ts @@ -41,6 +41,7 @@ const baseConfig: LidarrConfig = { base_url: 'http://lidarr.lan:8686', api_key: '***', default_quality_profile_id: 1, + default_metadata_profile_id: 1, default_root_folder_path: '/music' }; diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index fe6b7d83..0c14d01f 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -5,6 +5,7 @@ import type { ActionResult, AdminQuarantineRow, LidarrConfig, + LidarrMetadataProfile, LidarrQualityProfile, LidarrQuarantineActionRow, LidarrRequest, @@ -36,6 +37,10 @@ export async function listQualityProfiles(): Promise { return api.get('/api/admin/lidarr/quality-profiles'); } +export async function listMetadataProfiles(): Promise { + return api.get('/api/admin/lidarr/metadata-profiles'); +} + export async function listRootFolders(): Promise { return api.get('/api/admin/lidarr/root-folders'); } @@ -92,6 +97,14 @@ export function createQualityProfilesQuery(enabled: boolean = true) { }); } +export function createMetadataProfilesQuery(enabled: boolean = true) { + return createQuery({ + queryKey: qk.lidarrMetadataProfiles(), + queryFn: listMetadataProfiles, + enabled + }); +} + export function createRootFoldersQuery(enabled: boolean = true) { return createQuery({ queryKey: qk.lidarrRootFolders(), diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 07d92820..b6991e5d 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -24,8 +24,9 @@ export const qk = { ['lidarrSearch', { q, kind }] as const, myRequests: () => ['myRequests'] as const, lidarrConfig: () => ['lidarrConfig'] as const, - lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, - lidarrRootFolders: () => ['lidarrRootFolders'] as const, + lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, + lidarrMetadataProfiles: () => ['lidarrMetadataProfiles'] as const, + lidarrRootFolders: () => ['lidarrRootFolders'] as const, adminRequests: (status?: string) => ['adminRequests', { status: status ?? 'all' }] as const, myQuarantine: () => ['myQuarantine'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 1ee37d68..f863c76d 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -131,6 +131,7 @@ export type LidarrConfig = { base_url: string; api_key: string; default_quality_profile_id: number; + default_metadata_profile_id: number; default_root_folder_path: string; }; @@ -139,6 +140,11 @@ export type LidarrQualityProfile = { name: string; }; +export type LidarrMetadataProfile = { + id: number; + name: string; +}; + export type LidarrRootFolder = { path: string; accessible: boolean; diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index d707edb3..b945cada 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -4,6 +4,7 @@ import { createLidarrConfigQuery, createQualityProfilesQuery, + createMetadataProfilesQuery, createRootFoldersQuery, putLidarrConfig, testLidarrConnection @@ -26,6 +27,7 @@ let baseUrl = $state(''); let apiKeyInput = $state(''); // intentionally never seeded with '***' let qualityId = $state(0); + let metadataId = $state(0); let rootPath = $state(''); let initialized = false; @@ -34,6 +36,7 @@ if (c && !initialized) { baseUrl = c.base_url; qualityId = c.default_quality_profile_id; + metadataId = c.default_metadata_profile_id; rootPath = c.default_root_folder_path; initialized = true; } @@ -41,13 +44,18 @@ // Auto-default to the first option Lidarr returns when the operator // hasn't picked one yet. Saves a click for the common case (one - // quality profile + one root folder, which is what most home setups - // have). The operator can still change either before saving. + // profile + one root folder, which is what most home setups have). + // The operator can still change any of them before saving. $effect(() => { if (qualityId === 0 && profiles.data && profiles.data.length > 0) { qualityId = profiles.data[0].id; } }); + $effect(() => { + if (metadataId === 0 && metadataProfiles.data && metadataProfiles.data.length > 0) { + metadataId = metadataProfiles.data[0].id; + } + }); $effect(() => { if (rootPath === '' && folders.data && folders.data.length > 0) { rootPath = folders.data[0].path; @@ -61,6 +69,9 @@ const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled)); const profiles = $derived($profilesStore); + const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled)); + const metadataProfiles = $derived($metadataProfilesStore); + const foldersStore = $derived(createRootFoldersQuery(profilesEnabled)); const folders = $derived($foldersStore); @@ -78,6 +89,7 @@ base_url: baseUrl, api_key: apiKeyInput, // empty string tells backend "preserve saved key" default_quality_profile_id: qualityId, + default_metadata_profile_id: metadataId, default_root_folder_path: rootPath }; await putLidarrConfig(cfg); @@ -86,6 +98,7 @@ await Promise.all([ client.invalidateQueries({ queryKey: qk.lidarrConfig() }), client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }), + client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }), client.invalidateQueries({ queryKey: qk.lidarrRootFolders() }) ]); apiKeyInput = ''; @@ -126,11 +139,13 @@ base_url: '', api_key: '', default_quality_profile_id: 0, + default_metadata_profile_id: 0, default_root_folder_path: '' }); await Promise.all([ client.invalidateQueries({ queryKey: qk.lidarrConfig() }), client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }), + client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }), client.invalidateQueries({ queryKey: qk.lidarrRootFolders() }) ]); modalOpen = false; @@ -212,6 +227,19 @@ + +