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://. " + "This server is on the internet, so its URL must start with https://. " +
"Plain http:// only works on your home network.", "Plain http:// only works on your home network.",
"lidarr_unreachable" to "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_disabled" to "Lidarr integration is not enabled.",
"lidarr_auth_failed" to "Lidarr authentication failed.", "lidarr_auth_failed" to "Lidarr authentication failed.",
"lidarr_defaults_incomplete" to "lidarr_defaults_incomplete" to
"Lidarr is missing a default quality profile or root folder. " + "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_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
"lidarr_rejected" to "lidarr_rejected" to
"Lidarr rejected the request. Check the server logs for the field-level reason.", "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'] 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 detection** to 0. It defaults to 1, which assumes one proxy (see below for
why that matters). 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: the app is. Raise the proxy's read timeout well past a minute (nginx:
`proxy_read_timeout 1h;`). `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 - **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. 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 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) | | `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
| `register.png` | `/register` | The account-creation form on a fresh instance | | `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) | | `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 | | `android-download.png` | `/settings` | The "Install the Android app" download card |
| `invite-users.png` | `/admin/users` | The invite-token generation / users list | | `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 // 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 // said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way. // 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. // buildResetURL builds the emailed link from the operator-set public URL.
// It deliberately ignores the request: the Host header is whatever the // 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"` URL string `json:"url"`
} }
// QualityProfile is the dropdown choice in /admin/integrations. // QualityProfile is the dropdown choice on Admin › Acquisition.
type QualityProfile struct { type QualityProfile struct {
ID int ID int
Name string Name string
@@ -41,7 +41,7 @@ type MetadataProfile struct {
Name string Name string
} }
// RootFolder is the dropdown choice in /admin/integrations. // RootFolder is the dropdown choice on Admin › Acquisition.
type RootFolder struct { type RootFolder struct {
Path string Path string
Accessible bool Accessible bool
+1 -1
View File
@@ -161,7 +161,7 @@ func (s *Server) Router() http.Handler {
) )
lidarrCfg := lidarrconfig.New(s.Pool) lidarrCfg := lidarrconfig.New(s.Pool)
// Per-call client factory: re-reads config so an admin save in // 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 // Returns nil when Lidarr is disabled, which the Service-layer
// methods translate to ErrLidarrDisabled. // methods translate to ErrLidarrDisabled.
lidarrClientFn := func() *lidarr.Client { lidarrClientFn := func() *lidarr.Client {
@@ -130,7 +130,7 @@
{#if settings.email_unavailable_reason === 'no_address'} {#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>. 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} {: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} {:else}
Email is off: this server doesn't send email. Email is off: this server doesn't send email.
{/if} {/if}
@@ -123,7 +123,7 @@ describe('NotificationSettings', () => {
render(NotificationSettings); render(NotificationSettings);
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute( expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
'href', 'href',
'/admin/integrations' '/admin/email'
); );
}); });
}); });
@@ -10,9 +10,9 @@
import { pushToast } from '#lib/stores/toast.svelte.js'; import { pushToast } from '#lib/stores/toast.svelte.js';
// Policy for putting an album whose files went missing back on the wanted // 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 // list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
// than under Integrations because this is where the operator meets the // of how music is got (#5607); Missing files says what it is set to and
// problem it solves. Searching for a want, and retrying, is the search // links here. Searching for a want, and retrying, is the search
// schedule's job, so there is no retry policy here. // schedule's job, so there is no retry policy here.
let saved = $state<ReacquisitionSettings | null>(null); 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.gone) return 'The indexers no longer offer that release.';
if (r.unconfigured) { if (r.unconfigured) {
return r.missing === 'download_client' return r.missing === 'download_client'
? 'No download client 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 Integrations.'; : 'No indexer is set up. Add one under Admin › Acquisition.';
} }
if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.'; if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.';
return r.error ?? '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.", "system_playlist_readonly": "System playlists can't be edited directly.",
"connection_refused": "Couldn't reach the server. Check the URL and try again.", "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.", "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_disabled": "Lidarr integration is not enabled.",
"lidarr_auth_failed": "Lidarr authentication failed.", "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_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_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.", "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/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') }, { href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') }, { 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/users', label: 'Users' },
{ href: '/admin/diagnostics', label: 'Diagnostics' }, { href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' } { href: '/admin/tuning', label: 'Tuning' }
+13 -293
View File
@@ -1,21 +1,6 @@
<script lang="ts"> <script lang="ts">
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import StageBadge from '#lib/components/StageBadge.svelte'; import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
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 { relativeTime } from '#lib/utils/relativeTime.js'; import { relativeTime } from '#lib/utils/relativeTime.js';
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.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 // waited, then the empty ones on one line (#189). The queues themselves
// are worked on their own pages; the Overview no longer repeats them. // are worked on their own pages; the Overview no longer repeats them.
const client = useQueryClient();
const summaryStore = createAdminSummaryQuery(); const summaryStore = createAdminSummaryQuery();
const summaryQ = $derived($summaryStore); const summaryQ = $derived($summaryStore);
const summary = $derived(summaryQ.data); const summary = $derived(summaryQ.data);
@@ -33,87 +16,10 @@
const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0)); const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0));
const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0)); const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0));
// ---- Library scan ---- // A failed library scan needs you as much as a queue does. The scan
const scanStatusStore = $derived(createScanStatusQuery()); // itself, and the jobs after it, are on Admin › Library (#5607).
const scanStatusQ = $derived($scanStatusStore); const scanStore = createScanStatusQuery();
const scan = $derived(scanStatusQ.data); const scan = $derived($scanStore.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;
}
}
</script> </script>
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head> <svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
@@ -141,11 +47,14 @@
{#if problems > 0} {#if problems > 0}
<ul class="divide-y divide-border rounded-lg border border-border bg-surface"> <ul class="divide-y divide-border rounded-lg border border-border bg-surface">
{#if scan?.error_message} {#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"> <li data-testid="needs-you">
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span> <a href="/admin/library" class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 hover:bg-surface-hover">
{#if scan.finished_at} <span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span> {#if scan.finished_at}
{/if} <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> </li>
{/if} {/if}
{#each waiting as d (d.key)} {#each waiting as d (d.key)}
@@ -169,194 +78,5 @@
{/if} {/if}
{/if} {/if}
</section> </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> </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, isPending: false,
isError: 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. // where the page picked up empty data despite `mockReturnValue` being set.
// Importing at module load means the mocks attach to the same handles the // Importing at module load means the mocks attach to the same handles the
// page binds against. // page binds against.
import { import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
createAdminSummaryQuery,
createScanStatusQuery,
researchMissingArt
} from '#lib/api/admin.js';
import type { AdminSummary } from '#lib/api/types.js'; import type { AdminSummary } from '#lib/api/types.js';
import OverviewPage from './+page.svelte'; import OverviewPage from './+page.svelte';
import AdminLayout from './+layout.svelte'; import AdminLayout from './+layout.svelte';
@@ -155,7 +128,9 @@ describe('admin Overview page', () => {
setupOverview(); setupOverview();
render(OverviewPage); render(OverviewPage);
expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/); 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 () => { 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 })); await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(refetch).toHaveBeenCalled(); 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', () => { describe('admin layout', () => {
@@ -216,7 +162,10 @@ describe('admin layout', () => {
['/admin/duplicates', 'Duplicates 5'], ['/admin/duplicates', 'Duplicates 5'],
['/admin/suspect-sources', 'Suspect sources'], ['/admin/suspect-sources', 'Suspect sources'],
['/admin/missing-files', 'Missing files'], ['/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/users', 'Users'],
['/admin/diagnostics', 'Diagnostics'], ['/admin/diagnostics', 'Diagnostics'],
['/admin/tuning', 'Tuning'] ['/admin/tuning', 'Tuning']
+8 -11
View File
@@ -18,7 +18,6 @@
DuplicatesView, DuplicatesView,
ResolvedDuplicateActivity ResolvedDuplicateActivity
} from '#lib/api/types.js'; } from '#lib/api/types.js';
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.svelte'; import TabStrip from '#lib/components/TabStrip.svelte';
@@ -73,11 +72,6 @@
{ id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved } { id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved }
]); ]);
function refetchAll() {
query.refetch();
resolved.refetch();
}
async function onRunSweep() { async function onRunSweep() {
sweeping = true; sweeping = true;
try { try {
@@ -312,8 +306,8 @@
<p class="mt-3 text-text-primary">Nothing to compare yet.</p> <p class="mt-3 text-text-primary">Nothing to compare yet.</p>
<p class="mt-1 text-sm text-text-secondary"> <p class="mt-1 text-sm text-text-secondary">
{#if prints.enabled === false} {#if prints.enabled === false}
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the Fingerprinting is off, so no track has a fingerprint to compare. Turn it on under
settings below. <a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
{:else} {:else}
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go. The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
Duplicates appear here as the sweep finds them. Duplicates appear here as the sweep finds them.
@@ -485,7 +479,10 @@
/> />
{/if} {/if}
<!-- Settings sit under the report whatever state it is in: turning <!-- Fingerprinting is set under Library (#5607); the line says where,
fingerprinting back on is how an empty report gets out of that state. --> since turning it back on is how an empty report gets out of that state. -->
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={refetchAll} /> <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> </div>
@@ -15,18 +15,7 @@ vi.mock('#lib/api/admin.js', () => ({
mergeDuplicateGroup: vi.fn().mockResolvedValue({ mergeDuplicateGroup: vi.fn().mockResolvedValue({
survivor_track_id: 'www-01', survivor_track_id: 'www-01',
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3'] 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'; import AdminDuplicatesPage from './+page.svelte';
@@ -250,9 +239,11 @@ describe('admin duplicates', () => {
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off'); 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()); 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', () => { 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"> <script lang="ts">
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { FolderX, Music2 } from 'lucide-svelte'; 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 { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.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'; import type { AdminMissingGroup } from '#lib/api/types.js';
// Files the scan looked for and could not find. Read-only on purpose: // Files the scan looked for and could not find. Read-only on purpose:
@@ -35,6 +39,17 @@
const s = sec % 60; const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`; 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> </script>
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head> <svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
@@ -58,10 +73,16 @@
</p> </p>
</header> </header>
<!-- The policy sits above the list it governs: an operator looking at <p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
missing files is exactly the person deciding what should happen to {#if policy}
them, and burying this under Integrations would separate the two. --> {policy.enabled
<ReacquisitionSettingsCard /> ? `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} {#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p> <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 { mockQuery } from '../../../test-utils/query';
import type { AdminMissingResponse } from '#lib/api/types.js'; import type { AdminMissingResponse } from '#lib/api/types.js';
// The page now embeds ReacquisitionSettingsCard, which loads its own settings // The page reads the re-acquisition policy to say what happens to these;
// from this same module on mount. Stubbing both keeps these tests about the // the settings themselves are on Admin › Acquisition.
// missing-files list — the card has its own suite.
vi.mock('#lib/api/admin.js', () => ({ vi.mock('#lib/api/admin.js', () => ({
createMissingFilesQuery: vi.fn(), createMissingFilesQuery: vi.fn(),
getReacquisitionSettings: vi.fn().mockResolvedValue({ getReacquisitionSettings: vi.fn().mockResolvedValue({
@@ -13,8 +12,7 @@ vi.mock('#lib/api/admin.js', () => ({
grace_hours: 24, grace_hours: 24,
max_per_pass: 20, max_per_pass: 20,
unnameable_albums: 0 unnameable_albums: 0
}), })
updateReacquisitionSettings: vi.fn()
})); }));
import AdminMissingFilesPage from './+page.svelte'; import AdminMissingFilesPage from './+page.svelte';
@@ -77,6 +75,13 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
} }
describe('admin missing files', () => { 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', () => { test('groups rows under their directory', () => {
renderWith(response); renderWith(response);
expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy(); 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 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 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 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> </p>
{#if verdict} {#if verdict}
<p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict"> <p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">