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,
|
||||
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) {
|
||||
|
||||
@@ -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 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