feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 11:21:48 -04:00
co-authored by Claude Opus 5.5
parent 0cc4bc686a
commit 7e1cfb8d08
32 changed files with 1649 additions and 2115 deletions
@@ -0,0 +1,92 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import ProviderGroup from '#lib/components/admin/ProviderGroup.svelte';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
// Admin › Metadata sources (#5607): the outside sources Minstrel asks
// about your music. AcoustID names a recording from its sound; the cover
// art and tag sources share one block.
const client = useQueryClient();
const coverStore = createCoverProvidersQuery();
const tagStore = createTagProvidersQuery();
async function saveCover(id: string, patch: { enabled?: boolean; api_key?: string }) {
const result = await updateCoverProvider(id, patch);
await client.invalidateQueries({ queryKey: qk.coverProviders() });
// A change to the sources opens covers no source had to another try.
if (result.version_bumped) await client.invalidateQueries({ queryKey: qk.coverage() });
return result;
}
async function saveTag(id: string, patch: { enabled?: boolean; api_key?: string }) {
const result = await updateTagProvider(id, patch);
await client.invalidateQueries({ queryKey: qk.tagProviders() });
return result;
}
const link = 'underline hover:no-underline';
</script>
<svelte:head><title>{pageTitle('Admin · Metadata sources')}</title></svelte:head>
<h2 class="sr-only">Metadata sources</h2>
{#snippet coverNote(p: { id: string })}
{#if p.id === 'theaudiodb'}
Works with the public key;
<a class={link} href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer"
>a free key of your own</a
> gets its own quota.
{/if}
{/snippet}
{#snippet tagNote(p: { id: string })}
{#if p.id === 'musicbrainz'}
Needs no key; matches by MusicBrainz id.
{:else if p.id === 'lastfm'}
Matches by artist and title.
<a class={link} href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer"
>Get a free key</a
>.
{/if}
{/snippet}
<SettingsColumns>
{#snippet main()}
<AcoustIDCard />
{/snippet}
{#snippet side()}
<ProviderGroup
title="Cover art"
hint="Asked in this order for covers and artist images; the first to have one wins."
noun="cover art sources"
query={$coverStore}
save={saveCover}
test={testCoverProvider}
keyPlaceholder="Empty uses the public key"
note={coverNote}
/>
<ProviderGroup
title="Tags"
hint="Styles and moods for each track, merged from every source switched on."
noun="tag sources"
query={$tagStore}
save={saveTag}
test={testTagProvider}
keyPlaceholder="Paste a key to use this source"
note={tagNote}
/>
{/snippet}
</SettingsColumns>
@@ -0,0 +1,174 @@
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 { CoverProvider, TagProvider } from '#lib/api/admin.js';
const invalidateQueries = vi.fn();
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
});
vi.mock('#lib/api/admin.js', () => ({
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn(),
createTagProvidersQuery: vi.fn(),
updateTagProvider: vi.fn(),
testTagProvider: vi.fn(),
// AcoustIDCard loads on mount and has its own suite; left pending.
getAcoustID: vi.fn(() => new Promise(() => {})),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn()
}));
import MetadataPage from './+page.svelte';
import {
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider
} from '#lib/api/admin.js';
const caa: 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 audiodb: 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 mb: 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 lastfm: 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
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(opts: { cover?: ReturnType<typeof mockQuery>; covers?: CoverProvider[] } = {}) {
vi.mocked(createCoverProvidersQuery).mockReturnValue(
(opts.cover ?? mockQuery({ data: { providers: opts.covers ?? [caa, audiodb], sources_version: 1 } })) as never
);
vi.mocked(createTagProvidersQuery).mockReturnValue(
mockQuery({ data: { providers: [mb, lastfm], sources_version: 1 } }) as never
);
return render(MetadataPage);
}
const source = (id: string) => screen.getAllByTestId('provider').find((el) => el.dataset.provider === id)!;
const group = (title: string) => screen.getByRole('heading', { name: title, level: 2 }).closest('section') as HTMLElement;
describe('Admin › Metadata sources', () => {
test('each source is a row saying what it supplies in words, not its codes', () => {
setup();
const row = source('theaudiodb');
expect(row.textContent).toMatch(/Album covers, artist photos, artist backdrops/);
expect(row.textContent).not.toMatch(/artist_thumb|album_cover/);
expect(within(group('Tags')).getAllByTestId('provider')).toHaveLength(2);
});
test('a saved key says so, and an unsaved one invites a key', () => {
setup({ covers: [{ ...audiodb, api_key_set: true }] });
expect(within(source('theaudiodb')).getByPlaceholderText(/key saved/i)).toBeInTheDocument();
});
test('Save is off until something changes, then sends only the change', async () => {
setup();
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...audiodb, version_bumped: false });
const row = source('theaudiodb');
const save = within(row).getByRole('button', { name: /^save$/i }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
await fireEvent.input(within(row).getByLabelText(/theaudiodb api key/i), { target: { value: 'newkey123' } });
expect(save.disabled).toBe(false);
await fireEvent.click(save);
expect(updateCoverProvider).toHaveBeenCalledWith('theaudiodb', { api_key: 'newkey123' });
});
test('switching a cover source off reopens covers no source had', async () => {
setup();
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...caa, enabled: false, version_bumped: true });
const row = source('mbcaa');
await fireEvent.click(within(row).getByRole('switch'));
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['coverage'] })));
});
test('a save that fails says so', async () => {
setup();
vi.mocked(updateCoverProvider).mockRejectedValueOnce({ code: 'server_error' });
const row = source('theaudiodb');
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i));
});
test('Test says whether the source answered', async () => {
setup();
vi.mocked(testCoverProvider).mockResolvedValueOnce({ ok: true, duration_ms: 120 });
await fireEvent.click(within(source('mbcaa')).getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(within(source('mbcaa')).getByTestId('provider-test').textContent).toMatch(/works · answered in 120 ms/i));
});
test('sources that fail to load say so, with Try again', () => {
setup({ cover: mockQuery({ isError: true, error: { code: 'server_error' } }) });
expect(within(group('Cover art')).getByTestId('providers-error').textContent).toMatch(/couldn't load the cover art sources/i);
});
test('a tag source is switched on with its key in one save', async () => {
setup();
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true });
const row = source('lastfm');
await fireEvent.click(within(row).getByRole('switch'));
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } });
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
expect(updateTagProvider).toHaveBeenCalledWith('lastfm', { enabled: true, api_key: 'lfmkey' });
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['tagProviders'] })));
});
test('Test on a tag source asks that source', async () => {
setup();
vi.mocked(testTagProvider).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
await fireEvent.click(within(source('musicbrainz')).getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
});
test('where to get a key is linked from the source that needs one', () => {
setup();
expect(within(source('lastfm')).getByRole('link', { name: /get a free key/i })).toBeInTheDocument();
expect(within(source('theaudiodb')).getByRole('link', { name: /a free key of your own/i })).toBeInTheDocument();
});
});