diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 4ec38bcc..d1ee8e3e 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -8,7 +8,11 @@ createMetadataProfilesQuery, createRootFoldersQuery, putLidarrConfig, - testLidarrConnection + testLidarrConnection, + createCoverProvidersQuery, + updateCoverProvider, + testCoverProvider, + type CoverProvider } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; @@ -161,6 +165,67 @@ disconnectInput = ''; disconnectError = null; } + + // ---- Cover art providers ---- + const coverProvidersStore = $derived(createCoverProvidersQuery()); + const coverProvidersQ = $derived($coverProvidersStore); + + // Local edit state — keyed by provider_id. Initialised lazily when + // the query data lands, then preserved across refetches so the + // operator's in-flight edits don't get clobbered. + let coverLocalState = $state>({}); + let coverTestResults = $state>({}); + let coverSaving = $state>({}); + let coverTesting = $state>({}); + + $effect(() => { + if (coverProvidersQ.data) { + for (const p of coverProvidersQ.data.providers) { + if (!coverLocalState[p.id]) { + coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' }; + } + } + } + }); + + function coverHasChanges(provider: CoverProvider): boolean { + const local = coverLocalState[provider.id]; + if (!local) return false; + return provider.enabled !== local.enabled || local.apiKey !== ''; + } + + async function coverSave(provider: CoverProvider) { + const local = coverLocalState[provider.id]; + if (!local || !coverHasChanges(provider)) return; + const patch: { enabled?: boolean; api_key?: string } = {}; + if (provider.enabled !== local.enabled) patch.enabled = local.enabled; + if (local.apiKey !== '') patch.api_key = local.apiKey; + + coverSaving[provider.id] = true; + try { + const result = await updateCoverProvider(provider.id, patch); + coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' }; + await client.invalidateQueries({ queryKey: qk.coverProviders() }); + if (result.version_bumped) { + await client.invalidateQueries({ queryKey: qk.coverage() }); + } + } catch (_e) { + // Error display is handled inline via saveError pattern; swallow here. + } finally { + coverSaving[provider.id] = false; + } + } + + async function coverTest(provider: CoverProvider) { + coverTesting[provider.id] = true; + try { + coverTestResults[provider.id] = await testCoverProvider(provider.id); + } catch (e) { + coverTestResults[provider.id] = { ok: false, error: (e as Error).message }; + } finally { + coverTesting[provider.id] = false; + } + } {pageTitle('Admin · Integrations')} @@ -317,6 +382,100 @@

Not yet configured.

+ + +
+
+

Cover art providers

+

+ External sources for album covers and artist images. Providers + are tried in registration order; first hit wins. +

+
+ + {#if coverProvidersQ.data} + {#each coverProvidersQ.data.providers as provider (provider.id)} +
+
+
+

{provider.display_name}

+ +
+
+ {#each provider.supports as cap} + {cap.replace('_', ' ')} + {/each} +
+
+ + {#if coverLocalState[provider.id]} +
+ +
+ +
+ + {#if provider.requires_api_key} + +
+ + {#if provider.api_key_set} + ✓ Set + {/if} +
+ {/if} +
+ +
+ + {#if provider.testable} + + {/if} + {#if coverTestResults[provider.id]} + {#if coverTestResults[provider.id].ok} +

+ OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''} +

+ {:else} +

+ Failed — {coverTestResults[provider.id].error} +

+ {/if} + {/if} +
+ {/if} + + {#if provider.id === 'theaudiodb'} +

+ Public test key works out of the box. A free personal key gives separate quota + accounting — apply at theaudiodb.com/api_apply.php. +

+ {/if} +
+ {/each} + {:else if coverProvidersQ.isPending} +

Loading…

+ {:else} +

No providers registered.

+ {/if} +
{#if modalOpen} diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts index 3bb26e21..0ec33dfe 100644 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -2,12 +2,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { mockQuery } from '../../../test-utils/query'; import type { LidarrConfig } from '$lib/api/types'; +import type { CoverProvider } from '$lib/api/admin'; // useQueryClient is wrapped so the page can call invalidateQueries() without // a real QueryClient context. Everything else from svelte-query passes through. +const invalidateQueries = vi.fn(); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; + return { ...actual, useQueryClient: () => ({ invalidateQueries }) }; }); vi.mock('$lib/api/admin', () => ({ @@ -16,7 +18,10 @@ vi.mock('$lib/api/admin', () => ({ createMetadataProfilesQuery: vi.fn(), createRootFoldersQuery: vi.fn(), putLidarrConfig: vi.fn(), - testLidarrConnection: vi.fn() + testLidarrConnection: vi.fn(), + createCoverProvidersQuery: vi.fn(), + updateCoverProvider: vi.fn(), + testCoverProvider: vi.fn() })); import IntegrationsPage from './+page.svelte'; @@ -26,7 +31,10 @@ import { createMetadataProfilesQuery, createRootFoldersQuery, putLidarrConfig, - testLidarrConnection + testLidarrConnection, + createCoverProvidersQuery, + updateCoverProvider, + testCoverProvider } from '$lib/api/admin'; const cfgConnected: LidarrConfig = { @@ -47,11 +55,45 @@ const cfgUnset: LidarrConfig = { default_root_folder_path: '' }; +// Fixture cover providers +const providerMbcaa: CoverProvider = { + id: 'mbcaa', + display_name: 'MusicBrainz CAA', + requires_api_key: false, + supports: ['album_cover'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true +}; + +const providerTheAudioDB: CoverProvider = { + id: 'theaudiodb', + display_name: 'TheAudioDB', + requires_api_key: true, + supports: ['album_cover', 'artist_thumb', 'artist_fanart'], + enabled: true, + api_key_set: false, + display_order: 1, + testable: true +}; + +const providerTheAudioDBKeySet: CoverProvider = { + ...providerTheAudioDB, + api_key_set: true +}; + +const defaultCoverProviders = { + providers: [providerMbcaa, providerTheAudioDB], + sources_version: 1 +}; + afterEach(() => { vi.clearAllMocks(); + invalidateQueries.mockReset(); }); -function setup(opts: { config?: LidarrConfig } = {}) { +function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null } = {}) { (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.config ?? cfgConnected }) ); @@ -74,6 +116,10 @@ function setup(opts: { config?: LidarrConfig } = {}) { (createRootFoldersQuery as ReturnType).mockReturnValue( mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] }) ); + const cpData = opts.coverProviders === null ? undefined : (opts.coverProviders ?? defaultCoverProviders); + (createCoverProvidersQuery as ReturnType).mockReturnValue( + mockQuery({ data: cpData, isPending: opts.coverProviders === null }) + ); return render(IntegrationsPage); } @@ -195,3 +241,157 @@ describe('/admin/integrations', () => { expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument(); }); }); + +describe('/admin/integrations — cover art providers', () => { + test('renders one card per provider in the response', () => { + setup(); + expect(screen.getByText('MusicBrainz CAA')).toBeInTheDocument(); + expect(screen.getByText('TheAudioDB')).toBeInTheDocument(); + }); + + test('capability badges render for each capability in supports[]', () => { + setup(); + // TheAudioDB has album_cover, artist_thumb, artist_fanart + expect(screen.getAllByText('album cover').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('artist thumb')).toBeInTheDocument(); + expect(screen.getByText('artist fanart')).toBeInTheDocument(); + }); + + test('"✓ Set" indicator renders when api_key_set is true', () => { + setup({ + coverProviders: { + providers: [providerTheAudioDBKeySet], + sources_version: 1 + } + }); + expect(screen.getByText(/✓ set/i)).toBeInTheDocument(); + }); + + test('"✓ Set" indicator does not render when api_key_set is false', () => { + setup(); + expect(screen.queryByText(/✓ set/i)).not.toBeInTheDocument(); + }); + + test('Save button is disabled initially (no changes)', () => { + setup(); + const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); + // Both provider cards render Save buttons, both should be disabled initially + for (const btn of saveButtons) { + expect((btn as HTMLButtonElement).disabled).toBe(true); + } + }); + + test('Save button enables after typing in API key field', async () => { + setup(); + const keyInput = screen.getByLabelText(/api key/i); + await fireEvent.input(keyInput, { target: { value: 'myapikey' } }); + // The TheAudioDB save button should now be enabled + const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); + const theAudioDbSave = saveButtons.find((b) => !b.closest('article')?.textContent?.includes('MusicBrainz')); + expect(theAudioDbSave).toBeDefined(); + expect((theAudioDbSave as HTMLButtonElement).disabled).toBe(false); + }); + + test('clicking Save calls updateCoverProvider with api_key patch body', async () => { + setup(); + (updateCoverProvider as ReturnType).mockResolvedValueOnce({ + ...providerTheAudioDB, + version_bumped: false + }); + const keyInput = screen.getByLabelText(/api key/i); + await fireEvent.input(keyInput, { target: { value: 'newkey123' } }); + const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); + const theAudioDbCard = screen.getByText('TheAudioDB').closest('article'); + const theAudioDbSave = theAudioDbCard?.querySelector('button[type="button"]'); + // Find the save button for TheAudioDB specifically + const btn = saveButtons.find((b) => b.closest('article')?.contains(keyInput)); + await fireEvent.click(btn!); + expect(updateCoverProvider).toHaveBeenCalledWith( + 'theaudiodb', + expect.objectContaining({ api_key: 'newkey123' }) + ); + }); + + test('when version_bumped: true, invalidateQueries is called with qk.coverage()', async () => { + setup(); + (updateCoverProvider as ReturnType).mockResolvedValueOnce({ + ...providerMbcaa, + enabled: false, + version_bumped: true + }); + // Toggle enabled on MusicBrainz CAA card + const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!; + const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement; + await fireEvent.click(enabledCheckbox); + const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); + const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox)); + await fireEvent.click(mbcaaSave!); + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['coverage'] }) + ); + }); + }); + + test('when version_bumped: false, invalidateQueries is NOT called with qk.coverage()', async () => { + setup(); + (updateCoverProvider as ReturnType).mockResolvedValueOnce({ + ...providerMbcaa, + enabled: false, + version_bumped: false + }); + const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!; + const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement; + await fireEvent.click(enabledCheckbox); + const saveButtons = screen.getAllByRole('button', { name: /save changes/i }); + const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox)); + await fireEvent.click(mbcaaSave!); + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['coverProviders'] }) + ); + }); + // coverage invalidation should NOT have been called + const coverageCalls = invalidateQueries.mock.calls.filter( + (call: [{ queryKey: unknown[] }]) => JSON.stringify(call[0]?.queryKey) === JSON.stringify(['coverage']) + ); + expect(coverageCalls.length).toBe(0); + }); + + test('Test connection button calls testCoverProvider and shows OK result', async () => { + setup(); + (testCoverProvider as ReturnType).mockResolvedValueOnce({ + ok: true, + duration_ms: 120 + }); + const testButtons = screen.getAllByRole('button', { name: /test connection/i }); + await fireEvent.click(testButtons[0]); + await waitFor(() => + expect(screen.getByText(/ok.*120ms/i)).toBeInTheDocument() + ); + }); + + test('Test connection button shows failure message on error response', async () => { + setup(); + (testCoverProvider as ReturnType).mockResolvedValueOnce({ + ok: false, + error: 'mbcaa_unreachable' + }); + const testButtons = screen.getAllByRole('button', { name: /test connection/i }); + await fireEvent.click(testButtons[0]); + await waitFor(() => + expect(screen.getByText(/mbcaa_unreachable/i)).toBeInTheDocument() + ); + }); + + test('TheAudioDB help text renders with link', () => { + setup(); + expect(screen.getByText(/public test key works out of the box/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /theaudiodb\.com\/api_apply\.php/i })).toBeInTheDocument(); + }); + + test('shows loading state when cover providers query is pending', () => { + setup({ coverProviders: null }); + expect(screen.getByText(/loading…/i)).toBeInTheDocument(); + }); +});