feat(web): tag enrichment sources admin card (#1490 Step 4 web)
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m45s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 22:33:21 -04:00
co-authored by Claude Opus 4.8
parent 96c2eb6afb
commit 797ed1f5ad
5 changed files with 403 additions and 2 deletions
+84
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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');
});
});
+61
View File
@@ -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<TagProvidersResponse> {
return api.get<TagProvidersResponse>('/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<UpdateTagProviderResponse> {
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
}
export type TestTagProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
return api.post<TestTagProviderResponse>(`/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 = {
+1
View File
@@ -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<string, string | number | undefined>) =>