From 7e1cfb8d0858985d9db566c2c427a81b268cb531 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sun, 11 Oct 2026 11:21:48 -0400
Subject: [PATCH] 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)
- 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
---
.../com/fabledsword/minstrel/api/ErrorCopy.kt | 4 +-
docs/hosting.md | 4 +-
docs/screenshots/README.md | 2 +-
internal/api/auth_forgot.go | 2 +-
internal/lidarr/types.go | 4 +-
internal/server/server.go | 2 +-
.../components/NotificationSettings.svelte | 2 +-
.../components/NotificationSettings.test.ts | 2 +-
.../ReacquisitionSettingsCard.svelte | 6 +-
web/src/lib/components/WantCandidates.svelte | 4 +-
.../components/admin/LidarrSettings.svelte | 327 +++++++
.../components/admin/LidarrSettings.test.ts | 188 ++++
.../lib/components/admin/ProviderGroup.svelte | 180 ++++
.../lib/components/admin/SmtpSettings.svelte | 146 +++
.../lib/components/admin/SmtpSettings.test.ts | 69 ++
web/src/lib/styles/error-copy.json | 4 +-
web/src/routes/admin/+layout.svelte | 5 +-
web/src/routes/admin/+page.svelte | 306 +-----
web/src/routes/admin/acquisition/+page.svelte | 27 +
web/src/routes/admin/admin.test.ts | 69 +-
web/src/routes/admin/duplicates/+page.svelte | 19 +-
.../admin/duplicates/duplicates.test.ts | 19 +-
web/src/routes/admin/email/+page.svelte | 27 +
.../routes/admin/integrations/+page.svelte | 898 ------------------
.../admin/integrations/integrations.test.ts | 808 ----------------
web/src/routes/admin/library/+page.svelte | 214 +++++
web/src/routes/admin/library/library.test.ts | 110 +++
web/src/routes/admin/metadata/+page.svelte | 92 ++
.../routes/admin/metadata/metadata.test.ts | 174 ++++
.../routes/admin/missing-files/+page.svelte | 33 +-
.../admin/missing-files/missing-files.test.ts | 15 +-
web/src/routes/collection/+layout.svelte | 2 +-
32 files changed, 1649 insertions(+), 2115 deletions(-)
create mode 100644 web/src/lib/components/admin/LidarrSettings.svelte
create mode 100644 web/src/lib/components/admin/LidarrSettings.test.ts
create mode 100644 web/src/lib/components/admin/ProviderGroup.svelte
create mode 100644 web/src/lib/components/admin/SmtpSettings.svelte
create mode 100644 web/src/lib/components/admin/SmtpSettings.test.ts
create mode 100644 web/src/routes/admin/acquisition/+page.svelte
create mode 100644 web/src/routes/admin/email/+page.svelte
delete mode 100644 web/src/routes/admin/integrations/+page.svelte
delete mode 100644 web/src/routes/admin/integrations/integrations.test.ts
create mode 100644 web/src/routes/admin/library/+page.svelte
create mode 100644 web/src/routes/admin/library/library.test.ts
create mode 100644 web/src/routes/admin/metadata/+page.svelte
create mode 100644 web/src/routes/admin/metadata/metadata.test.ts
diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt
index 60fe379f..6a795d74 100644
--- a/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt
+++ b/android/app/src/main/java/com/fabledsword/minstrel/api/ErrorCopy.kt
@@ -106,12 +106,12 @@ object ErrorCopy {
"This server is on the internet, so its URL must start with https://. " +
"Plain http:// only works on your home network.",
"lidarr_unreachable" to
- "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
+ "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
"lidarr_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " +
- "Set them in Admin → Integrations.",
+ "Set them in Admin › Acquisition.",
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.",
diff --git a/docs/hosting.md b/docs/hosting.md
index 10ede42e..ab544713 100644
--- a/docs/hosting.md
+++ b/docs/hosting.md
@@ -13,7 +13,7 @@ Publish the port to the network and use `http://:4533`:
ports: ['4533:4533']
```
-If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
+If nothing sits in front of Minstrel, set **Admin › Email & network → Client IP
detection** to 0. It defaults to 1, which assumes one proxy (see below for
why that matters).
@@ -48,7 +48,7 @@ Three things, all required:
the app is. Raise the proxy's read timeout well past a minute (nginx:
`proxy_read_timeout 1h;`).
-3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
+3. **Tell Minstrel where it lives**, in **Admin › Email & network**:
- **Client IP detection:** the number of proxies in front of Minstrel. One
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
Minstrel uses this to find the real client address, for login rate
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
index 3aae8e97..d333d32d 100644
--- a/docs/screenshots/README.md
+++ b/docs/screenshots/README.md
@@ -11,7 +11,7 @@ across all shots for a consistent look.
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance |
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
-| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
+| `integrations.png` | `/admin/acquisition` | Indexers, download clients, searching and Lidarr |
| `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
diff --git a/internal/api/auth_forgot.go b/internal/api/auth_forgot.go
index 6d652437..e85fdc91 100644
--- a/internal/api/auth_forgot.go
+++ b/internal/api/auth_forgot.go
@@ -133,7 +133,7 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
// errNoPublicURL stops a reset email from going out before the operator has
// said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way.
-var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
+var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
// buildResetURL builds the emailed link from the operator-set public URL.
// It deliberately ignores the request: the Host header is whatever the
diff --git a/internal/lidarr/types.go b/internal/lidarr/types.go
index 3fd9db36..8fdef82f 100644
--- a/internal/lidarr/types.go
+++ b/internal/lidarr/types.go
@@ -25,7 +25,7 @@ type lidarrImage struct {
URL string `json:"url"`
}
-// QualityProfile is the dropdown choice in /admin/integrations.
+// QualityProfile is the dropdown choice on Admin › Acquisition.
type QualityProfile struct {
ID int
Name string
@@ -41,7 +41,7 @@ type MetadataProfile struct {
Name string
}
-// RootFolder is the dropdown choice in /admin/integrations.
+// RootFolder is the dropdown choice on Admin › Acquisition.
type RootFolder struct {
Path string
Accessible bool
diff --git a/internal/server/server.go b/internal/server/server.go
index 7d325e78..8fcc1fbe 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -161,7 +161,7 @@ func (s *Server) Router() http.Handler {
)
lidarrCfg := lidarrconfig.New(s.Pool)
// Per-call client factory: re-reads config so an admin save in
- // /admin/integrations takes effect immediately without restart.
+ // Admin › Acquisition takes effect immediately without restart.
// Returns nil when Lidarr is disabled, which the Service-layer
// methods translate to ErrLidarrDisabled.
lidarrClientFn := func() *lidarr.Client {
diff --git a/web/src/lib/components/NotificationSettings.svelte b/web/src/lib/components/NotificationSettings.svelte
index b186ba84..f2ea490a 100644
--- a/web/src/lib/components/NotificationSettings.svelte
+++ b/web/src/lib/components/NotificationSettings.svelte
@@ -130,7 +130,7 @@
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: add an email address in your profile.
{:else if user.value?.is_admin}
- Email is off: SMTP isn't set up.
+ Email is off: SMTP isn't set up.
{:else}
Email is off: this server doesn't send email.
{/if}
diff --git a/web/src/lib/components/NotificationSettings.test.ts b/web/src/lib/components/NotificationSettings.test.ts
index 937ca6b3..ee61f279 100644
--- a/web/src/lib/components/NotificationSettings.test.ts
+++ b/web/src/lib/components/NotificationSettings.test.ts
@@ -123,7 +123,7 @@ describe('NotificationSettings', () => {
render(NotificationSettings);
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
'href',
- '/admin/integrations'
+ '/admin/email'
);
});
});
diff --git a/web/src/lib/components/ReacquisitionSettingsCard.svelte b/web/src/lib/components/ReacquisitionSettingsCard.svelte
index 437faeca..569ac884 100644
--- a/web/src/lib/components/ReacquisitionSettingsCard.svelte
+++ b/web/src/lib/components/ReacquisitionSettingsCard.svelte
@@ -10,9 +10,9 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
// Policy for putting an album whose files went missing back on the wanted
- // list (milestone #290, M502 #5520). Lives on the missing-files page rather
- // than under Integrations because this is where the operator meets the
- // problem it solves. Searching for a want, and retrying, is the search
+ // list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
+ // of how music is got (#5607); Missing files says what it is set to and
+ // links here. Searching for a want, and retrying, is the search
// schedule's job, so there is no retry policy here.
let saved = $state(null);
diff --git a/web/src/lib/components/WantCandidates.svelte b/web/src/lib/components/WantCandidates.svelte
index 878eabc8..8d48e404 100644
--- a/web/src/lib/components/WantCandidates.svelte
+++ b/web/src/lib/components/WantCandidates.svelte
@@ -67,8 +67,8 @@
if (r.gone) return 'The indexers no longer offer that release.';
if (r.unconfigured) {
return r.missing === 'download_client'
- ? 'No download client is set up. Add one under Integrations.'
- : 'No indexer is set up. Add one under Integrations.';
+ ? 'No download client is set up. Add one under Admin › Acquisition.'
+ : 'No indexer is set up. Add one under Admin › Acquisition.';
}
if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.';
return r.error ?? 'Not taken.';
diff --git a/web/src/lib/components/admin/LidarrSettings.svelte b/web/src/lib/components/admin/LidarrSettings.svelte
new file mode 100644
index 00000000..83c1158d
--- /dev/null
+++ b/web/src/lib/components/admin/LidarrSettings.svelte
@@ -0,0 +1,327 @@
+
+
+{#snippet listError(key: string)}
+ {#if testListErrors[key]}
+
- {coverage.with_art.toLocaleString()} with art
- ·
- 0 ? 'cursor-help' : ''}
- title={coverage.pending_no_mbid > 0
- ? `${coverage.pending_no_mbid.toLocaleString()} of these have no MBID and won't be helped by another scan — fix tags or merge duplicate albums.`
- : undefined}
- >
- {coverage.pending.toLocaleString()} pending
-
- ·
- {coverage.settled.toLocaleString()} settled
-
{#if prints.enabled === false}
- Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the
- settings below.
+ Fingerprinting is off, so no track has a fingerprint to compare. Turn it on under
+ Library.
{:else}
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
Duplicates appear here as the sweep finds them.
@@ -485,7 +479,10 @@
/>
{/if}
-
-
+
+
+ Fingerprinting is {prints?.enabled === false ? 'off' : 'on'}. It's set under
+ Library.
+
diff --git a/web/src/routes/admin/duplicates/duplicates.test.ts b/web/src/routes/admin/duplicates/duplicates.test.ts
index 38b55ba2..369524fa 100644
--- a/web/src/routes/admin/duplicates/duplicates.test.ts
+++ b/web/src/routes/admin/duplicates/duplicates.test.ts
@@ -15,18 +15,7 @@ vi.mock('#lib/api/admin.js', () => ({
mergeDuplicateGroup: vi.fn().mockResolvedValue({
survivor_track_id: 'www-01',
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
- }),
- // The page embeds FingerprintSettingsCard, which loads its own settings from
- // this module; the card has its own suite.
- getFingerprintSettings: vi.fn().mockResolvedValue({
- enabled: true,
- chromaprint_length_sec: 120,
- acoustic_max_bit_error_rate: 0.15,
- backfill_concurrency: 2,
- sweep_interval_hours: 1,
- auto_resolve: true
- }),
- updateFingerprintSettings: vi.fn()
+ })
}));
import AdminDuplicatesPage from './+page.svelte';
@@ -250,9 +239,11 @@ describe('admin duplicates', () => {
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
});
- test('the fingerprinting settings are on this page', async () => {
+ test('the fingerprinting settings are a link to Library, saying whether it is on', () => {
renderWith(response());
- expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy();
+ const line = screen.getByTestId('fingerprint-settings-link');
+ expect(text(line)).toContain('Fingerprinting is on');
+ expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/library#fingerprints');
});
test('empty before any sweep says the sweep has not run', () => {
diff --git a/web/src/routes/admin/email/+page.svelte b/web/src/routes/admin/email/+page.svelte
new file mode 100644
index 00000000..49696010
--- /dev/null
+++ b/web/src/routes/admin/email/+page.svelte
@@ -0,0 +1,27 @@
+
+
+{pageTitle('Admin · Email & network')}
+
+
- Optional. Approved requests go on Minstrel's own wanted list; a connected Lidarr is
- still used to search for a cleaner copy of a suspect album, to remove an album through
- it, and to stop monitoring tracks you remove.
-
- Public test key works out of the box. A free personal key gives separate quota
- accounting — apply at theaudiodb.com/api_apply.php.
-
- {/if}
-
- {/each}
- {:else if coverProvidersQ.isPending}
-
Loading…
- {:else if coverProvidersQ.isError}
-
- Couldn't load the cover art providers.
-
-
- {:else}
-
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.
-
- 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 if tagProvidersQ.isError}
-
- Couldn't load the tag providers.
-
-
- {:else}
-
No providers registered.
- {/if}
-
-
-
-
-
Email (SMTP)
-
- Configure outgoing email for password resets and notifications. The "Send test email"
- button verifies your config end-to-end.
-
-
-
- {#if smtpQ.isError && !smtpLoaded}
-
- Couldn't load the email settings, so they can't be saved yet.
-
-
- {/if}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TLS
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- This clears the saved configuration. Type
- DISCONNECT to remove the
- Lidarr connection.
-
+
+ {/snippet}
+
diff --git a/web/src/routes/admin/library/library.test.ts b/web/src/routes/admin/library/library.test.ts
new file mode 100644
index 00000000..13627c12
--- /dev/null
+++ b/web/src/routes/admin/library/library.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
+import { mockQuery } from '../../../test-utils/query';
+import type { ScanStatus } from '#lib/api/admin.js';
+
+vi.mock('@tanstack/svelte-query', async (orig) => {
+ const actual = (await orig()) as Record;
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
+});
+
+vi.mock('#lib/api/admin.js', () => ({
+ createScanStatusQuery: vi.fn(),
+ createCoverageQuery: vi.fn(),
+ createFingerprintCoverageQuery: vi.fn(),
+ triggerScan: vi.fn().mockResolvedValue({}),
+ refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
+ researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
+ // The loudness and fingerprint settings load on mount and have their own
+ // suites; left pending.
+ getLoudnessSettings: vi.fn(() => new Promise(() => {})),
+ updateLoudnessSettings: vi.fn(),
+ getLoudnessCoverage: vi.fn(() => new Promise(() => {})),
+ getFingerprintSettings: vi.fn(() => new Promise(() => {})),
+ updateFingerprintSettings: vi.fn()
+}));
+
+vi.mock('#lib/api/wanted.js', async () => {
+ const { mockQuery } = await import('../../../test-utils/query');
+ return { createSearchSettingsQuery: vi.fn(() => mockQuery({ data: { scan_paths: ['/music', '/more-music'] } })) };
+});
+
+import LibraryPage from './+page.svelte';
+import {
+ createScanStatusQuery,
+ createCoverageQuery,
+ createFingerprintCoverageQuery,
+ triggerScan,
+ researchMissingArt,
+ refetchMissingCovers
+} from '#lib/api/admin.js';
+
+const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
+
+afterEach(() => vi.clearAllMocks());
+
+function setup(scan: Partial = { id: '', started_at: '', finished_at: null, in_flight: false }) {
+ vi.mocked(createScanStatusQuery).mockReturnValue(mockQuery({ data: scan }) as never);
+ vi.mocked(createCoverageQuery).mockReturnValue(
+ mockQuery({ data: { total: 100, with_art: 90, pending: 6, settled: 4, pending_no_mbid: 0 } }) as never
+ );
+ vi.mocked(createFingerprintCoverageQuery).mockReturnValue(
+ mockQuery({ data: { total: 100, fingerprinted: 80, rejected: 1, pending: 19, enabled: true } }) as never
+ );
+ return render(LibraryPage);
+}
+
+const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim();
+
+describe('Admin › Library', () => {
+ test('lists the folders Minstrel reads', () => {
+ setup();
+ expect(squash(screen.getByTestId('library-folders'))).toBe('/music /more-music');
+ });
+
+ test('before any scan it says so, with no job rows', () => {
+ setup();
+ expect(screen.getByText("Hasn't run yet.")).toBeInTheDocument();
+ expect(screen.queryAllByTestId('scan-job')).toHaveLength(0);
+ });
+
+ test('a running scan has a row per job, each with what it did and where it is', () => {
+ setup({
+ id: 's1',
+ started_at: hoursAgo(0.1),
+ finished_at: null,
+ in_flight: true,
+ library: { scanned: 1200, added: 3, updated: 2, skipped: 1195, errored: 0 }
+ });
+ const jobs = screen.getAllByTestId('scan-job').map(squash);
+ expect(jobs[0]).toBe('Reading files 1,200 read · 3 new · 2 changed Processing');
+ expect(jobs[1]).toMatch(/^MusicBrainz ids Pending$/);
+ expect((screen.getByRole('button', { name: /scanning…/i }) as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ test('a failed scan says so on the scan row', () => {
+ setup({ id: 's1', started_at: hoursAgo(2), finished_at: hoursAgo(1), in_flight: false, error_message: 'library root not mounted' });
+ expect(screen.getByText(/failed 1h ago: library root not mounted/i)).toBeInTheDocument();
+ });
+
+ test('Run scan starts one', async () => {
+ setup();
+ await fireEvent.click(screen.getByRole('button', { name: /run scan/i }));
+ await waitFor(() => expect(triggerScan).toHaveBeenCalled());
+ await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/scan started/i));
+ });
+
+ test('cover art says how many have art, and can look for the missing and look again', async () => {
+ setup();
+ expect(screen.getByText('90 with art · 6 still to look for · 4 no source has')).toBeInTheDocument();
+ await fireEvent.click(screen.getByRole('button', { name: /look for missing covers/i }));
+ await waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled());
+ await fireEvent.click(screen.getByRole('button', { name: /^look again$/i }));
+ await waitFor(() => expect(researchMissingArt).toHaveBeenCalled());
+ });
+
+ test('fingerprint progress is one line', () => {
+ setup();
+ expect(screen.getByText('80 of 100 tracks · 19 to go · 1 unreadable')).toBeInTheDocument();
+ });
+});
diff --git a/web/src/routes/admin/metadata/+page.svelte b/web/src/routes/admin/metadata/+page.svelte
new file mode 100644
index 00000000..8f493745
--- /dev/null
+++ b/web/src/routes/admin/metadata/+page.svelte
@@ -0,0 +1,92 @@
+
+
+{pageTitle('Admin · Metadata sources')}
+
+
Metadata sources
+
+{#snippet coverNote(p: { id: string })}
+ {#if p.id === 'theaudiodb'}
+ Works with the public key;
+ a free key of your own 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.
+ Get a free key.
+ {/if}
+{/snippet}
+
+
+ {#snippet main()}
+
+ {/snippet}
+ {#snippet side()}
+
+
+ {/snippet}
+
diff --git a/web/src/routes/admin/metadata/metadata.test.ts b/web/src/routes/admin/metadata/metadata.test.ts
new file mode 100644
index 00000000..0ab85c68
--- /dev/null
+++ b/web/src/routes/admin/metadata/metadata.test.ts
@@ -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;
+ 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; 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();
+ });
+});
diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte
index 656a3cb4..df3a6ad7 100644
--- a/web/src/routes/admin/missing-files/+page.svelte
+++ b/web/src/routes/admin/missing-files/+page.svelte
@@ -1,10 +1,14 @@
{pageTitle('Admin · Missing files')}
@@ -58,10 +73,16 @@
-
-
+
+ {#if policy}
+ {policy.enabled
+ ? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
+ : 'Missing albums are not wanted back automatically.'}
+ {/if}
+
+ {policy ? 'Change' : 'Set what happens to missing files'}
+
+
{#if query.isPending}
Checking what's missing…
diff --git a/web/src/routes/admin/missing-files/missing-files.test.ts b/web/src/routes/admin/missing-files/missing-files.test.ts
index a9a113c9..68673dd3 100644
--- a/web/src/routes/admin/missing-files/missing-files.test.ts
+++ b/web/src/routes/admin/missing-files/missing-files.test.ts
@@ -3,9 +3,8 @@ import { render, screen, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse } from '#lib/api/types.js';
-// The page now embeds ReacquisitionSettingsCard, which loads its own settings
-// from this same module on mount. Stubbing both keeps these tests about the
-// missing-files list — the card has its own suite.
+// The page reads the re-acquisition policy to say what happens to these;
+// the settings themselves are on Admin › Acquisition.
vi.mock('#lib/api/admin.js', () => ({
createMissingFilesQuery: vi.fn(),
getReacquisitionSettings: vi.fn().mockResolvedValue({
@@ -13,8 +12,7 @@ vi.mock('#lib/api/admin.js', () => ({
grace_hours: 24,
max_per_pass: 20,
unnameable_albums: 0
- }),
- updateReacquisitionSettings: vi.fn()
+ })
}));
import AdminMissingFilesPage from './+page.svelte';
@@ -77,6 +75,13 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
}
describe('admin missing files', () => {
+ test('says what happens to missing files, and links to where it is set', async () => {
+ renderWith(response);
+ const line = screen.getByTestId('reacquisition-policy');
+ await screen.findByText(/missing for 24 hours go back on the wanted list/i);
+ expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/acquisition#reacquisition');
+ });
+
test('groups rows under their directory', () => {
renderWith(response);
expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy();
diff --git a/web/src/routes/collection/+layout.svelte b/web/src/routes/collection/+layout.svelte
index 136345e1..9b240ae0 100644
--- a/web/src/routes/collection/+layout.svelte
+++ b/web/src/routes/collection/+layout.svelte
@@ -39,7 +39,7 @@
getting for it, from user requests, new releases of the artists it follows, and albums whose
files went missing. It searches your indexers on a schedule, checks each download against
MusicBrainz, and files what's right into the library. Searching and quality are set under
- Integrations.
+ Acquisition.
{#if verdict}