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
@@ -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.",
+2 -2
View File
@@ -13,7 +13,7 @@ Publish the port to the network and use `http://<host>: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
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 {
@@ -130,7 +130,7 @@
{#if settings.email_unavailable_reason === 'no_address'}
Email is off: <a href="/settings/account#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
{:else if user.value?.is_admin}
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
Email is off: <a href="/admin/email" class="underline hover:text-text-primary">SMTP isn't set up</a>.
{:else}
Email is off: this server doesn't send email.
{/if}
@@ -123,7 +123,7 @@ describe('NotificationSettings', () => {
render(NotificationSettings);
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
'href',
'/admin/integrations'
'/admin/email'
);
});
});
@@ -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<ReacquisitionSettings | null>(null);
+2 -2
View File
@@ -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.';
@@ -0,0 +1,327 @@
<script lang="ts">
import { tick } from 'svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { copyForCode } from '#lib/api/error-copy.js';
import Modal from '#lib/components/Modal.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import type { LidarrConfig, LidarrTestResult } from '#lib/api/types.js';
// The Lidarr connection, on Admin › Acquisition (#5607). Optional: wants
// are Minstrel's own, and a connected Lidarr is still asked to search for
// a cleaner copy of a suspect album, to remove an album through it, and to
// stop monitoring a track you remove.
//
// The saved key comes back masked as "***" and is never put in the field.
// The field starts empty, and an empty key on save keeps the saved one.
const client = useQueryClient();
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
// Form state, filled once when the config first loads, so a refetch
// doesn't throw away what is being typed.
let baseUrl = $state('');
let apiKeyInput = $state('');
let qualityId = $state<number>(0);
let metadataId = $state<number>(0);
let rootPath = $state<string>('');
let initialized = false;
// Lists from a test. A fresh install has no saved connection to read the
// lists through, so the test's own lists fill the choices until a save.
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
let testListErrors = $state<Record<string, string>>({});
$effect(() => {
const c = config.data;
if (c && !initialized) {
baseUrl = c.base_url;
qualityId = c.default_quality_profile_id;
metadataId = c.default_metadata_profile_id;
rootPath = c.default_root_folder_path;
initialized = true;
}
});
const profilesEnabled = $derived(!!config.data?.enabled);
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
const qualityList = $derived(testQualityProfiles.length ? testQualityProfiles : ($profilesStore.data ?? []));
const metadataList = $derived(
testMetadataProfiles.length ? testMetadataProfiles : ($metadataProfilesStore.data ?? [])
);
const folderList = $derived(testRootFolders.length ? testRootFolders : ($foldersStore.data ?? []));
// Each choice defaults to the first on offer until one is picked.
$effect(() => {
if (qualityId === 0 && qualityList.length > 0) qualityId = qualityList[0].id;
});
$effect(() => {
if (metadataId === 0 && metadataList.length > 0) metadataId = metadataList[0].id;
});
$effect(() => {
if (rootPath === '' && folderList.length > 0) rootPath = folderList[0].path;
});
// What a failure code means, said to the operator.
const SAID: Record<string, string> = {
lidarr_unreachable: "Couldn't reach Lidarr at that address.",
lidarr_auth_failed: 'Lidarr turned the API key down.',
lidarr_lookup_failed: "Lidarr answered, but not in a way Minstrel understands.",
internal_error: 'Something went wrong on the server. Its log says what.',
no_defaults: 'Lidarr offered no quality profile, metadata profile or root folder to use.'
};
const said = (code: string | undefined) => (code && SAID[code]) || copyForCode(code);
const LIST_NAMES: Record<string, string> = {
quality_profiles: 'quality profiles',
metadata_profiles: 'metadata profiles',
root_folders: 'root folders'
};
let testResult: LidarrTestResult | null = $state(null);
let saveError: string | null = $state(null);
let isSaving = $state(false);
let isTesting = $state(false);
function takeLists(t: LidarrTestResult) {
testQualityProfiles = t.quality_profiles ?? [];
testMetadataProfiles = t.metadata_profiles ?? [];
testRootFolders = t.root_folders ?? [];
testListErrors = t.list_errors ?? {};
}
async function invalidate() {
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
}
async function onSave() {
isSaving = true;
saveError = null;
try {
// Test first: it confirms the connection and brings the lists the
// defaults are taken from.
const test = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = test;
if (!test.ok) {
saveError = said(test.error);
return;
}
takeLists(test);
await tick();
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
saveError = said('no_defaults');
return;
}
const cfg: LidarrConfig = {
enabled: true,
base_url: baseUrl,
api_key: apiKeyInput,
default_quality_profile_id: qualityId,
default_metadata_profile_id: metadataId,
default_root_folder_path: rootPath
};
await putLidarrConfig(cfg);
// A new address is a different server, so its lists are read again.
await invalidate();
apiKeyInput = '';
} catch (e) {
saveError = said(errCode(e));
} finally {
isSaving = false;
}
}
async function onTest() {
isTesting = true;
testResult = null;
try {
const result = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
testResult = result;
if (result.ok) takeLists(result);
} catch (e) {
testResult = { ok: false, error: errCode(e) };
} finally {
isTesting = false;
}
}
// Disconnecting asks for DISCONNECT typed out, so a slip doesn't clear a
// connection the operator relies on.
let modalOpen = $state(false);
let disconnectInput = $state('');
let disconnectError = $state<string | null>(null);
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
async function onConfirmDisconnect() {
if (!canDisconnect) return;
disconnectError = null;
try {
await putLidarrConfig({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
});
await invalidate();
modalOpen = false;
disconnectInput = '';
} catch (e) {
disconnectError = said(errCode(e));
}
}
function cancelDisconnect() {
modalOpen = false;
disconnectInput = '';
disconnectError = null;
}
</script>
{#snippet listError(key: string)}
{#if testListErrors[key]}
<p class="w-full text-xs text-error-fg">Couldn't read Lidarr's {LIST_NAMES[key]}.</p>
{/if}
{/snippet}
<SettingsGroup
title="Lidarr"
id="lidarr"
hint="Optional. Asked for a cleaner copy of a suspect album, and to remove albums."
>
<SettingRow label="Status">
<span class="text-sm text-text-secondary" data-testid="lidarr-state">
{#if testResult?.ok}
Connected · Lidarr {testResult.version}
{:else if config.data?.enabled}
Connected
{:else}
Not connected
{/if}
</span>
</SettingRow>
<SettingRow label="Address" for="lidarr-url">
<input id="lidarr-url" type="text" bind:value={baseUrl} placeholder="http://lidarr.lan:8686" class={field} />
</SettingRow>
<SettingRow label="API key" for="lidarr-key" hint="In Lidarr under Settings › General.">
<input
id="lidarr-key"
type="password"
bind:value={apiKeyInput}
placeholder={config.data?.api_key === '***' ? 'Key saved · leave empty to keep it' : 'Paste the API key'}
class={field}
/>
</SettingRow>
<SettingRow label="Quality profile" for="lidarr-quality">
<select
id="lidarr-quality"
bind:value={qualityId}
disabled={qualityList.length === 0}
class="{field} disabled:opacity-50"
>
{#each qualityList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('quality_profiles')}
</SettingRow>
<SettingRow label="Metadata profile" for="lidarr-metadata">
<select
id="lidarr-metadata"
bind:value={metadataId}
disabled={metadataList.length === 0}
class="{field} disabled:opacity-50"
>
{#each metadataList as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{@render listError('metadata_profiles')}
</SettingRow>
<SettingRow label="Root folder" for="lidarr-root">
<select id="lidarr-root" bind:value={rootPath} disabled={folderList.length === 0} class="{field} disabled:opacity-50">
{#each folderList as f (f.path)}
<option value={f.path}>{f.path}{f.accessible ? '' : ' (Lidarr can’t reach it)'}</option>
{/each}
</select>
{@render listError('root_folders')}
</SettingRow>
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
<button type="button" onclick={onTest} disabled={isTesting} class={button}>
{isTesting ? 'Testing…' : 'Test'}
</button>
<button
type="button"
onclick={onSave}
disabled={isSaving}
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
{isSaving ? 'Saving…' : 'Save'}
</button>
{#if config.data?.enabled}
<button type="button" onclick={() => (modalOpen = true)} class="ml-auto {button}">Disconnect</button>
{/if}
{#if testResult && !testResult.ok && !saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-test-error">{said(testResult.error)}</p>
{/if}
{#if saveError}
<p class="w-full text-sm text-error-fg" data-testid="lidarr-save-error">Not saved: {saveError}</p>
{/if}
</div>
</SettingsGroup>
<Modal title="Disconnect Lidarr?" open={modalOpen} onClose={cancelDisconnect}>
<p class="-mt-2 text-text-secondary">
This clears the saved connection. Type
<span class="font-mono text-text-primary">DISCONNECT</span> to go ahead.
</p>
<input
type="text"
bind:value={disconnectInput}
placeholder="DISCONNECT"
aria-label="Type DISCONNECT to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error-fg">Not disconnected: {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button type="button" onclick={cancelDisconnect} class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg">
Cancel
</button>
<button
type="button"
onclick={onConfirmDisconnect}
disabled={!canDisconnect}
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
Disconnect
</button>
</div>
</Modal>
@@ -0,0 +1,188 @@
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 { LidarrConfig } from '#lib/api/types.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', () => ({
createLidarrConfigQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createMetadataProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn()
}));
import LidarrSettings from './LidarrSettings.svelte';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection
} from '#lib/api/admin.js';
const connected: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.local',
api_key: '***',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
};
const unset: LidarrConfig = {
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
};
const testOk = {
ok: true as const,
version: '2.0.5',
quality_profiles: [
{ id: 1, name: 'FLAC' },
{ id: 2, name: 'MP3-320' }
],
metadata_profiles: [{ id: 1, name: 'Standard' }],
root_folders: [{ path: '/music', accessible: true, free_space: 12345 }]
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(config: LidarrConfig = connected, lists = true) {
vi.mocked(createLidarrConfigQuery).mockReturnValue(mockQuery({ data: config }) as never);
vi.mocked(createQualityProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' }] : [] }) as never
);
vi.mocked(createMetadataProfilesQuery).mockReturnValue(
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' }] : [] }) as never
);
vi.mocked(createRootFoldersQuery).mockReturnValue(
mockQuery({ data: lists ? [{ path: '/music', accessible: true, free_space: 0 }] : [] }) as never
);
return render(LidarrSettings);
}
function modalConfirm(): HTMLElement {
return within(screen.getByRole('dialog')).getByRole('button', { name: /^disconnect$/i });
}
describe('LidarrSettings', () => {
test('connected, it says so; not connected, it says that and offers no Disconnect', () => {
const { unmount } = setup();
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Connected');
expect(screen.getByRole('button', { name: /^disconnect$/i })).toBeInTheDocument();
unmount();
setup(unset);
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Not connected');
expect(screen.queryByRole('button', { name: /^disconnect$/i })).toBeNull();
});
test('Save tests first, then saves with an empty key, which keeps the saved one', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, base_url: 'http://lidarr.local', api_key: '', default_root_folder_path: '/music' })
)
);
});
test('a typed key is sent', async () => {
setup();
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'newkey' } });
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'newkey' })));
});
test('Test shows the Lidarr version when it connects', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: true, version: '2.0.0' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-state').textContent).toMatch(/lidarr 2\.0\.0/i));
});
test('a failed test says what went wrong in words, not its code', async () => {
setup();
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
const said = await screen.findByTestId('lidarr-test-error');
expect(said.textContent).toMatch(/couldn't reach lidarr/i);
expect(said.textContent).not.toMatch(/lidarr_unreachable/);
});
test('a save whose test fails is not saved, and says why', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_auth_failed' });
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://wrong.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(screen.getByTestId('lidarr-save-error').textContent).toMatch(/turned the api key down/i));
expect(putLidarrConfig).not.toHaveBeenCalled();
});
test('first-time setup fills the defaults from the test, keeping a choice made after it', async () => {
setup(unset, false);
vi.mocked(testLidarrConnection).mockResolvedValue(testOk);
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://lidarr.local' } });
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'secret-key' } });
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
await waitFor(() => expect(testLidarrConnection).toHaveBeenCalledTimes(1));
await fireEvent.change(screen.getByLabelText(/quality profile/i), { target: { value: '2' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putLidarrConfig).toHaveBeenCalledWith({
enabled: true,
base_url: 'http://lidarr.local',
api_key: 'secret-key',
default_quality_profile_id: 2,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
test('Disconnect needs DISCONNECT typed; cancelling changes nothing', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
expect((modalConfirm() as HTMLButtonElement).disabled).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(putLidarrConfig).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Disconnect, confirmed, clears the connection', async () => {
setup();
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
await fireEvent.input(screen.getByPlaceholderText('DISCONNECT'), { target: { value: 'DISCONNECT' } });
vi.mocked(putLidarrConfig).mockResolvedValueOnce(unset);
await fireEvent.click(modalConfirm());
expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, base_url: '', api_key: '' }));
});
test('the profile and folder choices come from Lidarr', () => {
setup();
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Lossless')).toBeInTheDocument();
expect(screen.getByText('Singles only')).toBeInTheDocument();
expect(screen.getByText('/music')).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
// One block for a list of outside sources (#5607): cover art and tags had
// the same panel pasted twice. Each source is a row: its name and what it
// supplies, a switch, a key field when it takes one, Save and Test.
type Provider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: string[];
enabled: boolean;
api_key_set: boolean;
testable: boolean;
};
type TestResult = { ok: boolean; duration_ms?: number; error?: string };
type P = Provider;
type Query = {
data?: { providers: P[] };
isPending: boolean;
isError: boolean;
refetch: () => unknown;
};
let {
title,
hint,
noun,
query,
save,
test,
keyPlaceholder = 'Paste an API key',
note
}: {
title: string;
hint?: string;
// What the list is of, for the load error: "cover art sources".
noun: string;
query: Query;
save: (id: string, patch: { enabled?: boolean; api_key?: string }) => Promise<unknown>;
test: (id: string) => Promise<TestResult>;
keyPlaceholder?: string;
// A line under one source's name, when it needs one (where to get a key).
note?: Snippet<[P]>;
} = $props();
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
// What a source supplies, in words rather than its capability codes.
const SUPPLIES: Record<string, string> = {
album_cover: 'album covers',
artist_thumb: 'artist photos',
artist_fanart: 'artist backdrops',
track_tags: 'track tags'
};
function supplies(p: P): string {
const words = p.supports.map((c) => SUPPLIES[c] ?? c.replace(/_/g, ' '));
return words.length ? words.join(', ').replace(/^./, (c) => c.toUpperCase()) : '';
}
// The operator's edits, kept across refetches until saved.
let local = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let results = $state<Record<string, TestResult>>({});
let saving = $state<Record<string, boolean>>({});
let testing = $state<Record<string, boolean>>({});
$effect(() => {
for (const p of query.data?.providers ?? []) {
if (!local[p.id]) local[p.id] = { enabled: p.enabled, apiKey: '' };
}
});
function changed(p: P): boolean {
const l = local[p.id];
return !!l && (l.enabled !== p.enabled || l.apiKey !== '');
}
async function onSave(p: P) {
const l = local[p.id];
if (!l || !changed(p)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (l.enabled !== p.enabled) patch.enabled = l.enabled;
if (l.apiKey !== '') patch.api_key = l.apiKey;
saving[p.id] = true;
try {
await save(p.id, patch);
local[p.id] = { enabled: l.enabled, apiKey: '' };
} catch (e) {
pushToast(`Couldn't save ${p.display_name}: ${errMessage(e)}`, 'error');
} finally {
saving[p.id] = false;
}
}
async function onTest(p: P) {
testing[p.id] = true;
try {
results[p.id] = await test(p.id);
} catch (e) {
results[p.id] = { ok: false, error: errMessage(e) };
} finally {
testing[p.id] = false;
}
}
</script>
<SettingsGroup {title} {hint}>
{#if query.data}
{#each query.data.providers as p (p.id)}
{@const l = local[p.id]}
{@const r = results[p.id]}
<div data-testid="provider" data-provider={p.id}>
<SettingRow label={p.display_name} for="src-{p.id}">
{#snippet hint()}
{supplies(p)}
{#if note}{' · '}{@render note(p)}{/if}
{/snippet}
{#if l}
<input
id="src-{p.id}"
type="checkbox"
role="switch"
aria-checked={l.enabled}
bind:checked={l.enabled}
class="h-4 w-4 cursor-pointer accent-accent"
/>
{/if}
</SettingRow>
{#if l}
<div class="flex flex-wrap items-center gap-2 px-4 pb-3">
{#if p.requires_api_key}
<input
type="password"
aria-label="{p.display_name} API key"
placeholder={p.api_key_set ? 'Key saved · leave empty to keep it' : keyPlaceholder}
bind:value={l.apiKey}
class="w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
/>
{/if}
<button
type="button"
disabled={!changed(p) || saving[p.id]}
onclick={() => onSave(p)}
class={button}>{saving[p.id] ? 'Saving…' : 'Save'}</button
>
{#if p.testable}
<button type="button" disabled={testing[p.id]} onclick={() => onTest(p)} class={button}>
{testing[p.id] ? 'Testing…' : 'Test'}
</button>
{/if}
{#if r}
<span class="text-xs {r.ok ? 'text-text-secondary' : 'text-error-fg'}" data-testid="provider-test">
{#if r.ok}
Works{r.duration_ms ? ` · answered in ${r.duration_ms} ms` : ''}
{:else}
Didn't work: {r.error}
{/if}
</span>
{/if}
</div>
{/if}
</div>
{/each}
{:else if query.isPending}
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else if query.isError}
<p class="px-4 py-3 text-sm text-error-fg" data-testid="providers-error">
Couldn't load the {noun}.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else}
<p class="px-4 py-3 text-sm text-text-secondary">None available.</p>
{/if}
</SettingsGroup>
@@ -0,0 +1,146 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig, type SMTPConfig } from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode, errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Outgoing email (#5607): the mail server password resets and
// notifications go out through. Send test mails the signed-in admin.
const client = useQueryClient();
const smtpStore = createSMTPConfigQuery();
const smtpQ = $derived($smtpStore);
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
let form = $state<SMTPConfig>({
enabled: false,
host: '',
port: 587,
username: '',
password: '',
from_address: '',
from_name: 'Minstrel',
use_tls: true
});
let passwordInput = $state('');
let saving = $state(false);
let testing = $state(false);
// Filled from the saved config once, when it first loads, and again after
// a save, so a refetch doesn't throw edits away. Until it has loaded the
// form holds defaults, so Save stays off: saving then would write the
// defaults over the real config (#5604).
let loaded = $state(false);
$effect(() => {
if (smtpQ.data && !loaded) {
form = { ...smtpQ.data };
passwordInput = '';
loaded = true;
}
});
async function onSave() {
saving = true;
try {
await updateSMTPConfig({ ...form, password: passwordInput });
loaded = false;
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
passwordInput = '';
pushToast('Email settings saved.');
} catch (e: unknown) {
if (errCode(e) === 'missing_fields') pushToast('Server and from address are needed to send.', 'error');
else pushToast(`Not saved: ${errMessage(e)}`, 'error');
} finally {
saving = false;
}
}
async function onTest() {
testing = true;
try {
await testSMTPConfig();
pushToast('Test email sent. Check your inbox.');
} catch (e: unknown) {
const code = errCode(e);
const message = (e as { message?: string })?.message;
if (code === 'no_email_on_file') pushToast('Set your email in Settings › Account before testing.', 'error');
else if (code === 'not_configured') pushToast('Save the email settings first.', 'error');
else if (code === 'send_failed') pushToast(`Not sent: ${message || 'the server log says why'}`, 'error');
else pushToast(`Not sent: ${errMessage(e)}`, 'error');
} finally {
testing = false;
}
}
</script>
<SettingsGroup title="Outgoing email" id="email" hint="For password resets and notifications.">
{#if smtpQ.isError && !loaded}
<p class="px-4 py-3 text-sm text-error-fg" data-testid="smtp-error">
Couldn't load the email settings, so they can't be saved yet.
<button type="button" class="underline hover:no-underline" onclick={() => smtpQ.refetch()}>Try again</button>
</p>
{/if}
<SettingRow label="Send email" for="smtp-enabled">
<input
id="smtp-enabled"
type="checkbox"
role="switch"
aria-checked={form.enabled}
bind:checked={form.enabled}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
<SettingRow label="Server" for="smtp-host">
<input id="smtp-host" type="text" bind:value={form.host} placeholder="smtp.example.com" class={field} />
</SettingRow>
<SettingRow label="Port" for="smtp-port">
<input id="smtp-port" type="number" min="1" max="65535" bind:value={form.port} class="w-24 rounded-md border border-border bg-background px-3 py-1.5 text-sm" />
</SettingRow>
<SettingRow label="STARTTLS" for="smtp-tls" hint="Encrypt the connection to the server.">
<input
id="smtp-tls"
type="checkbox"
role="switch"
aria-checked={form.use_tls}
bind:checked={form.use_tls}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
<SettingRow label="Username" for="smtp-username">
<input id="smtp-username" type="text" bind:value={form.username} class={field} />
</SettingRow>
<SettingRow label="Password" for="smtp-password">
<input
id="smtp-password"
type="password"
placeholder={form.password === '***' ? 'Saved · leave empty to keep it' : ''}
bind:value={passwordInput}
class={field}
/>
</SettingRow>
<SettingRow label="From address" for="smtp-from">
<input id="smtp-from" type="email" bind:value={form.from_address} placeholder="minstrel@example.com" class={field} />
</SettingRow>
<SettingRow label="From name" for="smtp-from-name">
<input id="smtp-from-name" type="text" bind:value={form.from_name} class={field} />
</SettingRow>
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
<button type="button" disabled={testing} onclick={onTest} class={button}>
{testing ? 'Sending…' : 'Send test email'}
</button>
<button
type="button"
disabled={saving || !loaded}
onclick={onSave}
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</SettingsGroup>
@@ -0,0 +1,69 @@
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 { SMTPConfig } from '#lib/api/admin.js';
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
vi.mock('#lib/api/admin.js', () => ({
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn()
}));
import SmtpSettings from './SmtpSettings.svelte';
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig } from '#lib/api/admin.js';
const configured: SMTPConfig = {
enabled: true,
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '***',
from_address: 'noreply@example.com',
from_name: 'Minstrel',
use_tls: true
};
afterEach(() => vi.clearAllMocks());
function setup(q = mockQuery({ data: configured })) {
vi.mocked(createSMTPConfigQuery).mockReturnValue(q as never);
return render(SmtpSettings);
}
describe('SmtpSettings', () => {
test('the saved settings fill the rows, and a saved password is kept unless one is typed', async () => {
setup();
expect((screen.getByLabelText(/^server$/i) as HTMLInputElement).value).toBe('smtp.example.com');
expect(screen.getByPlaceholderText(/saved · leave empty to keep it/i)).toBeInTheDocument();
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ host: 'smtp.example.com', password: '' })));
});
test('a typed password is sent', async () => {
setup();
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'newsecret' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ password: 'newsecret' })));
});
test('Send test email sends one, and with no address on file says where to set it', async () => {
setup();
vi.mocked(testSMTPConfig).mockRejectedValueOnce({ code: 'no_email_on_file' });
await fireEvent.click(screen.getByRole('button', { name: /send test email/i }));
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/set your email in settings › account/i));
});
test('when the settings fail to load, Save is off, so defaults are never written over them', () => {
setup(mockQuery({ isError: true, error: { code: 'server_error' } }));
expect(screen.getByTestId('smtp-error')).toBeInTheDocument();
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
});
});
+2 -2
View File
@@ -34,10 +34,10 @@
"system_playlist_readonly": "System playlists can't be edited directly.",
"connection_refused": "Couldn't reach the server. Check the URL and try again.",
"musicbrainz_unreachable": "MusicBrainz can't be reached right now, so search is unavailable. Try again in a minute.",
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
"lidarr_disabled": "Lidarr integration is not enabled.",
"lidarr_auth_failed": "Lidarr authentication failed.",
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.",
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin › Acquisition.",
"lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.",
"lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
+4 -1
View File
@@ -32,7 +32,10 @@
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
{ href: '/admin/integrations', label: 'Integrations' },
{ href: '/admin/library', label: 'Library' },
{ href: '/admin/acquisition', label: 'Acquisition' },
{ href: '/admin/metadata', label: 'Metadata sources' },
{ href: '/admin/email', label: 'Email & network' },
{ href: '/admin/users', label: 'Users' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' }
+13 -293
View File
@@ -1,21 +1,6 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import StageBadge from '#lib/components/StageBadge.svelte';
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
import { stageState } from './stage-state';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createAdminSummaryQuery,
createScanStatusQuery,
createCoverageQuery,
createFingerprintCoverageQuery,
refetchMissingCovers,
triggerScan,
researchMissingArt
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
@@ -24,8 +9,6 @@
// waited, then the empty ones on one line (#189). The queues themselves
// are worked on their own pages; the Overview no longer repeats them.
const client = useQueryClient();
const summaryStore = createAdminSummaryQuery();
const summaryQ = $derived($summaryStore);
const summary = $derived(summaryQ.data);
@@ -33,87 +16,10 @@
const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0));
const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0));
// ---- Library scan ----
const scanStatusStore = $derived(createScanStatusQuery());
const scanStatusQ = $derived($scanStatusStore);
const scan = $derived(scanStatusQ.data);
const scanInFlight = $derived(scan?.in_flight === true);
// ---- Cover-art coverage gauge ----
const coverageStore = $derived(createCoverageQuery());
const coverageQ = $derived($coverageStore);
const coverage = $derived(coverageQ.data);
// ---- Fingerprint backfill gauge (#3908) ----
const fingerprintStore = $derived(createFingerprintCoverageQuery());
const fingerprintQ = $derived($fingerprintStore);
const fingerprints = $derived(fingerprintQ.data);
let triggering = $state(false);
let triggerResult = $state<string | null>(null);
async function onTriggerScan() {
if (triggering || scanInFlight) return;
triggering = true;
triggerResult = null;
try {
await triggerScan();
triggerResult = 'Scan started.';
await client.invalidateQueries({ queryKey: qk.scanStatus() });
await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) {
const code = errCode(e);
const status = (e as { status?: number })?.status;
if (status === 409) {
triggerResult = 'A scan is already running.';
} else {
triggerResult = `Failed: ${code}`;
}
} finally {
triggering = false;
}
}
function formatTime(iso: string | null | undefined): string {
if (!iso) return '—';
return new Date(iso).toLocaleString();
}
// ---- Cover art bulk refetch ----
let bulkBusy = $state(false);
let bulkResult = $state<string | null>(null);
// ---- Re-search missing art ----
let researchSaving = $state(false);
async function onResearchMissingArt() {
researchSaving = true;
try {
await researchMissingArt();
pushToast('All previously-failed art will be re-attempted on the next scan.');
} catch (e) {
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
} finally {
researchSaving = false;
}
}
async function onBulkRefetch() {
if (bulkBusy) return;
bulkBusy = true;
bulkResult = null;
try {
const { started } = await refetchMissingCovers();
bulkResult = started
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
: 'Could not start the cover refetch.';
await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) {
bulkResult = `Failed: ${errCode(e)}`;
} finally {
bulkBusy = false;
}
}
// A failed library scan needs you as much as a queue does. The scan
// itself, and the jobs after it, are on Admin › Library (#5607).
const scanStore = createScanStatusQuery();
const scan = $derived($scanStore.data);
</script>
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
@@ -141,11 +47,14 @@
{#if problems > 0}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#if scan?.error_message}
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3" data-testid="needs-you">
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
{#if scan.finished_at}
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
{/if}
<li data-testid="needs-you">
<a href="/admin/library" class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 hover:bg-surface-hover">
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
{#if scan.finished_at}
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
{/if}
<span class="ml-auto text-sm text-accent-fg">Open</span>
</a>
</li>
{/if}
{#each waiting as d (d.key)}
@@ -169,194 +78,5 @@
{/if}
{/if}
</section>
<!-- Library scan -->
<section class="rounded border border-border bg-surface p-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-display text-lg font-medium">Library scan</h2>
<p class="mt-1 text-sm text-text-secondary">
Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers.
</p>
</div>
<button
type="button"
onclick={onTriggerScan}
disabled={triggering || scanInFlight}
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50"
>
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
</button>
</div>
{#if !scan || !scan.id}
<p class="mt-3 text-sm text-text-muted">No scan has run yet.</p>
{:else}
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<!-- Library stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
<StageBadge state={stageState(scan, 'library')} />
</div>
{#if scan.library}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Scanned</dt><dd>{scan.library.scanned}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Added</dt><dd>{scan.library.added}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Updated</dt><dd>{scan.library.updated}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.library.skipped}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Errored</dt><dd>{scan.library.errored}</dd></div>
</dl>
{:else if stageState(scan, 'library') === 'pending'}
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
{/if}
</div>
<!-- MBID backfill stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
<StageBadge state={stageState(scan, 'mbid_backfill')} />
</div>
{#if scan.mbid_backfill}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.mbid_backfill.processed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Healed</dt><dd>{scan.mbid_backfill.healed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.mbid_backfill.skipped}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Duplicates</dt><dd>{scan.mbid_backfill.duplicates ?? 0}</dd></div>
</dl>
{:else if stageState(scan, 'mbid_backfill') === 'pending'}
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
{/if}
</div>
<!-- Cover enrichment stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
<StageBadge state={stageState(scan, 'cover_enrich')} />
</div>
{#if scan.cover_enrich}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.cover_enrich.processed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.cover_enrich.succeeded}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.cover_enrich.failed}</dd></div>
</dl>
{:else if stageState(scan, 'cover_enrich') === 'pending'}
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
{/if}
</div>
<!-- Artist art enrichment stage -->
<div class="rounded border border-border bg-bg p-3">
<div class="flex items-baseline justify-between gap-2">
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
<StageBadge state={stageState(scan, 'artist_art_enrich')} />
</div>
{#if scan.artist_art_enrich}
<dl class="mt-2 space-y-0.5 text-sm">
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.artist_art_enrich.processed}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.artist_art_enrich.succeeded}</dd></div>
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.artist_art_enrich.failed}</dd></div>
</dl>
{:else if stageState(scan, 'artist_art_enrich') === 'pending'}
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
{/if}
</div>
</div>
<div class="mt-3 text-xs text-text-muted">
Started {formatTime(scan.started_at)}
{#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if}
{#if scan.error_message}· <span class="text-error-fg">Error: {scan.error_message}</span>{/if}
</div>
{/if}
{#if triggerResult}
<p class="mt-2 text-sm">{triggerResult}</p>
{/if}
<!-- Fingerprint backfill (#3908). A worker of its own rather than a scan
stage, so its progress is read live here, not from the run above. -->
{#if fingerprints && fingerprints.total > 0}
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
<span class="text-xs font-medium uppercase tracking-wide text-text-muted">Fingerprints</span>
<span>{fingerprints.fingerprinted.toLocaleString()} of {fingerprints.total.toLocaleString()} tracks</span>
{#if fingerprints.pending > 0}
<span class="text-text-muted">·</span>
<span>{fingerprints.pending.toLocaleString()} pending</span>
{/if}
{#if fingerprints.enabled === false}
<!-- Off (#3913), pending never shrinks; say why rather than imply progress. -->
<span class="text-text-muted">·</span>
<a href="/admin/duplicates" class="underline hover:no-underline">fingerprinting is off</a>
{/if}
{#if fingerprints.rejected > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="The fingerprint tools could not read these files. Each is tried again when its file changes."
>
{fingerprints.rejected.toLocaleString()} unreadable
</span>
{/if}
</div>
{/if}
</section>
<!-- Loudness analysis (#4995): a background worker like the fingerprint
backfill, so its progress lives on its own card. -->
<LoudnessSettingsCard />
<!-- Cover art bulk refetch -->
<section class="rounded border border-border bg-surface p-4">
<h2 class="font-display text-lg font-medium">Cover art</h2>
<p class="mt-1 text-sm text-text-secondary">
Refetch cover art for albums that are missing one or where the previous attempt failed.
</p>
<div class="mt-3 flex flex-wrap items-center gap-4">
<button
type="button"
onclick={onBulkRefetch}
disabled={bulkBusy}
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
>
{bulkBusy ? 'Queueing…' : 'Refetch missing covers'}
</button>
<button
type="button"
onclick={onResearchMissingArt}
disabled={researchSaving}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
{researchSaving ? 'Bumping…' : 'Re-search missing art'}
</button>
{#if coverage}
{#if coverage.total === 0}
<span class="text-sm text-text-muted">Library is empty.</span>
{:else}
<div class="flex items-center gap-3 text-sm">
<span>{coverage.with_art.toLocaleString()} with art</span>
<span class="text-text-muted">·</span>
<span
class={coverage.pending_no_mbid > 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
</span>
<span class="text-text-muted">·</span>
<span>{coverage.settled.toLocaleString()} settled</span>
</div>
{/if}
{/if}
</div>
{#if bulkResult}
<p class="mt-2 text-sm">{bulkResult}</p>
{/if}
</section>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import LidarrSettings from '#lib/components/admin/LidarrSettings.svelte';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
// Admin › Acquisition (#5607): how Minstrel gets music. Where it searches
// and what downloads, how it searches and what it takes, what happens to
// a file that goes missing, and the optional Lidarr connection.
</script>
<svelte:head><title>{pageTitle('Admin · Acquisition')}</title></svelte:head>
<h2 class="sr-only">Acquisition</h2>
<SettingsColumns>
{#snippet main()}
<AcquisitionCard />
<SearchSettingsCard />
{/snippet}
{#snippet side()}
<div id="reacquisition"><ReacquisitionSettingsCard /></div>
<LidarrSettings />
{/snippet}
</SettingsColumns>
+9 -60
View File
@@ -26,30 +26,7 @@ vi.mock('#lib/api/admin.js', async () => {
isPending: false,
isError: false
})
),
createCoverageQuery: () =>
readable({
data: undefined,
isPending: false,
isError: false
}),
createFingerprintCoverageQuery: () =>
readable({
data: undefined,
isPending: false,
isError: false
}),
triggerScan: vi.fn().mockResolvedValue({}),
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
// LoudnessSettingsCard, rendered by the page and tested on its own.
getLoudnessSettings: vi
.fn()
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
updateLoudnessSettings: vi.fn(),
getLoudnessCoverage: vi.fn().mockResolvedValue({
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
})
)
};
});
@@ -88,11 +65,7 @@ describe('admin/+layout.ts load gate', () => {
// where the page picked up empty data despite `mockReturnValue` being set.
// Importing at module load means the mocks attach to the same handles the
// page binds against.
import {
createAdminSummaryQuery,
createScanStatusQuery,
researchMissingArt
} from '#lib/api/admin.js';
import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
import type { AdminSummary } from '#lib/api/types.js';
import OverviewPage from './+page.svelte';
import AdminLayout from './+layout.svelte';
@@ -155,7 +128,9 @@ describe('admin Overview page', () => {
setupOverview();
render(OverviewPage);
expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/);
expect(screen.getByText(/the last library scan failed: library root not mounted/i)).toBeInTheDocument();
expect(screen.getByText(/the last library scan failed: library root not mounted/i).closest('a')?.getAttribute('href')).toBe(
'/admin/library'
);
});
test('when the queues fail to load it says so, with Try again, rather than reading as all-clear', async () => {
@@ -167,35 +142,6 @@ describe('admin Overview page', () => {
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(refetch).toHaveBeenCalled();
});
test('renders Re-search missing art button and invokes on click', async () => {
setupOverview();
render(OverviewPage);
const btn = screen.getByRole('button', { name: /re-search missing art/i });
await fireEvent.click(btn);
expect(researchMissingArt).toHaveBeenCalled();
});
test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => {
const { readable } = await import('svelte/store');
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
readable({
data: {
id: 'in-flight',
started_at: '2026-05-06T11:00:00Z',
finished_at: null,
in_flight: true,
library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }
},
isPending: false,
isError: false
})
);
setupOverview();
userState.current = { id: 'admin', username: 'admin', is_admin: true };
render(OverviewPage);
expect(await screen.findByText('Processing')).toBeInTheDocument();
});
});
describe('admin layout', () => {
@@ -216,7 +162,10 @@ describe('admin layout', () => {
['/admin/duplicates', 'Duplicates 5'],
['/admin/suspect-sources', 'Suspect sources'],
['/admin/missing-files', 'Missing files'],
['/admin/integrations', 'Integrations'],
['/admin/library', 'Library'],
['/admin/acquisition', 'Acquisition'],
['/admin/metadata', 'Metadata sources'],
['/admin/email', 'Email & network'],
['/admin/users', 'Users'],
['/admin/diagnostics', 'Diagnostics'],
['/admin/tuning', 'Tuning']
+8 -11
View File
@@ -18,7 +18,6 @@
DuplicatesView,
ResolvedDuplicateActivity
} from '#lib/api/types.js';
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
@@ -73,11 +72,6 @@
{ id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved }
]);
function refetchAll() {
query.refetch();
resolved.refetch();
}
async function onRunSweep() {
sweeping = true;
try {
@@ -312,8 +306,8 @@
<p class="mt-3 text-text-primary">Nothing to compare yet.</p>
<p class="mt-1 text-sm text-text-secondary">
{#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
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
{: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}
<!-- Settings sit under the report whatever state it is in: turning
fingerprinting back on is how an empty report gets out of that state. -->
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={refetchAll} />
<!-- Fingerprinting is set under Library (#5607); the line says where,
since turning it back on is how an empty report gets out of that state. -->
<p class="text-xs text-text-secondary" data-testid="fingerprint-settings-link">
Fingerprinting is {prints?.enabled === false ? 'off' : 'on'}. It's set under
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
</p>
</div>
@@ -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', () => {
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import SmtpSettings from '#lib/components/admin/SmtpSettings.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
// Admin › Email & network (#5607): the mail server and when mail goes
// out, and how Minstrel sits behind the rest of the network: the address
// links point at, and the proxies in front of it.
</script>
<svelte:head><title>{pageTitle('Admin · Email & network')}</title></svelte:head>
<h2 class="sr-only">Email and network</h2>
<SettingsColumns>
{#snippet main()}
<SmtpSettings />
<NotificationEmailCard />
{/snippet}
{#snippet side()}
<PublicAddressCard />
<NetworkSettingsCard />
{/snippet}
</SettingsColumns>
@@ -1,898 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { pageTitle } from '#lib/branding.js';
import { Save, RefreshCw, Trash2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
updateSMTPConfig,
testSMTPConfig,
createSMTPConfigQuery,
type CoverProvider,
type TagProvider,
type SMTPConfig
} from '#lib/api/admin.js';
import { qk } from '#lib/api/queries.js';
import { errCode, errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte';
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
import NetworkSettingsCard from '#lib/components/NetworkSettingsCard.svelte';
import PublicAddressCard from '#lib/components/PublicAddressCard.svelte';
import NotificationEmailCard from '#lib/components/NotificationEmailCard.svelte';
import type { LidarrConfig, LidarrTestResult } from '#lib/api/types.js';
// Lidarr connection panel. The "saved api key" is masked as "***" on GET —
// never displayed in the input. The input always starts empty; sending an
// empty string on PUT tells the backend "preserve the saved key". This is
// the one field with that semantics; everything else is sent as-typed.
const client = useQueryClient();
const configStore = createLidarrConfigQuery();
const config = $derived($configStore);
// Local form state — initialized once when config first loads. Subsequent
// refetches don't clobber whatever the operator is in the middle of editing.
let baseUrl = $state('');
let apiKeyInput = $state(''); // intentionally never seeded with '***'
let qualityId = $state<number>(0);
let metadataId = $state<number>(0);
let rootPath = $state<string>('');
let initialized = false;
// Test-derived lists. Populated by onTest or as the first step of
// onSave. When non-empty, dropdowns prefer these over the query data
// (the query path only works once Lidarr is enabled, which on a
// fresh deploy isn't true until after the first successful save).
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
let testListErrors = $state<Record<string, string>>({});
$effect(() => {
const c = config.data;
if (c && !initialized) {
baseUrl = c.base_url;
qualityId = c.default_quality_profile_id;
metadataId = c.default_metadata_profile_id;
rootPath = c.default_root_folder_path;
initialized = true;
}
});
// Auto-default to the first available option. Sources, in priority
// order: the test response (works pre-save), then the query data
// (works post-save). The operator can still override after auto-fill.
$effect(() => {
if (qualityId === 0) {
const list = testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? []);
if (list.length > 0) qualityId = list[0].id;
}
});
$effect(() => {
if (metadataId === 0) {
const list = testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? []);
if (list.length > 0) metadataId = list[0].id;
}
});
$effect(() => {
if (rootPath === '') {
const list = testRootFolders.length ? testRootFolders : (folders.data ?? []);
if (list.length > 0) rootPath = list[0].path;
}
});
// The dropdown queries only fire once Lidarr is configured; otherwise the
// backend has no client to call and would 4xx.
const profilesEnabled = $derived(!!config.data?.enabled);
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
const profiles = $derived($profilesStore);
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
const metadataProfiles = $derived($metadataProfilesStore);
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
const folders = $derived($foldersStore);
let testResult: LidarrTestResult | null = $state(null);
let saveError: string | null = $state(null);
let isSaving = $state(false);
let isTesting = $state(false);
async function onSave() {
isSaving = true;
saveError = null;
try {
// Test first: confirm the connection AND fetch profiles/folders so
// the auto-default $effects can fill any zero/empty defaults.
const test = await testLidarrConnection({
base_url: baseUrl,
api_key: apiKeyInput
});
testResult = test;
if (!test.ok) {
saveError = test.error;
return;
}
testQualityProfiles = test.quality_profiles ?? [];
testMetadataProfiles = test.metadata_profiles ?? [];
testRootFolders = test.root_folders ?? [];
testListErrors = test.list_errors ?? {};
// Wait one tick for the auto-default $effects to fire on the
// newly-populated lists.
await tick();
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
saveError = 'missing_defaults_after_test';
return;
}
const cfg: LidarrConfig = {
enabled: true,
base_url: baseUrl,
api_key: apiKeyInput, // empty string tells backend "preserve saved key"
default_quality_profile_id: qualityId,
default_metadata_profile_id: metadataId,
default_root_folder_path: rootPath
};
await putLidarrConfig(cfg);
// Invalidate config + profile/folder lists. A new base_url means a
// different server, so the cached lists must refetch.
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
apiKeyInput = '';
} catch (e) {
const code = errCode(e);
saveError = code === 'unknown' ? 'save_failed' : code;
} finally {
isSaving = false;
}
}
async function onTest() {
isTesting = true;
testResult = null;
try {
const result = await testLidarrConnection({
base_url: baseUrl,
api_key: apiKeyInput
});
testResult = result;
if (result.ok) {
testQualityProfiles = result.quality_profiles ?? [];
testMetadataProfiles = result.metadata_profiles ?? [];
testRootFolders = result.root_folders ?? [];
testListErrors = result.list_errors ?? {};
}
} catch (e) {
// A test that can't reach the server says so, rather than leaving
// the button to stop spinning with nothing said (#5604).
testResult = { ok: false, error: errMessage(e) };
} finally {
isTesting = false;
}
}
// Disconnect typed-confirm modal. Requires the literal "DISCONNECT" string
// (whitespace-trimmed) to avoid muscle-memory clearing of an integration the
// operator depends on.
let modalOpen = $state(false);
let disconnectInput = $state('');
let disconnectError = $state<string | null>(null);
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
async function onConfirmDisconnect() {
if (!canDisconnect) return;
disconnectError = null;
try {
await putLidarrConfig({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
});
await Promise.all([
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
]);
modalOpen = false;
disconnectInput = '';
} catch (e) {
const code = errCode(e);
disconnectError = code === 'unknown' ? 'disconnect_failed' : code;
}
}
function cancelDisconnect() {
modalOpen = false;
disconnectInput = '';
disconnectError = null;
}
// ---- Cover art providers ----
const coverProvidersStore = $derived(createCoverProvidersQuery());
const coverProvidersQ = $derived($coverProvidersStore);
// Local edit state — keyed by provider_id. Initialised lazily when
// the query data lands, then preserved across refetches so the
// operator's in-flight edits don't get clobbered.
let coverLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let coverTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let coverSaving = $state<Record<string, boolean>>({});
let coverTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (coverProvidersQ.data) {
for (const p of coverProvidersQ.data.providers) {
if (!coverLocalState[p.id]) {
coverLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function coverHasChanges(provider: CoverProvider): boolean {
const local = coverLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function coverSave(provider: CoverProvider) {
const local = coverLocalState[provider.id];
if (!local || !coverHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
coverSaving[provider.id] = true;
try {
const result = await updateCoverProvider(provider.id, patch);
coverLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.coverProviders() });
if (result.version_bumped) {
await client.invalidateQueries({ queryKey: qk.coverage() });
}
} catch (e) {
pushToast(`Couldn't save ${provider.display_name}: ${errMessage(e)}`, 'error');
} finally {
coverSaving[provider.id] = false;
}
}
async function coverTest(provider: CoverProvider) {
coverTesting[provider.id] = true;
try {
coverTestResults[provider.id] = await testCoverProvider(provider.id);
} catch (e) {
coverTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
coverTesting[provider.id] = false;
}
}
// ---- Tag enrichment providers ----
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
const tagProvidersStore = $derived(createTagProvidersQuery());
const tagProvidersQ = $derived($tagProvidersStore);
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let tagSaving = $state<Record<string, boolean>>({});
let tagTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (tagProvidersQ.data) {
for (const p of tagProvidersQ.data.providers) {
if (!tagLocalState[p.id]) {
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function tagHasChanges(provider: TagProvider): boolean {
const local = tagLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function tagSave(provider: TagProvider) {
const local = tagLocalState[provider.id];
if (!local || !tagHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
tagSaving[provider.id] = true;
try {
const result = await updateTagProvider(provider.id, patch);
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.tagProviders() });
} catch (e) {
pushToast(`Couldn't save ${provider.display_name}: ${errMessage(e)}`, 'error');
} finally {
tagSaving[provider.id] = false;
}
}
async function tagTest(provider: TagProvider) {
tagTesting[provider.id] = true;
try {
tagTestResults[provider.id] = await testTagProvider(provider.id);
} catch (e) {
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
tagTesting[provider.id] = false;
}
}
// SMTP config -------------------------------------------------------------
const smtpStore = $derived(createSMTPConfigQuery());
const smtpQ = $derived($smtpStore);
let smtpForm = $state<SMTPConfig>({
enabled: false, host: '', port: 587, username: '',
password: '', from_address: '', from_name: 'Minstrel', use_tls: true,
});
let smtpPasswordInput = $state('');
let smtpSaving = $state(false);
let smtpTesting = $state(false);
// The form is filled from the saved config once, when it first loads,
// and again after a save. Re-filling on every refetch threw away edits
// in progress, and until a load succeeds the form holds defaults, so
// Save stays off then: saving it would write those defaults over the
// real config (#5604).
let smtpLoaded = $state(false);
$effect(() => {
if (smtpQ.data && !smtpLoaded) {
smtpForm = { ...smtpQ.data };
smtpPasswordInput = '';
smtpLoaded = true;
}
});
async function onSaveSMTP() {
smtpSaving = true;
try {
await updateSMTPConfig({ ...smtpForm, password: smtpPasswordInput });
smtpLoaded = false;
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
smtpPasswordInput = '';
pushToast('SMTP config saved.');
} catch (e: unknown) {
const code = errCode(e);
if (code === 'missing_fields') pushToast('Host and from address are required when enabled.', 'error');
else pushToast(`Save failed: ${code}`, 'error');
} finally {
smtpSaving = false;
}
}
async function onTestSMTP() {
smtpTesting = true;
try {
await testSMTPConfig();
pushToast('Test email sent. Check your inbox.');
} catch (e: unknown) {
const code = errCode(e);
const message = (e as { message?: string })?.message;
if (code === 'no_email_on_file') pushToast('Set your email in Settings › Account before testing.', 'error');
else if (code === 'not_configured') pushToast('Save the SMTP config first.', 'error');
else if (code === 'send_failed') pushToast(`Send failed: ${message || 'see server logs'}`, 'error');
else pushToast(`Test failed: ${code}`, 'error');
} finally {
smtpTesting = false;
}
}
</script>
<svelte:head><title>{pageTitle('Admin · Integrations')}</title></svelte:head>
<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="font-display text-2xl font-medium text-text-primary">Integrations</h2>
{#if config.data?.enabled}
<span
class="inline-flex items-center gap-2 rounded-md bg-accent-tint px-3 py-1 text-xs font-medium text-action-primary"
>
<span class="h-1.5 w-1.5 rounded-full bg-action-primary"></span>
Lidarr · connected
</span>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
>
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
Lidarr · unset
</span>
{/if}
</header>
<AcquisitionCard />
<SearchSettingsCard />
<!-- Lidarr panel -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Lidarr</h3>
<p class="mt-1 text-sm text-text-secondary">
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.
</p>
</div>
<label class="block">
<span class="block text-sm text-text-secondary">Base URL</span>
<input
type="text"
bind:value={baseUrl}
placeholder="http://lidarr.lan:8686"
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">API key</span>
<input
type="password"
bind:value={apiKeyInput}
placeholder={config.data?.api_key === '***'
? '••• (saved — leave empty to keep)'
: 'Paste API key'}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default quality profile</span>
<select
bind:value={qualityId}
disabled={!profilesEnabled && testQualityProfiles.length === 0}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each (testQualityProfiles.length ? testQualityProfiles : (profiles.data ?? [])) as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{#if testListErrors.quality_profiles}
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch quality profiles — {testListErrors.quality_profiles}
</p>
{/if}
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default metadata profile</span>
<select
bind:value={metadataId}
disabled={!profilesEnabled && testMetadataProfiles.length === 0}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each (testMetadataProfiles.length ? testMetadataProfiles : (metadataProfiles.data ?? [])) as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
{#if testListErrors.metadata_profiles}
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch metadata profiles — {testListErrors.metadata_profiles}
</p>
{/if}
</label>
<label class="block">
<span class="block text-sm text-text-secondary">Default root folder</span>
<select
bind:value={rootPath}
disabled={!profilesEnabled && testRootFolders.length === 0}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent disabled:opacity-50"
>
{#each (testRootFolders.length ? testRootFolders : (folders.data ?? [])) as f (f.path)}
<option value={f.path}
>{f.path}{f.accessible ? '' : ' (not accessible)'}</option
>
{/each}
</select>
{#if testListErrors.root_folders}
<p class="mt-1 text-xs text-error-fg">
Couldn't fetch root folders — {testListErrors.root_folders}
</p>
{/if}
</label>
{#if testResult}
{#if testResult.ok}
<p class="text-sm text-action-primary">
Connected — Lidarr {testResult.version}
</p>
{:else}
<p class="text-sm text-error-fg">Connection failed — {testResult.error}</p>
{/if}
{/if}
{#if saveError}
<p class="text-sm text-error-fg">Save failed — {saveError}</p>
{/if}
<div class="flex items-center gap-2 pt-2">
<button
type="button"
onclick={onSave}
disabled={isSaving}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
<Save size={14} strokeWidth={2} />
Save changes
</button>
<button
type="button"
onclick={onTest}
disabled={isTesting}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
<RefreshCw size={14} strokeWidth={2} />
Test connection
</button>
<button
type="button"
onclick={() => (modalOpen = true)}
class="ml-auto inline-flex items-center gap-2 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg"
>
<Trash2 size={14} strokeWidth={2} />
Disconnect
</button>
</div>
</section>
<!-- AcoustID recording-id lookup (M401). Took the place of a "MusicBrainz
overrides" placeholder that held the slot for a non-Lidarr integration. -->
<AcoustIDCard />
<!-- Cover art providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Cover art providers</h3>
<p class="mt-1 text-sm text-text-secondary">
External sources for album covers and artist images. Providers
are tried in registration order; first hit wins.
</p>
</div>
{#if coverProvidersQ.data}
{#each coverProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if coverLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="enabled-{provider.id}">Enabled</label>
<div>
<input id="enabled-{provider.id}" type="checkbox"
bind:checked={coverLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Leave blank to use the public test key'}
bind:value={coverLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!coverHasChanges(provider) || coverSaving[provider.id]}
onclick={() => coverSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{coverSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={coverTesting[provider.id]}
onclick={() => coverTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{coverTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if coverTestResults[provider.id]}
{#if coverTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{coverTestResults[provider.id].duration_ms ? ` (${coverTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error-fg">
Failed — {coverTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'theaudiodb'}
<p class="text-xs text-text-muted">
Public test key works out of the box. A free personal key gives separate quota
accounting — apply at <a class="underline" href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer">theaudiodb.com/api_apply.php</a>.
</p>
{/if}
</article>
{/each}
{:else if coverProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else if coverProvidersQ.isError}
<p class="text-sm text-error-fg" data-testid="cover-providers-error">
Couldn't load the cover art providers.
<button type="button" class="underline hover:no-underline" onclick={() => coverProvidersQ.refetch()}>Try again</button>
</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- Tag enrichment providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
<p class="mt-1 text-sm text-text-secondary">
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.
</p>
</div>
{#if tagProvidersQ.data}
{#each tagProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if tagLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
<div>
<input id="tag-enabled-{provider.id}" type="checkbox"
bind:checked={tagLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="tag-key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
bind:value={tagLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
onclick={() => tagSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={tagTesting[provider.id]}
onclick={() => tagTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if tagTestResults[provider.id]}
{#if tagTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error-fg">
Failed — {tagTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'musicbrainz'}
<p class="text-xs text-text-muted">
Keyless and on by default — the always-available baseline. Matches tags by
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
</p>
{:else if provider.id === 'lastfm'}
<p class="text-xs text-text-muted">
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
</p>
{/if}
</article>
{/each}
{:else if tagProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else if tagProvidersQ.isError}
<p class="text-sm text-error-fg" data-testid="tag-providers-error">
Couldn't load the tag providers.
<button type="button" class="underline hover:no-underline" onclick={() => tagProvidersQ.refetch()}>Try again</button>
</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- SMTP / email config -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Email (SMTP)</h3>
<p class="mt-1 text-sm text-text-secondary">
Configure outgoing email for password resets and notifications. The "Send test email"
button verifies your config end-to-end.
</p>
</div>
{#if smtpQ.isError && !smtpLoaded}
<p class="text-sm text-error-fg" data-testid="smtp-error">
Couldn't load the email settings, so they can't be saved yet.
<button type="button" class="underline hover:no-underline" onclick={() => smtpQ.refetch()}>Try again</button>
</p>
{/if}
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={smtpForm.enabled} />
Enabled
</label>
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
<label for="smtp-host" class="text-sm text-text-secondary">Host</label>
<input id="smtp-host" type="text" bind:value={smtpForm.host}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<label for="smtp-port" class="text-sm text-text-secondary">Port</label>
<input id="smtp-port" type="number" min="1" max="65535"
bind:value={smtpForm.port}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<label for="smtp-username" class="text-sm text-text-secondary">Username</label>
<input id="smtp-username" type="text" bind:value={smtpForm.username}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<label for="smtp-password" class="text-sm text-text-secondary">Password</label>
<input id="smtp-password" type="password"
placeholder={smtpForm.password === '***' ? 'Leave blank to keep current' : ''}
bind:value={smtpPasswordInput}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<label for="smtp-from" class="text-sm text-text-secondary">From address</label>
<input id="smtp-from" type="email" bind:value={smtpForm.from_address}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<label for="smtp-from-name" class="text-sm text-text-secondary">From name</label>
<input id="smtp-from-name" type="text" bind:value={smtpForm.from_name}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent" />
<span class="text-sm text-text-secondary">TLS</span>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={smtpForm.use_tls} />
Use STARTTLS
</label>
</div>
<div class="flex items-center gap-2 pt-2">
<button type="button" disabled={smtpSaving || !smtpLoaded} onclick={onSaveSMTP}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{smtpSaving ? 'Saving…' : 'Save'}
</button>
<button type="button" disabled={smtpTesting} onclick={onTestSMTP}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{smtpTesting ? 'Sending…' : 'Send test email'}
</button>
</div>
</section>
<NotificationEmailCard />
<!-- Client IP detection. Belongs here rather than in user Settings: it
describes how Minstrel sits behind other infrastructure, same as every
other card on this page, and it's an operator-wide setting rather than
a per-user preference. -->
<NetworkSettingsCard />
<PublicAddressCard />
</div>
<Modal
title="Disconnect Lidarr?"
open={modalOpen}
onClose={cancelDisconnect}
>
<p class="-mt-2 text-text-secondary">
This clears the saved configuration. Type
<span class="font-mono text-text-primary">DISCONNECT</span> to remove the
Lidarr connection.
</p>
<input
type="text"
bind:value={disconnectInput}
placeholder="DISCONNECT"
aria-label="Type DISCONNECT to confirm"
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
/>
{#if disconnectError}
<p class="mt-2 text-sm text-error-fg">Disconnect failed — {disconnectError}</p>
{/if}
<div class="mt-5 flex justify-end gap-2">
<button
type="button"
onclick={cancelDisconnect}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
>
Cancel
</button>
<button
type="button"
onclick={onConfirmDisconnect}
disabled={!canDisconnect}
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
>
Disconnect
</button>
</div>
</Modal>
@@ -1,808 +0,0 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within, cleanup } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '#lib/api/types.js';
import type { CoverProvider, TagProvider, SMTPConfig } from '#lib/api/admin.js';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
// so assertions see this named spy rather than the setup's anonymous fn.
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', () => ({
createLidarrConfigQuery: vi.fn(),
createQualityProfilesQuery: vi.fn(),
createMetadataProfilesQuery: vi.fn(),
createRootFoldersQuery: vi.fn(),
putLidarrConfig: vi.fn(),
testLidarrConnection: vi.fn(),
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn(),
createTagProvidersQuery: vi.fn(),
updateTagProvider: vi.fn(),
testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn(),
// AcoustIDCard loads on mount; a never-settling read leaves it on its own
// "Loading AcoustID settings…", which the page's /loading…/i queries skip.
getAcoustID: vi.fn(() => new Promise(() => {})),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(),
// NotificationEmailCard loads on mount; left pending like AcoustIDCard.
getNotificationEmailSettings: vi.fn(() => new Promise(() => {})),
updateNotificationEmailSettings: vi.fn(),
// AcquisitionCard loads on mount; left pending like AcoustIDCard.
listAcquisitionServices: vi.fn(() => new Promise(() => {})),
addAcquisitionService: vi.fn()
}));
// SearchSettingsCard reads its own query; settled, so it adds no "Loading…".
vi.mock('#lib/api/wanted.js', async () => {
const { mockQuery } = await import('../../../test-utils/query');
return {
createSearchSettingsQuery: vi.fn(() =>
mockQuery({
data: {
auto_grab: true,
search_interval_hours: 24,
// Not "Lossless": a Lidarr quality profile on this page is named that.
allowed_qualities: ['lossy_high'],
tiers: [{ tier: 'lossy_high', label: 'High quality lossy', what: 'MP3 320' }],
last_pass_at: null,
last_pass: {},
import_root: '',
// Not "/music": a Lidarr root folder on this page is named that.
scan_paths: ['/library'],
seed_ratio: 2,
seed_days: 14,
stall_hours: 6
}
})
),
putSearchSettings: vi.fn()
};
});
import IntegrationsPage from './+page.svelte';
import {
createLidarrConfigQuery,
createQualityProfilesQuery,
createMetadataProfilesQuery,
createRootFoldersQuery,
putLidarrConfig,
testLidarrConnection,
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
createSMTPConfigQuery,
updateSMTPConfig,
testSMTPConfig
} from '#lib/api/admin.js';
const cfgConnected: LidarrConfig = {
enabled: true,
base_url: 'http://lidarr.local',
api_key: '***',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
};
const cfgUnset: LidarrConfig = {
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_metadata_profile_id: 0,
default_root_folder_path: ''
};
// Fixture cover providers
const providerMbcaa: 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 providerTheAudioDB: 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 providerTheAudioDBKeySet: CoverProvider = {
...providerTheAudioDB,
api_key_set: true
};
const defaultCoverProviders = {
providers: [providerMbcaa, providerTheAudioDB],
sources_version: 1
};
// Fixture tag providers
const providerMusicbrainz: 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 providerLastfm: 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
};
const defaultTagProviders = {
providers: [providerMusicbrainz, providerLastfm],
sources_version: 1
};
const testResponseOk = {
ok: true as const,
version: '2.0.5',
quality_profiles: [
{ id: 1, name: 'FLAC' },
{ id: 2, name: 'MP3-320' }
],
metadata_profiles: [{ id: 1, name: 'Standard' }],
root_folders: [{ path: '/music', accessible: true, free_space: 12345 }]
};
const smtpUnset: SMTPConfig = {
enabled: false,
host: '',
port: 587,
username: '',
password: '',
from_address: '',
from_name: 'Minstrel',
use_tls: true
};
const smtpConfigured: SMTPConfig = {
enabled: true,
host: 'smtp.example.com',
port: 587,
username: 'user@example.com',
password: '***',
from_address: 'noreply@example.com',
from_name: 'Minstrel',
use_tls: true
};
afterEach(() => {
vi.clearAllMocks();
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Lossless' }
]
})
);
(createMetadataProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({
data: [
{ id: 1, name: 'Standard' },
{ id: 2, name: 'Singles only' }
]
})
);
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: [{ path: '/music', accessible: true, free_space: 0 }] })
);
const cpData = opts.coverProviders === null ? undefined : (opts.coverProviders ?? defaultCoverProviders);
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.smtp ?? smtpUnset })
);
return render(IntegrationsPage);
}
// Helper for finding the modal's Disconnect confirm button. Both the panel
// trigger and the modal confirm share the visible name "Disconnect"; the
// modal one is the descendant of role="dialog".
function modalConfirmButton(): HTMLButtonElement {
const matches = screen.getAllByRole('button', { name: /^disconnect$/i });
const inDialog = matches.find((b) => b.closest('[role="dialog"]') !== null);
if (!inDialog) throw new Error('modal Disconnect button not found');
return inDialog as HTMLButtonElement;
}
// Section-scoping helpers. Both Lidarr and the cover-art-providers panel render
// "Save changes", "Test connection", and "API key" controls, so unscoped queries
// match multiple elements. These helpers narrow every query to its own <section>.
function lidarrSection(): HTMLElement {
return screen.getByRole('heading', { name: /^lidarr$/i, level: 3 }).closest('section') as HTMLElement;
}
function coverProvidersSection(): HTMLElement {
return screen.getByRole('heading', { name: /cover art providers/i, level: 3 }).closest('section') as HTMLElement;
}
// Each provider is rendered inside an <article>. Provider headings are h4.
function providerCard(providerName: string): HTMLElement {
return within(coverProvidersSection())
.getByRole('heading', { name: providerName, level: 4 })
.closest('article') as HTMLElement;
}
function tagProvidersSection(): HTMLElement {
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
}
function tagProviderCard(providerName: string): HTMLElement {
return within(tagProvidersSection())
.getByRole('heading', { name: providerName, level: 4 })
.closest('article') as HTMLElement;
}
describe('/admin/integrations', () => {
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
setup();
// Save now runs Test first (commit bca8622); mock both endpoints. The
// test response also has to match enough of the existing config that
// the Save handler treats it as a valid passthrough.
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabled: true,
base_url: 'http://lidarr.local',
api_key: '',
default_quality_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
});
test('Save with new api_key sends the typed value', async () => {
setup();
const apiInput = screen.getByPlaceholderText(/saved — leave empty to keep/i);
await fireEvent.input(apiInput, { target: { value: 'newkey' } });
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
await fireEvent.click(within(lidarrSection()).getByRole('button', { name: /save changes/i }));
await waitFor(() => {
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({ api_key: 'newkey' })
);
});
});
test('Test connection renders Lidarr version on success', async () => {
setup();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
version: '2.0.0'
});
await fireEvent.click(
within(lidarrSection()).getByRole('button', { name: /test connection/i })
);
await waitFor(() =>
expect(screen.getByText(/lidarr 2\.0\.0/i)).toBeInTheDocument()
);
});
test('Test connection renders error code on failure', async () => {
setup();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lidarr_unreachable'
});
await fireEvent.click(
within(lidarrSection()).getByRole('button', { name: /test connection/i })
);
await waitFor(() =>
expect(screen.getByText(/lidarr_unreachable/i)).toBeInTheDocument()
);
});
test('Disconnect requires typed confirm; cancelling does not call API', async () => {
setup();
// Open modal via the panel-level Disconnect button.
await fireEvent.click(
screen.getByRole('button', { name: /^disconnect$/i })
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Confirm button is disabled until "DISCONNECT" is typed.
expect(modalConfirmButton().disabled).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(putLidarrConfig).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('Disconnect with typed confirmation calls API with cleared config', async () => {
setup();
await fireEvent.click(
screen.getByRole('button', { name: /^disconnect$/i })
);
const confirmInput = screen.getByPlaceholderText('DISCONNECT');
await fireEvent.input(confirmInput, { target: { value: 'DISCONNECT' } });
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgUnset);
await fireEvent.click(modalConfirmButton());
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
base_url: '',
api_key: '',
default_quality_profile_id: 0,
default_root_folder_path: ''
})
);
});
test('Quality / metadata / root folder dropdowns populate from API', () => {
setup();
// 'Standard' appears in both quality + metadata fixtures, so use *AllBy*.
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('Lossless')).toBeInTheDocument();
expect(screen.getByText('Singles only')).toBeInTheDocument();
expect(screen.getByText('/music')).toBeInTheDocument();
});
test('header shows "Lidarr · connected" when configured', () => {
setup();
expect(screen.getByText(/lidarr · connected/i)).toBeInTheDocument();
});
test('header shows "Lidarr · unset" when not configured', () => {
setup({ config: cfgUnset });
expect(screen.getByText(/lidarr · unset/i)).toBeInTheDocument();
});
});
describe('/admin/integrations — cover art providers', () => {
test('renders one card per provider in the response', () => {
setup();
expect(screen.getByText('MusicBrainz CAA')).toBeInTheDocument();
expect(screen.getByText('TheAudioDB')).toBeInTheDocument();
});
test('capability badges render for each capability in supports[]', () => {
setup();
// TheAudioDB has album_cover, artist_thumb, artist_fanart
expect(screen.getAllByText('album cover').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('artist thumb')).toBeInTheDocument();
expect(screen.getByText('artist fanart')).toBeInTheDocument();
});
test('"✓ Set" indicator renders when api_key_set is true', () => {
setup({
coverProviders: {
providers: [providerTheAudioDBKeySet],
sources_version: 1
}
});
expect(screen.getByText(/✓ set/i)).toBeInTheDocument();
});
test('"✓ Set" indicator does not render when api_key_set is false', () => {
setup();
expect(screen.queryByText(/✓ set/i)).not.toBeInTheDocument();
});
test('Save button is disabled initially (no changes)', () => {
setup();
const cpSection = coverProvidersSection();
const saveButtons = within(cpSection).getAllByRole('button', { name: /save changes/i });
// Both provider cards render Save buttons, both should be disabled initially
for (const btn of saveButtons) {
expect((btn as HTMLButtonElement).disabled).toBe(true);
}
});
test('Save button enables after typing in API key field', async () => {
setup();
const theAudioDbCard = providerCard('TheAudioDB');
const keyInput = within(theAudioDbCard).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'myapikey' } });
// The TheAudioDB save button should now be enabled
const theAudioDbSave = within(theAudioDbCard).getByRole('button', { name: /save changes/i });
expect(theAudioDbSave).toBeDefined();
expect((theAudioDbSave as HTMLButtonElement).disabled).toBe(false);
});
test('clicking Save calls updateCoverProvider with api_key patch body', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerTheAudioDB,
version_bumped: false
});
const theAudioDbCard = providerCard('TheAudioDB');
const keyInput = within(theAudioDbCard).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'newkey123' } });
const btn = within(theAudioDbCard).getByRole('button', { name: /save changes/i });
await fireEvent.click(btn);
expect(updateCoverProvider).toHaveBeenCalledWith(
'theaudiodb',
expect.objectContaining({ api_key: 'newkey123' })
);
});
test('a provider save that fails says so', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'server_error' });
const theAudioDbCard = providerCard('TheAudioDB');
await fireEvent.input(within(theAudioDbCard).getByLabelText(/api key/i), { target: { value: 'k' } });
await fireEvent.click(within(theAudioDbCard).getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i)
);
});
test('providers that fail to load say so, not "No providers registered"', () => {
setup();
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: { code: 'server_error' } })
);
cleanup();
render(IntegrationsPage);
expect(screen.getByTestId('cover-providers-error')).toBeInTheDocument();
});
test('when version_bumped: true, invalidateQueries is called with qk.coverage()', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerMbcaa,
enabled: false,
version_bumped: true
});
// Toggle enabled on MusicBrainz CAA card
const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!;
const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox));
await fireEvent.click(mbcaaSave!);
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['coverage'] })
);
});
});
test('when version_bumped: false, invalidateQueries is NOT called with qk.coverage()', async () => {
setup();
(updateCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerMbcaa,
enabled: false,
version_bumped: false
});
const mbcaaCard = screen.getByText('MusicBrainz CAA').closest('article')!;
const enabledCheckbox = mbcaaCard.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const saveButtons = screen.getAllByRole('button', { name: /save changes/i });
const mbcaaSave = saveButtons.find((b) => b.closest('article')?.contains(enabledCheckbox));
await fireEvent.click(mbcaaSave!);
await waitFor(() => {
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['coverProviders'] })
);
});
// coverage invalidation should NOT have been called
const coverageCalls = invalidateQueries.mock.calls.filter(
(call: any[]) => JSON.stringify(call[0]?.queryKey) === JSON.stringify(['coverage'])
);
expect(coverageCalls.length).toBe(0);
});
test('Test connection button calls testCoverProvider and shows OK result', async () => {
setup();
(testCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 120
});
const mbcaaCard = providerCard('MusicBrainz CAA');
await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i }));
await waitFor(() =>
expect(screen.getByText(/ok.*120ms/i)).toBeInTheDocument()
);
});
test('Test connection button shows failure message on error response', async () => {
setup();
(testCoverProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'mbcaa_unreachable'
});
const mbcaaCard = providerCard('MusicBrainz CAA');
await fireEvent.click(within(mbcaaCard).getByRole('button', { name: /test connection/i }));
await waitFor(() =>
expect(screen.getByText(/mbcaa_unreachable/i)).toBeInTheDocument()
);
});
test('TheAudioDB help text renders with link', () => {
setup();
expect(screen.getByText(/public test key works out of the box/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /theaudiodb\.com\/api_apply\.php/i })).toBeInTheDocument();
});
test('shows loading state when cover providers query is pending', () => {
setup({ coverProviders: null });
expect(screen.getByText(/loading…/i)).toBeInTheDocument();
});
});
function smtpSection(): HTMLElement {
return screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 }).closest('section') as HTMLElement;
}
describe('/admin/integrations — SMTP card', () => {
test('renders "Email (SMTP)" heading', () => {
setup();
expect(screen.getByRole('heading', { name: /email \(smtp\)/i, level: 3 })).toBeInTheDocument();
});
test('Save calls updateSMTPConfig with form values; empty password preserves stored value', async () => {
setup({ smtp: smtpConfigured });
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
// password input starts empty — should send '' which tells backend to preserve
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(updateSMTPConfig).toHaveBeenCalledWith(
expect.objectContaining({ password: '' })
)
);
});
test('Save with a typed password sends the typed value', async () => {
setup({ smtp: smtpConfigured });
(updateSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const section = smtpSection();
const pwInput = within(section).getByLabelText(/password/i);
await fireEvent.input(pwInput, { target: { value: 'newsecret' } });
await fireEvent.click(within(section).getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(updateSMTPConfig).toHaveBeenCalledWith(
expect.objectContaining({ password: 'newsecret' })
)
);
});
test('Test button calls testSMTPConfig', async () => {
setup();
(testSMTPConfig as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
});
test('no_email_on_file error from testSMTPConfig surfaces actionable toast', async () => {
setup();
(testSMTPConfig as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ code: 'no_email_on_file' });
const section = smtpSection();
await fireEvent.click(within(section).getByRole('button', { name: /send test email/i }));
await waitFor(() =>
expect(screen.getByTestId('toast').textContent).toMatch(/set your email in settings › account/i)
);
});
test('when the email settings fail to load, Save is off, so defaults are never written over them', () => {
setup();
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: { code: 'server_error' } })
);
cleanup();
render(IntegrationsPage);
const section = smtpSection();
expect(within(section).getByTestId('smtp-error')).toBeInTheDocument();
expect((within(section).getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
});
test('password input placeholder shows hint when existing password is masked', async () => {
setup({ smtp: smtpConfigured });
// smtpConfigured has password: '***' — placeholder should show "Leave blank to keep current"
const section = smtpSection();
await waitFor(() =>
expect(within(section).getByPlaceholderText(/leave blank to keep current/i)).toBeInTheDocument()
);
});
});
describe('Lidarr first-time setup (Test-then-Save)', () => {
// Fresh-install variant of setup() — the three list queries return
// empty arrays so we can verify the dropdowns get populated from the
// test response, not from the (empty) cached query data.
function setupFresh() {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cfgUnset })
);
(createQualityProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createMetadataProfilesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createRootFoldersQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: defaultCoverProviders })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: smtpUnset })
);
return render(IntegrationsPage);
}
test('Save calls test first, fills defaults from response, then put-config', async () => {
setupFresh();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
const section = lidarrSection();
await fireEvent.input(within(section).getByLabelText(/Base URL/i), {
target: { value: 'http://lidarr.local' }
});
await fireEvent.input(within(section).getByLabelText(/API key/i), {
target: { value: 'secret-key' }
});
await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i }));
await waitFor(() => {
expect(testLidarrConnection).toHaveBeenCalledWith({
base_url: 'http://lidarr.local',
api_key: 'secret-key'
});
});
await waitFor(() => {
expect(putLidarrConfig).toHaveBeenCalledWith({
enabled: true,
base_url: 'http://lidarr.local',
api_key: 'secret-key',
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
});
});
});
test('Save aborts when test fails and surfaces the error', async () => {
setupFresh();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lidarr_unreachable'
});
const section = lidarrSection();
await fireEvent.input(within(section).getByLabelText(/Base URL/i), {
target: { value: 'http://wrong.local' }
});
await fireEvent.input(within(section).getByLabelText(/API key/i), {
target: { value: 'k' }
});
await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i }));
await waitFor(() => {
expect(testLidarrConnection).toHaveBeenCalled();
});
expect(putLidarrConfig).not.toHaveBeenCalled();
await waitFor(() => {
expect(within(lidarrSection()).getByText(/save failed — lidarr_unreachable/i)).toBeInTheDocument();
});
});
test('Save preserves operator-picked defaults over auto-defaults', async () => {
setupFresh();
(testLidarrConnection as ReturnType<typeof vi.fn>).mockResolvedValue(testResponseOk);
(putLidarrConfig as ReturnType<typeof vi.fn>).mockResolvedValueOnce(cfgConnected);
const section = lidarrSection();
await fireEvent.input(within(section).getByLabelText(/Base URL/i), {
target: { value: 'http://lidarr.local' }
});
await fireEvent.input(within(section).getByLabelText(/API key/i), {
target: { value: 'secret-key' }
});
// Click Test first to populate the dropdowns, then change the
// quality profile selection before clicking Save.
await fireEvent.click(within(section).getByRole('button', { name: /Test connection/i }));
await waitFor(() => {
expect(testLidarrConnection).toHaveBeenCalledTimes(1);
});
const qualitySelect = within(section).getByLabelText(/Default quality profile/i);
await fireEvent.change(qualitySelect, { target: { value: '2' } });
await fireEvent.click(within(section).getByRole('button', { name: /Save changes/i }));
await waitFor(() => {
expect(putLidarrConfig).toHaveBeenCalledWith(
expect.objectContaining({
default_quality_profile_id: 2,
default_metadata_profile_id: 1,
default_root_folder_path: '/music'
})
);
});
});
});
describe('/admin/integrations tag sources', () => {
test('renders tag enrichment providers', () => {
setup();
const section = tagProvidersSection();
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
});
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
setup();
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerLastfm,
enabled: true,
api_key_set: true,
version_bumped: true
});
const card = tagProviderCard('Last.fm');
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const keyInput = within(card).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
expect(updateTagProvider).toHaveBeenCalledWith(
'lastfm',
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
);
await waitFor(() =>
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['tagProviders'] })
)
);
});
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
setup();
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
const card = tagProviderCard('MusicBrainz');
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
});
});
+214
View File
@@ -0,0 +1,214 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { useQueryClient } from '@tanstack/svelte-query';
import StageBadge from '#lib/components/StageBadge.svelte';
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
import {
createScanStatusQuery,
createCoverageQuery,
createFingerprintCoverageQuery,
refetchMissingCovers,
triggerScan,
researchMissingArt
} from '#lib/api/admin.js';
import { createSearchSettingsQuery } from '#lib/api/wanted.js';
import { qk } from '#lib/api/queries.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { stageState, type StageKey } from '../stage-state';
// Admin › Library (#5607): the folders Minstrel reads, the scan that reads
// them with a row for each of its jobs, and the background work that
// follows a scan: fingerprints, loudness and cover art. Moved here from
// the Overview, which now only says what needs you.
const client = useQueryClient();
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const searchStore = createSearchSettingsQuery();
const folders = $derived($searchStore.data?.scan_paths ?? []);
const scanStore = createScanStatusQuery();
const scan = $derived($scanStore.data);
const scanInFlight = $derived(scan?.in_flight === true);
const coverageStore = createCoverageQuery();
const coverage = $derived($coverageStore.data);
const fingerprintStore = createFingerprintCoverageQuery();
const prints = $derived($fingerprintStore.data);
const n = (v: number | undefined) => (v ?? 0).toLocaleString();
// One line for the scan as a whole: running, failed, or when it last ran.
const scanLine = $derived.by(() => {
if (!scan || !scan.id) return "Hasn't run yet.";
if (scanInFlight) return `Running · started ${relativeTime(scan.started_at)}`;
if (scan.error_message) return `Failed ${relativeTime(scan.finished_at ?? scan.started_at)}: ${scan.error_message}`;
return `Last ran ${relativeTime(scan.finished_at ?? scan.started_at)}`;
});
// The scan's jobs in the order they run, each as one line of what it did.
const jobs: { key: StageKey; label: string; line: () => string }[] = [
{
key: 'library',
label: 'Reading files',
line: () => {
const l = scan?.library;
if (!l) return '';
let s = `${n(l.scanned)} read · ${n(l.added)} new · ${n(l.updated)} changed`;
if (l.errored) s += ` · ${n(l.errored)} unreadable`;
return s;
}
},
{
key: 'mbid_backfill',
label: 'MusicBrainz ids',
line: () => {
const m = scan?.mbid_backfill;
if (!m) return '';
let s = `${n(m.processed)} looked up · ${n(m.healed)} filled in`;
if (m.duplicates) s += ` · ${n(m.duplicates)} duplicates`;
return s;
}
},
{
key: 'cover_enrich',
label: 'Album covers',
line: () => {
const c = scan?.cover_enrich;
return c ? `${n(c.processed)} tried · ${n(c.succeeded)} found · ${n(c.failed)} not found` : '';
}
},
{
key: 'artist_art_enrich',
label: 'Artist photos',
line: () => {
const a = scan?.artist_art_enrich;
return a ? `${n(a.processed)} tried · ${n(a.succeeded)} found · ${n(a.failed)} not found` : '';
}
}
];
let triggering = $state(false);
async function onRunScan() {
if (triggering || scanInFlight) return;
triggering = true;
try {
await triggerScan();
pushToast('Scan started.');
await client.invalidateQueries({ queryKey: qk.scanStatus() });
await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) {
if ((e as { status?: number })?.status === 409) pushToast('A scan is already running.');
else pushToast(`The scan didn't start: ${errMessage(e)}`, 'error');
} finally {
triggering = false;
}
}
let refetching = $state(false);
async function onRefetchCovers() {
if (refetching) return;
refetching = true;
try {
const { started } = await refetchMissingCovers();
if (started) pushToast('Looking for the missing covers. Outside sources are asked at their own pace.');
else pushToast("Couldn't start looking for covers.", 'error');
await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) {
pushToast(`Couldn't start looking for covers: ${errMessage(e)}`, 'error');
} finally {
refetching = false;
}
}
let researching = $state(false);
async function onResearch() {
researching = true;
try {
await researchMissingArt();
pushToast('Art no source had will be looked for again on the next scan.');
} catch (e) {
pushToast(`Not done: ${errMessage(e)}`, 'error');
} finally {
researching = false;
}
}
</script>
<svelte:head><title>{pageTitle('Admin · Library')}</title></svelte:head>
<h2 class="sr-only">Library</h2>
<SettingsColumns>
{#snippet main()}
<SettingsGroup title="Library" id="scan" hint="The folders Minstrel reads, and the scan that reads them.">
<SettingRow label="Folders" hint="Set where Minstrel is installed.">
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
{#each folders as f (f)}<span>{f}</span>{:else}<span>—</span>{/each}
</span>
</SettingRow>
<SettingRow label="Scan" hint={scanLine}>
<button type="button" onclick={onRunScan} disabled={triggering || scanInFlight} class={button}>
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
</button>
</SettingRow>
{#if scan?.id}
{#each jobs as job (job.key)}
<div data-testid="scan-job">
<SettingRow label={job.label} hint={job.line() || undefined}>
<StageBadge state={stageState(scan, job.key)} />
</SettingRow>
</div>
{/each}
{/if}
</SettingsGroup>
<SettingsGroup title="Cover art" id="covers">
<SettingRow label="Covers" hint={coverage
? coverage.total === 0
? 'The library is empty.'
: `${n(coverage.with_art)} with art · ${n(coverage.pending)} still to look for · ${n(coverage.settled)} no source has`
: undefined}>
<button type="button" onclick={onRefetchCovers} disabled={refetching} class={button}>
{refetching ? 'Starting…' : 'Look for missing covers'}
</button>
</SettingRow>
{#if coverage && coverage.pending_no_mbid > 0}
<SettingRow
label="Without a MusicBrainz id"
hint="{n(coverage.pending_no_mbid)} albums can't be looked up until their tags are fixed or they're matched."
/>
{/if}
<SettingRow label="Look again" hint="Ask every source again for art none of them had.">
<button type="button" onclick={onResearch} disabled={researching} class={button}>
{researching ? 'Starting…' : 'Look again'}
</button>
</SettingRow>
</SettingsGroup>
{/snippet}
{#snippet side()}
{#if prints && prints.total > 0}
<SettingsGroup title="Fingerprints" id="fingerprints-progress">
<SettingRow
label="Progress"
hint="{n(prints.fingerprinted)} of {n(prints.total)} tracks{prints.pending ? ` · ${n(prints.pending)} to go` : ''}{prints.rejected ? ` · ${n(prints.rejected)} unreadable` : ''}{prints.enabled === false ? ' · off' : ''}"
/>
</SettingsGroup>
{/if}
<div id="fingerprints">
<FingerprintSettingsCard
libraryTotal={prints?.total ?? 0}
onSaved={() => client.invalidateQueries({ queryKey: qk.fingerprintCoverage() })}
/>
</div>
<LoudnessSettingsCard />
{/snippet}
</SettingsColumns>
@@ -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<string, unknown>;
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<ScanStatus> = { 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();
});
});
@@ -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();
});
});
@@ -1,10 +1,14 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte';
import { createMissingFilesQuery } from '#lib/api/admin.js';
import {
createMissingFilesQuery,
getReacquisitionSettings,
type ReacquisitionSettings
} from '#lib/api/admin.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
import { onMount } from 'svelte';
import type { AdminMissingGroup } from '#lib/api/types.js';
// Files the scan looked for and could not find. Read-only on purpose:
@@ -35,6 +39,17 @@
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// What happens to these is set under Acquisition (#5607); the line above
// the list says what it is now, since this is where the question comes up.
let policy = $state<ReacquisitionSettings | null>(null);
onMount(async () => {
try {
policy = await getReacquisitionSettings();
} catch {
policy = null;
}
});
</script>
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
@@ -58,10 +73,16 @@
</p>
</header>
<!-- The policy sits above the list it governs: an operator looking at
missing files is exactly the person deciding what should happen to
them, and burying this under Integrations would separate the two. -->
<ReacquisitionSettingsCard />
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
{#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}
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
{policy ? 'Change' : 'Set what happens to missing files'}
</a>
</p>
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
@@ -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();
+1 -1
View File
@@ -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
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>.
</p>
{#if verdict}
<p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">