diff --git a/web/src/lib/api/admin.test.ts b/web/src/lib/api/admin.test.ts new file mode 100644 index 00000000..90e76954 --- /dev/null +++ b/web/src/lib/api/admin.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + } +})); + +import { + getLidarrConfig, + putLidarrConfig, + testLidarrConnection, + listQualityProfiles, + listRootFolders, + listAdminRequests, + approveRequest, + rejectRequest +} from './admin'; +import { api } from './client'; +import { qk } from './queries'; +import type { + LidarrConfig, + LidarrQualityProfile, + LidarrRequest, + LidarrRootFolder +} from './types'; + +const baseConfig: LidarrConfig = { + enabled: true, + base_url: 'http://lidarr.lan:8686', + api_key: '***', + default_quality_profile_id: 1, + default_root_folder_path: '/music' +}; + +const baseRow: LidarrRequest = { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: null, + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getLidarrConfig', () => { + test('GETs /api/admin/lidarr/config', async () => { + (api.get as ReturnType).mockResolvedValueOnce(baseConfig); + const out = await getLidarrConfig(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/config'); + expect(out).toBe(baseConfig); + }); +}); + +describe('putLidarrConfig', () => { + test('PUTs /api/admin/lidarr/config with the full config body', async () => { + const next: LidarrConfig = { ...baseConfig, api_key: 'plain-key' }; + (api.put as ReturnType).mockResolvedValueOnce(next); + const out = await putLidarrConfig(next); + expect(api.put).toHaveBeenCalledWith('/api/admin/lidarr/config', next); + expect(out).toBe(next); + }); +}); + +describe('testLidarrConnection', () => { + test('POSTs /api/admin/lidarr/test with empty body when no overrides', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: true, + version: '2.0.0' + }); + const out = await testLidarrConnection(); + expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', {}); + expect(out).toEqual({ ok: true, version: '2.0.0' }); + }); + + test('forwards override base_url + api_key body', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: true, + version: '2.0.0' + }); + await testLidarrConnection({ + base_url: 'http://other:8686', + api_key: 'try-this' + }); + expect(api.post).toHaveBeenCalledWith('/api/admin/lidarr/test', { + base_url: 'http://other:8686', + api_key: 'try-this' + }); + }); + + test('returns ok:false branch without throwing', async () => { + (api.post as ReturnType).mockResolvedValueOnce({ + ok: false, + error: 'auth failed' + }); + const out = await testLidarrConnection(); + expect(out).toEqual({ ok: false, error: 'auth failed' }); + }); +}); + +describe('listQualityProfiles', () => { + test('GETs /api/admin/lidarr/quality-profiles', async () => { + const profiles: LidarrQualityProfile[] = [{ id: 1, name: 'Lossless' }]; + (api.get as ReturnType).mockResolvedValueOnce(profiles); + const out = await listQualityProfiles(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/quality-profiles'); + expect(out).toBe(profiles); + }); +}); + +describe('listRootFolders', () => { + test('GETs /api/admin/lidarr/root-folders', async () => { + const folders: LidarrRootFolder[] = [ + { path: '/music', accessible: true, free_space: 100 } + ]; + (api.get as ReturnType).mockResolvedValueOnce(folders); + const out = await listRootFolders(); + expect(api.get).toHaveBeenCalledWith('/api/admin/lidarr/root-folders'); + expect(out).toBe(folders); + }); +}); + +describe('listAdminRequests', () => { + test('no args -> /api/admin/requests with no query string', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests(); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests'); + }); + + test('status only -> ?status=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests('approved'); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests?status=approved'); + }); + + test('status + limit -> ?status=&limit=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests('pending', 25); + expect(api.get).toHaveBeenCalledWith( + '/api/admin/requests?status=pending&limit=25' + ); + }); + + test('limit only -> ?limit=', async () => { + (api.get as ReturnType).mockResolvedValueOnce([baseRow]); + await listAdminRequests(undefined, 10); + expect(api.get).toHaveBeenCalledWith('/api/admin/requests?limit=10'); + }); +}); + +describe('approveRequest', () => { + test('POSTs to /api/admin/requests/:id/approve with empty body by default', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await approveRequest('r1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', {}); + }); + + test('forwards quality_profile_id + root_folder_path overrides', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await approveRequest('r1', { + quality_profile_id: 2, + root_folder_path: '/music' + }); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/approve', { + quality_profile_id: 2, + root_folder_path: '/music' + }); + }); +}); + +describe('rejectRequest', () => { + test('POSTs to /api/admin/requests/:id/reject with empty body when no notes', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await rejectRequest('r1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {}); + }); + + test('includes notes when provided', async () => { + (api.post as ReturnType).mockResolvedValueOnce(baseRow); + await rejectRequest('r1', 'duplicate of r0'); + expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', { + notes: 'duplicate of r0' + }); + }); +}); + +describe('qk admin keys', () => { + test('lidarrConfig key', () => { + expect(qk.lidarrConfig()).toEqual(['lidarrConfig']); + }); + test('lidarrQualityProfiles key', () => { + expect(qk.lidarrQualityProfiles()).toEqual(['lidarrQualityProfiles']); + }); + test('lidarrRootFolders key', () => { + expect(qk.lidarrRootFolders()).toEqual(['lidarrRootFolders']); + }); + test('adminRequests key defaults to pending when status omitted', () => { + expect(qk.adminRequests()).toEqual(['adminRequests', { status: 'pending' }]); + }); + test('adminRequests key includes the status filter', () => { + expect(qk.adminRequests('approved')).toEqual([ + 'adminRequests', + { status: 'approved' } + ]); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts new file mode 100644 index 00000000..bcf228f0 --- /dev/null +++ b/web/src/lib/api/admin.ts @@ -0,0 +1,104 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { + LidarrConfig, + LidarrQualityProfile, + LidarrRequest, + LidarrRequestStatus, + LidarrRootFolder, + LidarrTestResult +} from './types'; + +// Admin Lidarr config ----------------------------------------------------- + +export async function getLidarrConfig(): Promise { + return api.get('/api/admin/lidarr/config'); +} + +export async function putLidarrConfig(cfg: LidarrConfig): Promise { + return api.put('/api/admin/lidarr/config', cfg); +} + +// testLidarrConnection always returns 200 with a discriminated-union body — +// callers branch on `result.ok`. We do NOT throw on `ok:false`; the SPA wants +// to render either branch (e.g. "connected to Lidarr X.Y.Z" vs "auth failed"). +export async function testLidarrConnection( + body: { base_url?: string; api_key?: string } = {} +): Promise { + return api.post('/api/admin/lidarr/test', body); +} + +export async function listQualityProfiles(): Promise { + return api.get('/api/admin/lidarr/quality-profiles'); +} + +export async function listRootFolders(): Promise { + return api.get('/api/admin/lidarr/root-folders'); +} + +// Admin request queue ----------------------------------------------------- + +export async function listAdminRequests( + status?: LidarrRequestStatus, + limit?: number +): Promise { + const params = new URLSearchParams(); + if (status) params.set('status', status); + if (limit !== undefined) params.set('limit', String(limit)); + const qs = params.toString(); + return api.get( + qs ? `/api/admin/requests?${qs}` : '/api/admin/requests' + ); +} + +export async function approveRequest( + id: string, + overrides: { quality_profile_id?: number; root_folder_path?: string } = {} +): Promise { + return api.post(`/api/admin/requests/${id}/approve`, overrides); +} + +export async function rejectRequest( + id: string, + notes?: string +): Promise { + const body = notes !== undefined ? { notes } : {}; + return api.post(`/api/admin/requests/${id}/reject`, body); +} + +// Query factories --------------------------------------------------------- + +export function createLidarrConfigQuery() { + return createQuery({ + queryKey: qk.lidarrConfig(), + queryFn: getLidarrConfig + }); +} + +// `enabled` is passed in by the caller — typically derived from +// LidarrConfig.enabled — so the query only fires once Lidarr is configured. +// Keeping it as a prop (vs. reading config inside this factory) preserves +// purity and lets the caller choose its own gating logic. +export function createQualityProfilesQuery(enabled: boolean = true) { + return createQuery({ + queryKey: qk.lidarrQualityProfiles(), + queryFn: listQualityProfiles, + enabled + }); +} + +export function createRootFoldersQuery(enabled: boolean = true) { + return createQuery({ + queryKey: qk.lidarrRootFolders(), + queryFn: listRootFolders, + enabled + }); +} + +export function createAdminRequestsQuery(status?: LidarrRequestStatus) { + return createQuery({ + queryKey: qk.adminRequests(status), + queryFn: () => listAdminRequests(status) + }); +} diff --git a/web/src/lib/api/lidarr.test.ts b/web/src/lib/api/lidarr.test.ts new file mode 100644 index 00000000..31e26c90 --- /dev/null +++ b/web/src/lib/api/lidarr.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + } +})); + +import { searchLidarr } from './lidarr'; +import { api } from './client'; +import { qk } from './queries'; +import type { LidarrSearchResult } from './types'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('searchLidarr', () => { + test('builds URL with q and kind query params', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await searchLidarr('aphex twin', 'artist'); + expect(api.get).toHaveBeenCalledWith('/api/lidarr/search?q=aphex+twin&kind=artist'); + }); + + test('encodes special characters in q (quotes, ampersands, plus)', async () => { + (api.get as ReturnType).mockResolvedValueOnce([]); + await searchLidarr('boards of canada & friends', 'album'); + const calledWith = (api.get as ReturnType).mock.calls[0][0] as string; + // URLSearchParams encodes space as '+' and '&' as '%26'. + expect(calledWith).toBe( + '/api/lidarr/search?q=boards+of+canada+%26+friends&kind=album' + ); + }); + + test('passes through results unchanged', async () => { + const fixture: LidarrSearchResult[] = [ + { + mbid: 'mbid-1', + name: 'Aphex Twin', + secondary_text: 'Electronic', + image_url: 'https://example.test/img.jpg', + artist_mbid: '', + album_mbid: '', + in_library: false, + requested: false + } + ]; + (api.get as ReturnType).mockResolvedValueOnce(fixture); + const out = await searchLidarr('aphex', 'artist'); + expect(out).toBe(fixture); + }); + + test('propagates errors from api.get', async () => { + const err = { code: 'lidarr_unavailable', message: 'down', status: 503 }; + (api.get as ReturnType).mockRejectedValueOnce(err); + await expect(searchLidarr('x', 'track')).rejects.toMatchObject({ + code: 'lidarr_unavailable', + status: 503 + }); + }); +}); + +describe('qk.lidarrSearch', () => { + test('keys include q + kind', () => { + expect(qk.lidarrSearch('foo', 'artist')).toEqual([ + 'lidarrSearch', + { q: 'foo', kind: 'artist' } + ]); + }); +}); diff --git a/web/src/lib/api/lidarr.ts b/web/src/lib/api/lidarr.ts new file mode 100644 index 00000000..e3ddd332 --- /dev/null +++ b/web/src/lib/api/lidarr.ts @@ -0,0 +1,28 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { LidarrSearchResult, LidarrRequestKind } from './types'; + +// Search-only. Admin-side Lidarr config + connection probing lives in admin.ts. + +// searchLidarr proxies the user-facing Lidarr search endpoint. URLSearchParams +// handles encoding (spaces, quotes, ampersands) so callers can pass q raw. +export async function searchLidarr( + q: string, + kind: LidarrRequestKind +): Promise { + const params = new URLSearchParams({ q, kind }); + return api.get(`/api/lidarr/search?${params.toString()}`); +} + +// staleTime mirrors the spec §11 60s server-side LRU cache: re-issuing the +// same query within a minute hits cache on the server, so there's no point +// in marking it stale client-side any sooner. +export function createLidarrSearchQuery(q: string, kind: LidarrRequestKind) { + return createQuery({ + queryKey: qk.lidarrSearch(q, kind), + queryFn: () => searchLidarr(q, kind), + enabled: q.length > 0, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 11c8b2a4..973bd51a 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -19,6 +19,15 @@ export const qk = { likedTracks: () => ['likedTracks'] as const, likedAlbums: () => ['likedAlbums'] as const, likedArtists: () => ['likedArtists'] as const, + // Lidarr / requests / admin. + lidarrSearch: (q: string, kind: string) => + ['lidarrSearch', { q, kind }] as const, + myRequests: () => ['myRequests'] as const, + lidarrConfig: () => ['lidarrConfig'] as const, + lidarrQualityProfiles: () => ['lidarrQualityProfiles'] as const, + lidarrRootFolders: () => ['lidarrRootFolders'] as const, + adminRequests: (status?: string) => + ['adminRequests', { status: status ?? 'pending' }] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/lib/api/requests.test.ts b/web/src/lib/api/requests.test.ts new file mode 100644 index 00000000..b63fb5c9 --- /dev/null +++ b/web/src/lib/api/requests.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + }, + apiFetch: vi.fn() +})); + +import { + createRequest, + listMyRequests, + getRequest, + cancelRequest +} from './requests'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { LidarrRequest } from './types'; + +const mockRow: LidarrRequest = { + id: 'r1', + user_id: 'u1', + status: 'pending', + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: null, + artist_name: 'Aphex Twin', + album_title: 'Selected Ambient Works', + track_title: null, + quality_profile_id: null, + root_folder_path: null, + decided_at: null, + decided_by: null, + notes: null, + completed_at: null, + matched_track_id: null, + matched_album_id: null, + matched_artist_id: null, + requested_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('createRequest', () => { + test('POSTs /api/requests with full body for an album request', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + const out = await createRequest({ + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + artist_name: 'Aphex Twin', + album_title: 'Selected Ambient Works' + }); + expect(api.post).toHaveBeenCalledWith('/api/requests', { + kind: 'album', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: 'alb-mbid', + album_title: 'Selected Ambient Works' + }); + expect(out).toBe(mockRow); + }); + + test('omits empty / undefined optional MBID + title fields from wire body', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await createRequest({ + kind: 'artist', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: '', + lidarr_track_mbid: '' + }); + const body = (api.post as ReturnType).mock.calls[0][1] as Record< + string, + unknown + >; + expect(body).toEqual({ + kind: 'artist', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin' + }); + expect(body).not.toHaveProperty('lidarr_album_mbid'); + expect(body).not.toHaveProperty('lidarr_track_mbid'); + expect(body).not.toHaveProperty('album_title'); + expect(body).not.toHaveProperty('track_title'); + }); + + test('includes track fields for a track-kind request', async () => { + (api.post as ReturnType).mockResolvedValueOnce(mockRow); + await createRequest({ + kind: 'track', + lidarr_artist_mbid: 'art-mbid', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: 'trk-mbid', + artist_name: 'Aphex Twin', + album_title: 'Drukqs', + track_title: 'Avril 14th' + }); + expect(api.post).toHaveBeenCalledWith('/api/requests', { + kind: 'track', + lidarr_artist_mbid: 'art-mbid', + artist_name: 'Aphex Twin', + lidarr_album_mbid: 'alb-mbid', + lidarr_track_mbid: 'trk-mbid', + album_title: 'Drukqs', + track_title: 'Avril 14th' + }); + }); +}); + +describe('listMyRequests', () => { + test('GETs /api/requests', async () => { + (api.get as ReturnType).mockResolvedValueOnce([mockRow]); + const out = await listMyRequests(); + expect(api.get).toHaveBeenCalledWith('/api/requests'); + expect(out).toEqual([mockRow]); + }); +}); + +describe('getRequest', () => { + test('GETs /api/requests/:id', async () => { + (api.get as ReturnType).mockResolvedValueOnce(mockRow); + const out = await getRequest('r1'); + expect(api.get).toHaveBeenCalledWith('/api/requests/r1'); + expect(out).toBe(mockRow); + }); +}); + +describe('cancelRequest', () => { + test('DELETEs /api/requests/:id and returns the cancelled row', async () => { + const cancelled = { ...mockRow, status: 'rejected' as const }; + (apiFetch as ReturnType).mockResolvedValueOnce(cancelled); + const out = await cancelRequest('r1'); + expect(apiFetch).toHaveBeenCalledWith('/api/requests/r1', { method: 'DELETE' }); + expect(out).toBe(cancelled); + expect(out.status).toBe('rejected'); + }); +}); + +describe('qk.myRequests', () => { + test('returns the expected key tuple', () => { + expect(qk.myRequests()).toEqual(['myRequests']); + }); +}); diff --git a/web/src/lib/api/requests.ts b/web/src/lib/api/requests.ts new file mode 100644 index 00000000..ad01ecd5 --- /dev/null +++ b/web/src/lib/api/requests.ts @@ -0,0 +1,67 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api, apiFetch } from './client'; +import { qk } from './queries'; +import type { LidarrRequest, LidarrRequestKind } from './types'; + +// CreateRequestParams is the shape callers pass; unused MBID fields are +// optional, and we omit them from the wire body rather than sending empty +// strings (the backend tolerates either, but this keeps test expectations +// crisp and matches the spec §5 wire shape). +export type CreateRequestParams = { + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + lidarr_album_mbid?: string; + lidarr_track_mbid?: string; + artist_name: string; + album_title?: string; + track_title?: string; +}; + +type CreateRequestBody = { + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + artist_name: string; + lidarr_album_mbid?: string; + lidarr_track_mbid?: string; + album_title?: string; + track_title?: string; +}; + +function buildCreateBody(params: CreateRequestParams): CreateRequestBody { + const body: CreateRequestBody = { + kind: params.kind, + lidarr_artist_mbid: params.lidarr_artist_mbid, + artist_name: params.artist_name + }; + if (params.lidarr_album_mbid) body.lidarr_album_mbid = params.lidarr_album_mbid; + if (params.lidarr_track_mbid) body.lidarr_track_mbid = params.lidarr_track_mbid; + if (params.album_title) body.album_title = params.album_title; + if (params.track_title) body.track_title = params.track_title; + return body; +} + +export async function createRequest(params: CreateRequestParams): Promise { + return api.post('/api/requests', buildCreateBody(params)); +} + +export async function listMyRequests(): Promise { + return api.get('/api/requests'); +} + +export async function getRequest(id: string): Promise { + return api.get(`/api/requests/${id}`); +} + +// Server returns the cancelled row body (not 204) so callers can patch the +// cache without a refetch. api.del's return type is fixed to null, so we +// drop down to apiFetch here to keep typing honest. +export async function cancelRequest(id: string): Promise { + return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise; +} + +export function createMyRequestsQuery() { + return createQuery({ + queryKey: qk.myRequests(), + queryFn: listMyRequests + }); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2d666faa..0eb5ed43 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -70,3 +70,79 @@ export type EventRequest = export type PlayStartedResponse = { play_event_id: string; session_id: string }; export type EventOkResponse = { ok: true }; + +// Lidarr / requests shared enums + shapes. +// Status + Kind are used across lidarr.ts, requests.ts, and admin.ts so they +// live here. Per-module helpers (CreateRequestParams, etc.) stay in their +// respective modules. +export type LidarrRequestStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'completed' + | 'failed'; + +export type LidarrRequestKind = 'artist' | 'album' | 'track'; + +export type LidarrSearchResult = { + mbid: string; + name: string; + secondary_text: string; + image_url: string; + artist_mbid: string; + album_mbid: string; + in_library: boolean; + requested: boolean; +}; + +// pgtype.UUID and pgtype.Timestamptz JSON-marshal as a native string when +// Valid, and as `null` (or omitted, with omitempty) otherwise. We unify both +// "absent" cases as `string | null` so callers branch on a single check. +export type LidarrRequest = { + id: string; + user_id: string; + status: LidarrRequestStatus; + kind: LidarrRequestKind; + lidarr_artist_mbid: string; + lidarr_album_mbid?: string | null; + lidarr_track_mbid?: string | null; + artist_name: string; + album_title?: string | null; + track_title?: string | null; + quality_profile_id?: number | null; + root_folder_path?: string | null; + decided_at?: string | null; + decided_by?: string | null; + notes?: string | null; + completed_at?: string | null; + matched_track_id?: string | null; + matched_album_id?: string | null; + matched_artist_id?: string | null; + requested_at: string; + updated_at: string; +}; + +export type LidarrConfig = { + enabled: boolean; + // api_key is "" when unset, "***" when masked on GET, plain text on PUT. + base_url: string; + api_key: string; + default_quality_profile_id: number; + default_root_folder_path: string; +}; + +export type LidarrQualityProfile = { + id: number; + name: string; +}; + +export type LidarrRootFolder = { + path: string; + accessible: boolean; + free_space: number; +}; + +// testLidarrConnection always returns 200; callers branch on `.ok`. +export type LidarrTestResult = + | { ok: true; version: string } + | { ok: false; error: string };