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) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
Reference in New Issue
Block a user