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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user