diff --git a/web/src/lib/api/admin.cover-sources.test.ts b/web/src/lib/api/admin.cover-sources.test.ts new file mode 100644 index 00000000..46b8f060 --- /dev/null +++ b/web/src/lib/api/admin.cover-sources.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getCoverProviders, + updateCoverProvider, + testCoverProvider, + type CoverProvider, + type CoverProvidersResponse +} from './admin'; + +vi.mock('./client', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() } +})); + +import { api } from './client'; + +describe('admin cover-sources API', () => { + beforeEach(() => vi.clearAllMocks()); + + it('getCoverProviders GETs the correct path', async () => { + const sample: CoverProvidersResponse = { + providers: [ + { + id: 'mbcaa', + display_name: 'Cover Art Archive', + requires_api_key: false, + supports: ['album_cover'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true + } + ], + sources_version: 1 + }; + (api.get as unknown as ReturnType).mockResolvedValueOnce(sample); + const got = await getCoverProviders(); + expect(api.get).toHaveBeenCalledWith('/api/admin/cover-sources'); + expect(got.providers[0].id).toBe('mbcaa'); + expect(got.sources_version).toBe(1); + }); + + it('updateCoverProvider PATCHes the right path with patch body', async () => { + const patched: CoverProvider & { version_bumped: boolean } = { + id: 'theaudiodb', + display_name: 'TheAudioDB', + requires_api_key: true, + supports: ['album_cover', 'artist_thumb', 'artist_fanart'], + enabled: true, + api_key_set: true, + display_order: 1, + testable: true, + version_bumped: false + }; + (api.patch as unknown as ReturnType).mockResolvedValueOnce(patched); + const got = await updateCoverProvider('theaudiodb', { api_key: 'mykey' }); + expect(api.patch).toHaveBeenCalledWith( + '/api/admin/cover-sources/theaudiodb', + { api_key: 'mykey' } + ); + expect(got.api_key_set).toBe(true); + expect(got.version_bumped).toBe(false); + }); + + it('updateCoverProvider with enabled toggle returns version_bumped: true', async () => { + const patched: CoverProvider & { version_bumped: boolean } = { + id: 'theaudiodb', + display_name: 'TheAudioDB', + requires_api_key: true, + supports: ['album_cover', 'artist_thumb', 'artist_fanart'], + enabled: false, + api_key_set: false, + display_order: 1, + testable: true, + version_bumped: true + }; + (api.patch as unknown as ReturnType).mockResolvedValueOnce(patched); + const got = await updateCoverProvider('theaudiodb', { enabled: false }); + expect(got.version_bumped).toBe(true); + expect(got.enabled).toBe(false); + }); + + it('testCoverProvider POSTs to /test endpoint with empty body', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + duration_ms: 234 + }); + const got = await testCoverProvider('theaudiodb'); + expect(api.post).toHaveBeenCalledWith('/api/admin/cover-sources/theaudiodb/test', {}); + expect(got.ok).toBe(true); + expect(got.duration_ms).toBe(234); + }); + + it('testCoverProvider returns ok=false with error string on failure', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + duration_ms: 12, + error: 'connection refused' + }); + const got = await testCoverProvider('theaudiodb'); + expect(got.ok).toBe(false); + expect(got.error).toBe('connection refused'); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 0aebaabd..80ce50d3 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -269,3 +269,63 @@ export function createCoverageQuery() { refetchInterval: 3_000 }); } + +// Cover-art providers ------------------------------------------------------ + +export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart'; + +export type CoverProvider = { + id: string; + display_name: string; + requires_api_key: boolean; + supports: CoverProviderCapability[]; + enabled: boolean; + api_key_set: boolean; + display_order: number; + testable: boolean; +}; + +export type CoverProvidersResponse = { + providers: CoverProvider[]; + sources_version: number; +}; + +export async function getCoverProviders(): Promise { + return api.get('/api/admin/cover-sources'); +} + +export type UpdateCoverProviderPatch = { + enabled?: boolean; + api_key?: string; +}; + +export type UpdateCoverProviderResponse = CoverProvider & { + version_bumped: boolean; +}; + +export async function updateCoverProvider( + id: string, + patch: UpdateCoverProviderPatch +): Promise { + return api.patch(`/api/admin/cover-sources/${id}`, patch); +} + +export type TestCoverProviderResponse = { + ok: boolean; + duration_ms?: number; + error?: string; +}; + +export async function testCoverProvider(id: string): Promise { + return api.post(`/api/admin/cover-sources/${id}/test`, {}); +} + +// Settings rarely change in operator time. 60s staleTime; refetches +// on window focus + after any mutation rather than polling. +export function createCoverProvidersQuery() { + return createQuery({ + queryKey: qk.coverProviders(), + queryFn: getCoverProviders, + staleTime: 60_000 + }); +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 2a030a03..dadc2549 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -56,6 +56,8 @@ export const api = { apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, put: (path: string, body: unknown): Promise => apiFetch(path, { method: 'PUT', body: JSON.stringify(body) }) as Promise, + patch: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'PATCH', body: JSON.stringify(body) }) as Promise, del: (path: string): Promise => apiFetch(path, { method: 'DELETE' }) as Promise }; diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index a8d38b59..e94e3b04 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -36,6 +36,7 @@ export const qk = { ['adminQuarantineActions', { limit: limit ?? 50 }] as const, scanStatus: () => ['scanStatus'] as const, coverage: () => ['coverage'] as const, + coverProviders: () => ['coverProviders'] as const, suggestions: (limit?: number) => ['suggestions', { limit: limit ?? 12 }] as const, home: () => ['home'] as const,