From 797ed1f5adb38bdbe3459cfac1cea30c61dbdc1f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 22:33:00 -0400 Subject: [PATCH] feat(web): tag enrichment sources admin card (#1490 Step 4 web) Add a "Tag enrichment sources" card to the admin integrations page, mirroring the cover-art providers card: per-provider enable toggle + API-key field + Save + Test connection, over /api/admin/tag-sources. Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled tracks for re-enrichment via the version bump. - admin.ts: TagProvider types + get/update/test functions + createTagProvidersQuery; qk.tagProviders key. - integrations/+page.svelte: the card + local edit state, with MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes. - Tests: admin.tag-sources API test + integrations component tests (render / enable+key save / test connection), plus the mock plumbing the existing suite needs for the new query. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/lib/api/admin.tag-sources.test.ts | 84 +++++++++ web/src/lib/api/admin.ts | 61 +++++++ web/src/lib/api/queries.ts | 1 + .../routes/admin/integrations/+page.svelte | 162 ++++++++++++++++++ .../admin/integrations/integrations.test.ts | 97 ++++++++++- 5 files changed, 403 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/api/admin.tag-sources.test.ts diff --git a/web/src/lib/api/admin.tag-sources.test.ts b/web/src/lib/api/admin.tag-sources.test.ts new file mode 100644 index 00000000..e7b9c4d2 --- /dev/null +++ b/web/src/lib/api/admin.tag-sources.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getTagProviders, + updateTagProvider, + testTagProvider, + type TagProvider, + type TagProvidersResponse +} from './admin'; + +vi.mock('./client', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() } +})); + +import { api } from './client'; + +describe('admin tag-sources API', () => { + beforeEach(() => vi.clearAllMocks()); + + it('getTagProviders GETs the correct path', async () => { + const sample: TagProvidersResponse = { + providers: [ + { + id: 'musicbrainz', + display_name: 'MusicBrainz', + requires_api_key: false, + supports: ['track_tags'], + 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 getTagProviders(); + expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources'); + expect(got.providers[0].id).toBe('musicbrainz'); + expect(got.sources_version).toBe(1); + }); + + it('updateTagProvider PATCHes the right path with patch body', async () => { + const patched: TagProvider & { version_bumped: boolean } = { + id: 'lastfm', + display_name: 'Last.fm', + requires_api_key: true, + supports: ['track_tags'], + enabled: true, + api_key_set: true, + display_order: 1, + testable: true, + version_bumped: true + }; + (api.patch as unknown as ReturnType).mockResolvedValueOnce(patched); + const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' }); + expect(api.patch).toHaveBeenCalledWith( + '/api/admin/tag-sources/lastfm', + { enabled: true, api_key: 'mykey' } + ); + expect(got.api_key_set).toBe(true); + expect(got.version_bumped).toBe(true); + }); + + it('testTagProvider POSTs to /test endpoint with empty body', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ + ok: true, + duration_ms: 55 + }); + const got = await testTagProvider('musicbrainz'); + expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {}); + expect(got.ok).toBe(true); + expect(got.duration_ms).toBe(55); + }); + + it('testTagProvider returns ok=false with error string on failure', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ + ok: false, + error: 'lastfm api key not set' + }); + const got = await testTagProvider('lastfm'); + expect(got.ok).toBe(false); + expect(got.error).toBe('lastfm api key not set'); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 8e4d775a..8fafa7a8 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -383,6 +383,67 @@ export function createCoverProvidersQuery() { }); } +// Tag-enrichment providers ------------------------------------------------- +// Parallel to the cover-art providers surface, over /api/admin/tag-sources. +// Independent settings so a new folksonomy source is added without touching +// art config (#1490). + +export type TagProviderCapability = 'track_tags'; + +export type TagProvider = { + id: string; + display_name: string; + requires_api_key: boolean; + supports: TagProviderCapability[]; + enabled: boolean; + api_key_set: boolean; + display_order: number; + testable: boolean; +}; + +export type TagProvidersResponse = { + providers: TagProvider[]; + sources_version: number; +}; + +export async function getTagProviders(): Promise { + return api.get('/api/admin/tag-sources'); +} + +export type UpdateTagProviderPatch = { + enabled?: boolean; + api_key?: string; +}; + +export type UpdateTagProviderResponse = TagProvider & { + version_bumped: boolean; +}; + +export async function updateTagProvider( + id: string, + patch: UpdateTagProviderPatch +): Promise { + return api.patch(`/api/admin/tag-sources/${id}`, patch); +} + +export type TestTagProviderResponse = { + ok: boolean; + duration_ms?: number; + error?: string; +}; + +export async function testTagProvider(id: string): Promise { + return api.post(`/api/admin/tag-sources/${id}/test`, {}); +} + +export function createTagProvidersQuery() { + return createQuery({ + queryKey: qk.tagProviders(), + queryFn: getTagProviders, + staleTime: 60_000 + }); +} + // Admin user-management ------------------------------------------------------ export type AdminUser = { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index d12397e1..f59d7260 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -48,6 +48,7 @@ export const qk = { scanStatus: () => ['scanStatus'] as const, coverage: () => ['coverage'] as const, coverProviders: () => ['coverProviders'] as const, + tagProviders: () => ['tagProviders'] as const, adminUsers: () => ['adminUsers'] as const, adminInvites: () => ['adminInvites'] as const, adminDiagnostics: (f: Record) => diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 1b5b7553..7915ec50 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -13,10 +13,14 @@ createCoverProvidersQuery, updateCoverProvider, testCoverProvider, + createTagProvidersQuery, + updateTagProvider, + testTagProvider, updateSMTPConfig, testSMTPConfig, createSMTPConfigQuery, type CoverProvider, + type TagProvider, type SMTPConfig } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; @@ -280,6 +284,62 @@ } } + // ---- Tag enrichment providers ---- + // Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490). + const tagProvidersStore = $derived(createTagProvidersQuery()); + const tagProvidersQ = $derived($tagProvidersStore); + + let tagLocalState = $state>({}); + let tagTestResults = $state>({}); + let tagSaving = $state>({}); + let tagTesting = $state>({}); + + $effect(() => { + if (tagProvidersQ.data) { + for (const p of tagProvidersQ.data.providers) { + if (!tagLocalState[p.id]) { + tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' }; + } + } + } + }); + + function tagHasChanges(provider: TagProvider): boolean { + const local = tagLocalState[provider.id]; + if (!local) return false; + return provider.enabled !== local.enabled || local.apiKey !== ''; + } + + async function tagSave(provider: TagProvider) { + const local = tagLocalState[provider.id]; + if (!local || !tagHasChanges(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; + + tagSaving[provider.id] = true; + try { + const result = await updateTagProvider(provider.id, patch); + tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' }; + await client.invalidateQueries({ queryKey: qk.tagProviders() }); + } catch (_e) { + // Inline error surfacing handled by the mutation toast layer; swallow here. + } finally { + tagSaving[provider.id] = false; + } + } + + async function tagTest(provider: TagProvider) { + tagTesting[provider.id] = true; + try { + tagTestResults[provider.id] = await testTagProvider(provider.id); + } catch (e) { + tagTestResults[provider.id] = { ok: false, error: (e as Error).message }; + } finally { + tagTesting[provider.id] = false; + } + } + // SMTP config ------------------------------------------------------------- const smtpStore = $derived(createSMTPConfigQuery()); @@ -597,6 +657,108 @@

No providers registered.

{/if} + + +
+
+

Tag enrichment sources

+

+ Folksonomy style/mood tags fetched per track and folded into each + listener's taste profile alongside the raw file genre — so "Rock" + gains "post-punk / shoegaze / melancholic". All enabled sources are + merged. Enabling or disabling a source re-opens previously-processed + tracks for a fresh pass. +

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

{provider.display_name}

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

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

+ {:else} +

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

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

+ Keyless and on by default — the always-available baseline. Matches tags by + recording MBID, so coverage tracks how well your library is MusicBrainz-tagged. +

+ {:else if provider.id === 'lastfm'} +

+ Name-based (artist + title), so it covers tracks without an MBID. Requires a free + API key — get one at last.fm/api. +

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

Loading…

+ {:else} +

No providers registered.

+ {/if} +
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts index e5b295b4..360d41a3 100644 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte'; import { mockQuery } from '../../../test-utils/query'; import type { LidarrConfig } from '$lib/api/types'; -import type { CoverProvider, SMTPConfig } from '$lib/api/admin'; +import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin'; // useQueryClient is wrapped so the page can call invalidateQueries() without // a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default @@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({ createCoverProvidersQuery: vi.fn(), updateCoverProvider: vi.fn(), testCoverProvider: vi.fn(), + createTagProvidersQuery: vi.fn(), + updateTagProvider: vi.fn(), + testTagProvider: vi.fn(), createSMTPConfigQuery: vi.fn(), updateSMTPConfig: vi.fn(), testSMTPConfig: vi.fn() @@ -39,6 +42,9 @@ import { createCoverProvidersQuery, updateCoverProvider, testCoverProvider, + createTagProvidersQuery, + updateTagProvider, + testTagProvider, createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig @@ -95,6 +101,34 @@ const defaultCoverProviders = { sources_version: 1 }; +// Fixture tag providers +const providerMusicbrainz: TagProvider = { + id: 'musicbrainz', + display_name: 'MusicBrainz', + requires_api_key: false, + supports: ['track_tags'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true +}; + +const providerLastfm: TagProvider = { + id: 'lastfm', + display_name: 'Last.fm', + requires_api_key: true, + supports: ['track_tags'], + enabled: false, + api_key_set: false, + display_order: 1, + testable: true +}; + +const defaultTagProviders = { + providers: [providerMusicbrainz, providerLastfm], + sources_version: 1 +}; + const testResponseOk = { ok: true as const, version: '2.0.5', @@ -133,7 +167,7 @@ afterEach(() => { invalidateQueries.mockReset(); }); -function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) { +function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) { (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.config ?? cfgConnected }) ); @@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov (createCoverProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: cpData, isPending: opts.coverProviders === null }) ); + const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders); + (createTagProvidersQuery as ReturnType).mockReturnValue( + mockQuery({ data: tpData, isPending: opts.tagProviders === null }) + ); (createSMTPConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.smtp ?? smtpUnset }) ); @@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement { .closest('article') as HTMLElement; } +function tagProvidersSection(): HTMLElement { + return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement; +} + +function tagProviderCard(providerName: string): HTMLElement { + return within(tagProvidersSection()) + .getByRole('heading', { name: providerName, level: 4 }) + .closest('article') as HTMLElement; +} + describe('/admin/integrations', () => { test('Save calls putLidarrConfig with empty api_key when input is blank', async () => { setup(); @@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => { }); }); }); + +describe('/admin/integrations tag sources', () => { + test('renders tag enrichment providers', () => { + setup(); + const section = tagProvidersSection(); + expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument(); + expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument(); + expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1); + }); + + test('enabling Last.fm and typing a key calls updateTagProvider', async () => { + setup(); + (updateTagProvider as ReturnType).mockResolvedValueOnce({ + ...providerLastfm, + enabled: true, + api_key_set: true, + version_bumped: true + }); + const card = tagProviderCard('Last.fm'); + const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement; + await fireEvent.click(enabledCheckbox); + const keyInput = within(card).getByLabelText(/api key/i); + await fireEvent.input(keyInput, { target: { value: 'lfmkey' } }); + await fireEvent.click(within(card).getByRole('button', { name: /save changes/i })); + + expect(updateTagProvider).toHaveBeenCalledWith( + 'lastfm', + expect.objectContaining({ enabled: true, api_key: 'lfmkey' }) + ); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['tagProviders'] }) + ) + ); + }); + + test('Test connection on a tag provider calls testTagProvider and shows OK', async () => { + setup(); + (testTagProvider as ReturnType).mockResolvedValueOnce({ ok: true, duration_ms: 42 }); + const card = tagProviderCard('MusicBrainz'); + await fireEvent.click(within(card).getByRole('button', { name: /test connection/i })); + await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz')); + await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument()); + }); +});