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:
@@ -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' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ export const qk = {
|
|||||||
likedTracks: () => ['likedTracks'] as const,
|
likedTracks: () => ['likedTracks'] as const,
|
||||||
likedAlbums: () => ['likedAlbums'] as const,
|
likedAlbums: () => ['likedAlbums'] as const,
|
||||||
likedArtists: () => ['likedArtists'] 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) {
|
export function createArtistsQuery(sort: ArtistSort) {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -70,3 +70,79 @@ export type EventRequest =
|
|||||||
|
|
||||||
export type PlayStartedResponse = { play_event_id: string; session_id: string };
|
export type PlayStartedResponse = { play_event_id: string; session_id: string };
|
||||||
export type EventOkResponse = { ok: true };
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user