feat(web): add API client modules for Lidarr, requests, admin

T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del
helpers and the existing TanStack Query qk namespace:

  lidarr.ts   - searchLidarr() + createLidarrSearchQuery()
  requests.ts - createRequest, listMyRequests, getRequest, cancelRequest;
                createMyRequestsQuery(); cancel goes through apiFetch
                directly because the backend returns the cancelled row body
                (api.del's return type is fixed to null).
  admin.ts    - getLidarrConfig, putLidarrConfig, testLidarrConnection,
                listQualityProfiles, listRootFolders, listAdminRequests,
                approveRequest, rejectRequest; query factories for each
                read; quality profiles + root folders take an enabled prop
                so the call site decides when Lidarr is configured.

Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/
search-result shapes added to types.ts. Per-module helpers (CreateRequestParams)
stay in their module files. testLidarrConnection returns a discriminated union
({ok:true,version} | {ok:false,error}) and never throws on ok:false so the
SPA can render either branch.

qk extended with lidarrSearch, myRequests, lidarrConfig,
lidarrQualityProfiles, lidarrRootFolders, adminRequests.

Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction,
query-param encoding, body shapes, the not-ok testLidarrConnection branch,
and qk additions. 32 new tests, 217 total passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 19:53:04 -04:00
parent 29968ae8da
commit d7eaa189e2
8 changed files with 739 additions and 0 deletions
+227
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
await listAdminRequests();
expect(api.get).toHaveBeenCalledWith('/api/admin/requests');
});
test('status only -> ?status=', async () => {
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce([baseRow]);
await listAdminRequests('approved');
expect(api.get).toHaveBeenCalledWith('/api/admin/requests?status=approved');
});
test('status + limit -> ?status=&limit=', async () => {
(api.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(baseRow);
await rejectRequest('r1');
expect(api.post).toHaveBeenCalledWith('/api/admin/requests/r1/reject', {});
});
test('includes notes when provided', async () => {
(api.post as ReturnType<typeof vi.fn>).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' }
]);
});
});
+104
View File
@@ -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<LidarrConfig> {
return api.get<LidarrConfig>('/api/admin/lidarr/config');
}
export async function putLidarrConfig(cfg: LidarrConfig): Promise<LidarrConfig> {
return api.put<LidarrConfig>('/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<LidarrTestResult> {
return api.post<LidarrTestResult>('/api/admin/lidarr/test', body);
}
export async function listQualityProfiles(): Promise<LidarrQualityProfile[]> {
return api.get<LidarrQualityProfile[]>('/api/admin/lidarr/quality-profiles');
}
export async function listRootFolders(): Promise<LidarrRootFolder[]> {
return api.get<LidarrRootFolder[]>('/api/admin/lidarr/root-folders');
}
// Admin request queue -----------------------------------------------------
export async function listAdminRequests(
status?: LidarrRequestStatus,
limit?: number
): Promise<LidarrRequest[]> {
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<LidarrRequest[]>(
qs ? `/api/admin/requests?${qs}` : '/api/admin/requests'
);
}
export async function approveRequest(
id: string,
overrides: { quality_profile_id?: number; root_folder_path?: string } = {}
): Promise<LidarrRequest> {
return api.post<LidarrRequest>(`/api/admin/requests/${id}/approve`, overrides);
}
export async function rejectRequest(
id: string,
notes?: string
): Promise<LidarrRequest> {
const body = notes !== undefined ? { notes } : {};
return api.post<LidarrRequest>(`/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)
});
}
+73
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce([]);
await searchLidarr('boards of canada & friends', 'album');
const calledWith = (api.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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' }
]);
});
});
+28
View File
@@ -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<LidarrSearchResult[]> {
const params = new URLSearchParams({ q, kind });
return api.get<LidarrSearchResult[]>(`/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
});
}
+9
View File
@@ -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) {
+155
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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']);
});
});
+67
View File
@@ -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<LidarrRequest> {
return api.post<LidarrRequest>('/api/requests', buildCreateBody(params));
}
export async function listMyRequests(): Promise<LidarrRequest[]> {
return api.get<LidarrRequest[]>('/api/requests');
}
export async function getRequest(id: string): Promise<LidarrRequest> {
return api.get<LidarrRequest>(`/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<LidarrRequest> {
return apiFetch(`/api/requests/${id}`, { method: 'DELETE' }) as Promise<LidarrRequest>;
}
export function createMyRequestsQuery() {
return createQuery({
queryKey: qk.myRequests(),
queryFn: listMyRequests
});
}
+76
View File
@@ -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 };