feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates) - Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr - Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair - Email & network: outgoing email, the notification schedule, public address, proxies - Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes - Duplicates and Missing files say what their setting is and link to where it is set Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,12 +106,12 @@ object ErrorCopy {
|
||||
"This server is on the internet, so its URL must start with https://. " +
|
||||
"Plain http:// only works on your home network.",
|
||||
"lidarr_unreachable" to
|
||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||
"Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
|
||||
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||
"lidarr_auth_failed" to "Lidarr authentication failed.",
|
||||
"lidarr_defaults_incomplete" to
|
||||
"Lidarr is missing a default quality profile or root folder. " +
|
||||
"Set them in Admin → Integrations.",
|
||||
"Set them in Admin › Acquisition.",
|
||||
"lidarr_server_error" to "Lidarr returned an error. Check Lidarr's logs for the cause.",
|
||||
"lidarr_rejected" to
|
||||
"Lidarr rejected the request. Check the server logs for the field-level reason.",
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ Publish the port to the network and use `http://<host>:4533`:
|
||||
ports: ['4533:4533']
|
||||
```
|
||||
|
||||
If nothing sits in front of Minstrel, set **Admin → Integrations → Client IP
|
||||
If nothing sits in front of Minstrel, set **Admin › Email & network → Client IP
|
||||
detection** to 0. It defaults to 1, which assumes one proxy (see below for
|
||||
why that matters).
|
||||
|
||||
@@ -48,7 +48,7 @@ Three things, all required:
|
||||
the app is. Raise the proxy's read timeout well past a minute (nginx:
|
||||
`proxy_read_timeout 1h;`).
|
||||
|
||||
3. **Tell Minstrel where it lives**, in **Admin → Integrations**:
|
||||
3. **Tell Minstrel where it lives**, in **Admin › Email & network**:
|
||||
- **Client IP detection:** the number of proxies in front of Minstrel. One
|
||||
proxy (Caddy, Traefik, nginx) is 1; Cloudflare in front of Traefik is 2.
|
||||
Minstrel uses this to find the real client address, for login rate
|
||||
|
||||
@@ -11,7 +11,7 @@ across all shots for a consistent look.
|
||||
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||
| `integrations.png` | `/admin/acquisition` | Indexers, download clients, searching and Lidarr |
|
||||
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
|
||||
// errNoPublicURL stops a reset email from going out before the operator has
|
||||
// said where Minstrel lives. It surfaces in the log, not the response, which
|
||||
// stays the same opaque 200 either way.
|
||||
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
|
||||
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin › Email & network › Public address); reset email not sent")
|
||||
|
||||
// buildResetURL builds the emailed link from the operator-set public URL.
|
||||
// It deliberately ignores the request: the Host header is whatever the
|
||||
|
||||
@@ -25,7 +25,7 @@ type lidarrImage struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// QualityProfile is the dropdown choice in /admin/integrations.
|
||||
// QualityProfile is the dropdown choice on Admin › Acquisition.
|
||||
type QualityProfile struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -41,7 +41,7 @@ type MetadataProfile struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// RootFolder is the dropdown choice in /admin/integrations.
|
||||
// RootFolder is the dropdown choice on Admin › Acquisition.
|
||||
type RootFolder struct {
|
||||
Path string
|
||||
Accessible bool
|
||||
|
||||
@@ -161,7 +161,7 @@ func (s *Server) Router() http.Handler {
|
||||
)
|
||||
lidarrCfg := lidarrconfig.New(s.Pool)
|
||||
// Per-call client factory: re-reads config so an admin save in
|
||||
// /admin/integrations takes effect immediately without restart.
|
||||
// Admin › Acquisition takes effect immediately without restart.
|
||||
// Returns nil when Lidarr is disabled, which the Service-layer
|
||||
// methods translate to ErrLidarrDisabled.
|
||||
lidarrClientFn := func() *lidarr.Client {
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
{#if settings.email_unavailable_reason === 'no_address'}
|
||||
Email is off: <a href="/settings/account#profile" class="underline hover:text-text-primary">add an email address in your profile</a>.
|
||||
{:else if user.value?.is_admin}
|
||||
Email is off: <a href="/admin/integrations" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
Email is off: <a href="/admin/email" class="underline hover:text-text-primary">SMTP isn't set up</a>.
|
||||
{:else}
|
||||
Email is off: this server doesn't send email.
|
||||
{/if}
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('NotificationSettings', () => {
|
||||
render(NotificationSettings);
|
||||
expect(screen.getByRole('link', { name: "SMTP isn't set up" })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/integrations'
|
||||
'/admin/email'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// Policy for putting an album whose files went missing back on the wanted
|
||||
// list (milestone #290, M502 #5520). Lives on the missing-files page rather
|
||||
// than under Integrations because this is where the operator meets the
|
||||
// problem it solves. Searching for a want, and retrying, is the search
|
||||
// list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
|
||||
// of how music is got (#5607); Missing files says what it is set to and
|
||||
// links here. Searching for a want, and retrying, is the search
|
||||
// schedule's job, so there is no retry policy here.
|
||||
|
||||
let saved = $state<ReacquisitionSettings | null>(null);
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
if (r.gone) return 'The indexers no longer offer that release.';
|
||||
if (r.unconfigured) {
|
||||
return r.missing === 'download_client'
|
||||
? 'No download client is set up. Add one under Integrations.'
|
||||
: 'No indexer is set up. Add one under Integrations.';
|
||||
? 'No download client is set up. Add one under Admin › Acquisition.'
|
||||
: 'No indexer is set up. Add one under Admin › Acquisition.';
|
||||
}
|
||||
if (r.refused) return r.reason ? `Not taken: ${r.reason}` : 'Not taken.';
|
||||
return r.error ?? 'Not taken.';
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createLidarrConfigQuery,
|
||||
createQualityProfilesQuery,
|
||||
createMetadataProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
putLidarrConfig,
|
||||
testLidarrConnection
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import { copyForCode } from '#lib/api/error-copy.js';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import type { LidarrConfig, LidarrTestResult } from '#lib/api/types.js';
|
||||
|
||||
// The Lidarr connection, on Admin › Acquisition (#5607). Optional: wants
|
||||
// are Minstrel's own, and a connected Lidarr is still asked to search for
|
||||
// a cleaner copy of a suspect album, to remove an album through it, and to
|
||||
// stop monitoring a track you remove.
|
||||
//
|
||||
// The saved key comes back masked as "***" and is never put in the field.
|
||||
// The field starts empty, and an empty key on save keeps the saved one.
|
||||
|
||||
const client = useQueryClient();
|
||||
const configStore = createLidarrConfigQuery();
|
||||
const config = $derived($configStore);
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
|
||||
|
||||
// Form state, filled once when the config first loads, so a refetch
|
||||
// doesn't throw away what is being typed.
|
||||
let baseUrl = $state('');
|
||||
let apiKeyInput = $state('');
|
||||
let qualityId = $state<number>(0);
|
||||
let metadataId = $state<number>(0);
|
||||
let rootPath = $state<string>('');
|
||||
let initialized = false;
|
||||
|
||||
// Lists from a test. A fresh install has no saved connection to read the
|
||||
// lists through, so the test's own lists fill the choices until a save.
|
||||
let testQualityProfiles = $state<{ id: number; name: string }[]>([]);
|
||||
let testMetadataProfiles = $state<{ id: number; name: string }[]>([]);
|
||||
let testRootFolders = $state<{ path: string; accessible: boolean; free_space: number }[]>([]);
|
||||
let testListErrors = $state<Record<string, string>>({});
|
||||
|
||||
$effect(() => {
|
||||
const c = config.data;
|
||||
if (c && !initialized) {
|
||||
baseUrl = c.base_url;
|
||||
qualityId = c.default_quality_profile_id;
|
||||
metadataId = c.default_metadata_profile_id;
|
||||
rootPath = c.default_root_folder_path;
|
||||
initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
const profilesEnabled = $derived(!!config.data?.enabled);
|
||||
const profilesStore = $derived(createQualityProfilesQuery(profilesEnabled));
|
||||
const metadataProfilesStore = $derived(createMetadataProfilesQuery(profilesEnabled));
|
||||
const foldersStore = $derived(createRootFoldersQuery(profilesEnabled));
|
||||
|
||||
const qualityList = $derived(testQualityProfiles.length ? testQualityProfiles : ($profilesStore.data ?? []));
|
||||
const metadataList = $derived(
|
||||
testMetadataProfiles.length ? testMetadataProfiles : ($metadataProfilesStore.data ?? [])
|
||||
);
|
||||
const folderList = $derived(testRootFolders.length ? testRootFolders : ($foldersStore.data ?? []));
|
||||
|
||||
// Each choice defaults to the first on offer until one is picked.
|
||||
$effect(() => {
|
||||
if (qualityId === 0 && qualityList.length > 0) qualityId = qualityList[0].id;
|
||||
});
|
||||
$effect(() => {
|
||||
if (metadataId === 0 && metadataList.length > 0) metadataId = metadataList[0].id;
|
||||
});
|
||||
$effect(() => {
|
||||
if (rootPath === '' && folderList.length > 0) rootPath = folderList[0].path;
|
||||
});
|
||||
|
||||
// What a failure code means, said to the operator.
|
||||
const SAID: Record<string, string> = {
|
||||
lidarr_unreachable: "Couldn't reach Lidarr at that address.",
|
||||
lidarr_auth_failed: 'Lidarr turned the API key down.',
|
||||
lidarr_lookup_failed: "Lidarr answered, but not in a way Minstrel understands.",
|
||||
internal_error: 'Something went wrong on the server. Its log says what.',
|
||||
no_defaults: 'Lidarr offered no quality profile, metadata profile or root folder to use.'
|
||||
};
|
||||
const said = (code: string | undefined) => (code && SAID[code]) || copyForCode(code);
|
||||
|
||||
const LIST_NAMES: Record<string, string> = {
|
||||
quality_profiles: 'quality profiles',
|
||||
metadata_profiles: 'metadata profiles',
|
||||
root_folders: 'root folders'
|
||||
};
|
||||
|
||||
let testResult: LidarrTestResult | null = $state(null);
|
||||
let saveError: string | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
let isTesting = $state(false);
|
||||
|
||||
function takeLists(t: LidarrTestResult) {
|
||||
testQualityProfiles = t.quality_profiles ?? [];
|
||||
testMetadataProfiles = t.metadata_profiles ?? [];
|
||||
testRootFolders = t.root_folders ?? [];
|
||||
testListErrors = t.list_errors ?? {};
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: qk.lidarrConfig() }),
|
||||
client.invalidateQueries({ queryKey: qk.lidarrQualityProfiles() }),
|
||||
client.invalidateQueries({ queryKey: qk.lidarrMetadataProfiles() }),
|
||||
client.invalidateQueries({ queryKey: qk.lidarrRootFolders() })
|
||||
]);
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
try {
|
||||
// Test first: it confirms the connection and brings the lists the
|
||||
// defaults are taken from.
|
||||
const test = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
|
||||
testResult = test;
|
||||
if (!test.ok) {
|
||||
saveError = said(test.error);
|
||||
return;
|
||||
}
|
||||
takeLists(test);
|
||||
await tick();
|
||||
if (qualityId === 0 || metadataId === 0 || rootPath === '') {
|
||||
saveError = said('no_defaults');
|
||||
return;
|
||||
}
|
||||
const cfg: LidarrConfig = {
|
||||
enabled: true,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKeyInput,
|
||||
default_quality_profile_id: qualityId,
|
||||
default_metadata_profile_id: metadataId,
|
||||
default_root_folder_path: rootPath
|
||||
};
|
||||
await putLidarrConfig(cfg);
|
||||
// A new address is a different server, so its lists are read again.
|
||||
await invalidate();
|
||||
apiKeyInput = '';
|
||||
} catch (e) {
|
||||
saveError = said(errCode(e));
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
isTesting = true;
|
||||
testResult = null;
|
||||
try {
|
||||
const result = await testLidarrConnection({ base_url: baseUrl, api_key: apiKeyInput });
|
||||
testResult = result;
|
||||
if (result.ok) takeLists(result);
|
||||
} catch (e) {
|
||||
testResult = { ok: false, error: errCode(e) };
|
||||
} finally {
|
||||
isTesting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnecting asks for DISCONNECT typed out, so a slip doesn't clear a
|
||||
// connection the operator relies on.
|
||||
let modalOpen = $state(false);
|
||||
let disconnectInput = $state('');
|
||||
let disconnectError = $state<string | null>(null);
|
||||
const canDisconnect = $derived(disconnectInput.trim() === 'DISCONNECT');
|
||||
|
||||
async function onConfirmDisconnect() {
|
||||
if (!canDisconnect) return;
|
||||
disconnectError = null;
|
||||
try {
|
||||
await putLidarrConfig({
|
||||
enabled: false,
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 0,
|
||||
default_metadata_profile_id: 0,
|
||||
default_root_folder_path: ''
|
||||
});
|
||||
await invalidate();
|
||||
modalOpen = false;
|
||||
disconnectInput = '';
|
||||
} catch (e) {
|
||||
disconnectError = said(errCode(e));
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDisconnect() {
|
||||
modalOpen = false;
|
||||
disconnectInput = '';
|
||||
disconnectError = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet listError(key: string)}
|
||||
{#if testListErrors[key]}
|
||||
<p class="w-full text-xs text-error-fg">Couldn't read Lidarr's {LIST_NAMES[key]}.</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<SettingsGroup
|
||||
title="Lidarr"
|
||||
id="lidarr"
|
||||
hint="Optional. Asked for a cleaner copy of a suspect album, and to remove albums."
|
||||
>
|
||||
<SettingRow label="Status">
|
||||
<span class="text-sm text-text-secondary" data-testid="lidarr-state">
|
||||
{#if testResult?.ok}
|
||||
Connected · Lidarr {testResult.version}
|
||||
{:else if config.data?.enabled}
|
||||
Connected
|
||||
{:else}
|
||||
Not connected
|
||||
{/if}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Address" for="lidarr-url">
|
||||
<input id="lidarr-url" type="text" bind:value={baseUrl} placeholder="http://lidarr.lan:8686" class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="API key" for="lidarr-key" hint="In Lidarr under Settings › General.">
|
||||
<input
|
||||
id="lidarr-key"
|
||||
type="password"
|
||||
bind:value={apiKeyInput}
|
||||
placeholder={config.data?.api_key === '***' ? 'Key saved · leave empty to keep it' : 'Paste the API key'}
|
||||
class={field}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Quality profile" for="lidarr-quality">
|
||||
<select
|
||||
id="lidarr-quality"
|
||||
bind:value={qualityId}
|
||||
disabled={qualityList.length === 0}
|
||||
class="{field} disabled:opacity-50"
|
||||
>
|
||||
{#each qualityList as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{@render listError('quality_profiles')}
|
||||
</SettingRow>
|
||||
<SettingRow label="Metadata profile" for="lidarr-metadata">
|
||||
<select
|
||||
id="lidarr-metadata"
|
||||
bind:value={metadataId}
|
||||
disabled={metadataList.length === 0}
|
||||
class="{field} disabled:opacity-50"
|
||||
>
|
||||
{#each metadataList as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{@render listError('metadata_profiles')}
|
||||
</SettingRow>
|
||||
<SettingRow label="Root folder" for="lidarr-root">
|
||||
<select id="lidarr-root" bind:value={rootPath} disabled={folderList.length === 0} class="{field} disabled:opacity-50">
|
||||
{#each folderList as f (f.path)}
|
||||
<option value={f.path}>{f.path}{f.accessible ? '' : ' (Lidarr can’t reach it)'}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{@render listError('root_folders')}
|
||||
</SettingRow>
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<button type="button" onclick={onTest} disabled={isTesting} class={button}>
|
||||
{isTesting ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onSave}
|
||||
disabled={isSaving}
|
||||
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{#if config.data?.enabled}
|
||||
<button type="button" onclick={() => (modalOpen = true)} class="ml-auto {button}">Disconnect</button>
|
||||
{/if}
|
||||
{#if testResult && !testResult.ok && !saveError}
|
||||
<p class="w-full text-sm text-error-fg" data-testid="lidarr-test-error">{said(testResult.error)}</p>
|
||||
{/if}
|
||||
{#if saveError}
|
||||
<p class="w-full text-sm text-error-fg" data-testid="lidarr-save-error">Not saved: {saveError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
<Modal title="Disconnect Lidarr?" open={modalOpen} onClose={cancelDisconnect}>
|
||||
<p class="-mt-2 text-text-secondary">
|
||||
This clears the saved connection. Type
|
||||
<span class="font-mono text-text-primary">DISCONNECT</span> to go ahead.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={disconnectInput}
|
||||
placeholder="DISCONNECT"
|
||||
aria-label="Type DISCONNECT to confirm"
|
||||
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
{#if disconnectError}
|
||||
<p class="mt-2 text-sm text-error-fg">Not disconnected: {disconnectError}</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button type="button" onclick={cancelDisconnect} class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onConfirmDisconnect}
|
||||
disabled={!canDisconnect}
|
||||
class="rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { LidarrConfig } from '#lib/api/types.js';
|
||||
|
||||
const invalidateQueries = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createLidarrConfigQuery: vi.fn(),
|
||||
createQualityProfilesQuery: vi.fn(),
|
||||
createMetadataProfilesQuery: vi.fn(),
|
||||
createRootFoldersQuery: vi.fn(),
|
||||
putLidarrConfig: vi.fn(),
|
||||
testLidarrConnection: vi.fn()
|
||||
}));
|
||||
|
||||
import LidarrSettings from './LidarrSettings.svelte';
|
||||
import {
|
||||
createLidarrConfigQuery,
|
||||
createQualityProfilesQuery,
|
||||
createMetadataProfilesQuery,
|
||||
createRootFoldersQuery,
|
||||
putLidarrConfig,
|
||||
testLidarrConnection
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const connected: LidarrConfig = {
|
||||
enabled: true,
|
||||
base_url: 'http://lidarr.local',
|
||||
api_key: '***',
|
||||
default_quality_profile_id: 1,
|
||||
default_metadata_profile_id: 1,
|
||||
default_root_folder_path: '/music'
|
||||
};
|
||||
|
||||
const unset: LidarrConfig = {
|
||||
enabled: false,
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
default_quality_profile_id: 0,
|
||||
default_metadata_profile_id: 0,
|
||||
default_root_folder_path: ''
|
||||
};
|
||||
|
||||
const testOk = {
|
||||
ok: true as const,
|
||||
version: '2.0.5',
|
||||
quality_profiles: [
|
||||
{ id: 1, name: 'FLAC' },
|
||||
{ id: 2, name: 'MP3-320' }
|
||||
],
|
||||
metadata_profiles: [{ id: 1, name: 'Standard' }],
|
||||
root_folders: [{ path: '/music', accessible: true, free_space: 12345 }]
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateQueries.mockReset();
|
||||
});
|
||||
|
||||
function setup(config: LidarrConfig = connected, lists = true) {
|
||||
vi.mocked(createLidarrConfigQuery).mockReturnValue(mockQuery({ data: config }) as never);
|
||||
vi.mocked(createQualityProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Lossless' }] : [] }) as never
|
||||
);
|
||||
vi.mocked(createMetadataProfilesQuery).mockReturnValue(
|
||||
mockQuery({ data: lists ? [{ id: 1, name: 'Standard' }, { id: 2, name: 'Singles only' }] : [] }) as never
|
||||
);
|
||||
vi.mocked(createRootFoldersQuery).mockReturnValue(
|
||||
mockQuery({ data: lists ? [{ path: '/music', accessible: true, free_space: 0 }] : [] }) as never
|
||||
);
|
||||
return render(LidarrSettings);
|
||||
}
|
||||
|
||||
function modalConfirm(): HTMLElement {
|
||||
return within(screen.getByRole('dialog')).getByRole('button', { name: /^disconnect$/i });
|
||||
}
|
||||
|
||||
describe('LidarrSettings', () => {
|
||||
test('connected, it says so; not connected, it says that and offers no Disconnect', () => {
|
||||
const { unmount } = setup();
|
||||
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Connected');
|
||||
expect(screen.getByRole('button', { name: /^disconnect$/i })).toBeInTheDocument();
|
||||
unmount();
|
||||
setup(unset);
|
||||
expect(screen.getByTestId('lidarr-state').textContent?.trim()).toBe('Not connected');
|
||||
expect(screen.queryByRole('button', { name: /^disconnect$/i })).toBeNull();
|
||||
});
|
||||
|
||||
test('Save tests first, then saves with an empty key, which keeps the saved one', async () => {
|
||||
setup();
|
||||
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
|
||||
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: true, base_url: 'http://lidarr.local', api_key: '', default_root_folder_path: '/music' })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('a typed key is sent', async () => {
|
||||
setup();
|
||||
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'newkey' } });
|
||||
vi.mocked(testLidarrConnection).mockResolvedValueOnce(testOk);
|
||||
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ api_key: 'newkey' })));
|
||||
});
|
||||
|
||||
test('Test shows the Lidarr version when it connects', async () => {
|
||||
setup();
|
||||
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: true, version: '2.0.0' });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('lidarr-state').textContent).toMatch(/lidarr 2\.0\.0/i));
|
||||
});
|
||||
|
||||
test('a failed test says what went wrong in words, not its code', async () => {
|
||||
setup();
|
||||
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_unreachable' });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
|
||||
const said = await screen.findByTestId('lidarr-test-error');
|
||||
expect(said.textContent).toMatch(/couldn't reach lidarr/i);
|
||||
expect(said.textContent).not.toMatch(/lidarr_unreachable/);
|
||||
});
|
||||
|
||||
test('a save whose test fails is not saved, and says why', async () => {
|
||||
setup(unset, false);
|
||||
vi.mocked(testLidarrConnection).mockResolvedValueOnce({ ok: false, error: 'lidarr_auth_failed' });
|
||||
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://wrong.local' } });
|
||||
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'k' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('lidarr-save-error').textContent).toMatch(/turned the api key down/i));
|
||||
expect(putLidarrConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('first-time setup fills the defaults from the test, keeping a choice made after it', async () => {
|
||||
setup(unset, false);
|
||||
vi.mocked(testLidarrConnection).mockResolvedValue(testOk);
|
||||
vi.mocked(putLidarrConfig).mockResolvedValueOnce(connected);
|
||||
await fireEvent.input(screen.getByLabelText(/address/i), { target: { value: 'http://lidarr.local' } });
|
||||
await fireEvent.input(screen.getByLabelText(/api key/i), { target: { value: 'secret-key' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(testLidarrConnection).toHaveBeenCalledTimes(1));
|
||||
await fireEvent.change(screen.getByLabelText(/quality profile/i), { target: { value: '2' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
base_url: 'http://lidarr.local',
|
||||
api_key: 'secret-key',
|
||||
default_quality_profile_id: 2,
|
||||
default_metadata_profile_id: 1,
|
||||
default_root_folder_path: '/music'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('Disconnect needs DISCONNECT typed; cancelling changes nothing', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
|
||||
expect((modalConfirm() as HTMLButtonElement).disabled).toBe(true);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(putLidarrConfig).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Disconnect, confirmed, clears the connection', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
|
||||
await fireEvent.input(screen.getByPlaceholderText('DISCONNECT'), { target: { value: 'DISCONNECT' } });
|
||||
vi.mocked(putLidarrConfig).mockResolvedValueOnce(unset);
|
||||
await fireEvent.click(modalConfirm());
|
||||
expect(putLidarrConfig).toHaveBeenCalledWith(expect.objectContaining({ enabled: false, base_url: '', api_key: '' }));
|
||||
});
|
||||
|
||||
test('the profile and folder choices come from Lidarr', () => {
|
||||
setup();
|
||||
expect(screen.getAllByText('Standard').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('Lossless')).toBeInTheDocument();
|
||||
expect(screen.getByText('Singles only')).toBeInTheDocument();
|
||||
expect(screen.getByText('/music')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
|
||||
// One block for a list of outside sources (#5607): cover art and tags had
|
||||
// the same panel pasted twice. Each source is a row: its name and what it
|
||||
// supplies, a switch, a key field when it takes one, Save and Test.
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
requires_api_key: boolean;
|
||||
supports: string[];
|
||||
enabled: boolean;
|
||||
api_key_set: boolean;
|
||||
testable: boolean;
|
||||
};
|
||||
type TestResult = { ok: boolean; duration_ms?: number; error?: string };
|
||||
type P = Provider;
|
||||
type Query = {
|
||||
data?: { providers: P[] };
|
||||
isPending: boolean;
|
||||
isError: boolean;
|
||||
refetch: () => unknown;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
hint,
|
||||
noun,
|
||||
query,
|
||||
save,
|
||||
test,
|
||||
keyPlaceholder = 'Paste an API key',
|
||||
note
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
// What the list is of, for the load error: "cover art sources".
|
||||
noun: string;
|
||||
query: Query;
|
||||
save: (id: string, patch: { enabled?: boolean; api_key?: string }) => Promise<unknown>;
|
||||
test: (id: string) => Promise<TestResult>;
|
||||
keyPlaceholder?: string;
|
||||
// A line under one source's name, when it needs one (where to get a key).
|
||||
note?: Snippet<[P]>;
|
||||
} = $props();
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
|
||||
// What a source supplies, in words rather than its capability codes.
|
||||
const SUPPLIES: Record<string, string> = {
|
||||
album_cover: 'album covers',
|
||||
artist_thumb: 'artist photos',
|
||||
artist_fanart: 'artist backdrops',
|
||||
track_tags: 'track tags'
|
||||
};
|
||||
function supplies(p: P): string {
|
||||
const words = p.supports.map((c) => SUPPLIES[c] ?? c.replace(/_/g, ' '));
|
||||
return words.length ? words.join(', ').replace(/^./, (c) => c.toUpperCase()) : '';
|
||||
}
|
||||
|
||||
// The operator's edits, kept across refetches until saved.
|
||||
let local = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
|
||||
let results = $state<Record<string, TestResult>>({});
|
||||
let saving = $state<Record<string, boolean>>({});
|
||||
let testing = $state<Record<string, boolean>>({});
|
||||
|
||||
$effect(() => {
|
||||
for (const p of query.data?.providers ?? []) {
|
||||
if (!local[p.id]) local[p.id] = { enabled: p.enabled, apiKey: '' };
|
||||
}
|
||||
});
|
||||
|
||||
function changed(p: P): boolean {
|
||||
const l = local[p.id];
|
||||
return !!l && (l.enabled !== p.enabled || l.apiKey !== '');
|
||||
}
|
||||
|
||||
async function onSave(p: P) {
|
||||
const l = local[p.id];
|
||||
if (!l || !changed(p)) return;
|
||||
const patch: { enabled?: boolean; api_key?: string } = {};
|
||||
if (l.enabled !== p.enabled) patch.enabled = l.enabled;
|
||||
if (l.apiKey !== '') patch.api_key = l.apiKey;
|
||||
saving[p.id] = true;
|
||||
try {
|
||||
await save(p.id, patch);
|
||||
local[p.id] = { enabled: l.enabled, apiKey: '' };
|
||||
} catch (e) {
|
||||
pushToast(`Couldn't save ${p.display_name}: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
saving[p.id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest(p: P) {
|
||||
testing[p.id] = true;
|
||||
try {
|
||||
results[p.id] = await test(p.id);
|
||||
} catch (e) {
|
||||
results[p.id] = { ok: false, error: errMessage(e) };
|
||||
} finally {
|
||||
testing[p.id] = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsGroup {title} {hint}>
|
||||
{#if query.data}
|
||||
{#each query.data.providers as p (p.id)}
|
||||
{@const l = local[p.id]}
|
||||
{@const r = results[p.id]}
|
||||
<div data-testid="provider" data-provider={p.id}>
|
||||
<SettingRow label={p.display_name} for="src-{p.id}">
|
||||
{#snippet hint()}
|
||||
{supplies(p)}
|
||||
{#if note}{' · '}{@render note(p)}{/if}
|
||||
{/snippet}
|
||||
{#if l}
|
||||
<input
|
||||
id="src-{p.id}"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={l.enabled}
|
||||
bind:checked={l.enabled}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
{/if}
|
||||
</SettingRow>
|
||||
{#if l}
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 pb-3">
|
||||
{#if p.requires_api_key}
|
||||
<input
|
||||
type="password"
|
||||
aria-label="{p.display_name} API key"
|
||||
placeholder={p.api_key_set ? 'Key saved · leave empty to keep it' : keyPlaceholder}
|
||||
bind:value={l.apiKey}
|
||||
class="w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!changed(p) || saving[p.id]}
|
||||
onclick={() => onSave(p)}
|
||||
class={button}>{saving[p.id] ? 'Saving…' : 'Save'}</button
|
||||
>
|
||||
{#if p.testable}
|
||||
<button type="button" disabled={testing[p.id]} onclick={() => onTest(p)} class={button}>
|
||||
{testing[p.id] ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if r}
|
||||
<span class="text-xs {r.ok ? 'text-text-secondary' : 'text-error-fg'}" data-testid="provider-test">
|
||||
{#if r.ok}
|
||||
Works{r.duration_ms ? ` · answered in ${r.duration_ms} ms` : ''}
|
||||
{:else}
|
||||
Didn't work: {r.error}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else if query.isPending}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||
{:else if query.isError}
|
||||
<p class="px-4 py-3 text-sm text-error-fg" data-testid="providers-error">
|
||||
Couldn't load the {noun}.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="px-4 py-3 text-sm text-text-secondary">None available.</p>
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig, type SMTPConfig } from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errCode, errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
// Outgoing email (#5607): the mail server password resets and
|
||||
// notifications go out through. Send test mails the signed-in admin.
|
||||
|
||||
const client = useQueryClient();
|
||||
const smtpStore = createSMTPConfigQuery();
|
||||
const smtpQ = $derived($smtpStore);
|
||||
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm';
|
||||
|
||||
let form = $state<SMTPConfig>({
|
||||
enabled: false,
|
||||
host: '',
|
||||
port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
from_address: '',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
});
|
||||
let passwordInput = $state('');
|
||||
let saving = $state(false);
|
||||
let testing = $state(false);
|
||||
|
||||
// Filled from the saved config once, when it first loads, and again after
|
||||
// a save, so a refetch doesn't throw edits away. Until it has loaded the
|
||||
// form holds defaults, so Save stays off: saving then would write the
|
||||
// defaults over the real config (#5604).
|
||||
let loaded = $state(false);
|
||||
$effect(() => {
|
||||
if (smtpQ.data && !loaded) {
|
||||
form = { ...smtpQ.data };
|
||||
passwordInput = '';
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function onSave() {
|
||||
saving = true;
|
||||
try {
|
||||
await updateSMTPConfig({ ...form, password: passwordInput });
|
||||
loaded = false;
|
||||
await client.invalidateQueries({ queryKey: qk.smtpConfig() });
|
||||
passwordInput = '';
|
||||
pushToast('Email settings saved.');
|
||||
} catch (e: unknown) {
|
||||
if (errCode(e) === 'missing_fields') pushToast('Server and from address are needed to send.', 'error');
|
||||
else pushToast(`Not saved: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing = true;
|
||||
try {
|
||||
await testSMTPConfig();
|
||||
pushToast('Test email sent. Check your inbox.');
|
||||
} catch (e: unknown) {
|
||||
const code = errCode(e);
|
||||
const message = (e as { message?: string })?.message;
|
||||
if (code === 'no_email_on_file') pushToast('Set your email in Settings › Account before testing.', 'error');
|
||||
else if (code === 'not_configured') pushToast('Save the email settings first.', 'error');
|
||||
else if (code === 'send_failed') pushToast(`Not sent: ${message || 'the server log says why'}`, 'error');
|
||||
else pushToast(`Not sent: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<SettingsGroup title="Outgoing email" id="email" hint="For password resets and notifications.">
|
||||
{#if smtpQ.isError && !loaded}
|
||||
<p class="px-4 py-3 text-sm text-error-fg" data-testid="smtp-error">
|
||||
Couldn't load the email settings, so they can't be saved yet.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => smtpQ.refetch()}>Try again</button>
|
||||
</p>
|
||||
{/if}
|
||||
<SettingRow label="Send email" for="smtp-enabled">
|
||||
<input
|
||||
id="smtp-enabled"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={form.enabled}
|
||||
bind:checked={form.enabled}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Server" for="smtp-host">
|
||||
<input id="smtp-host" type="text" bind:value={form.host} placeholder="smtp.example.com" class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Port" for="smtp-port">
|
||||
<input id="smtp-port" type="number" min="1" max="65535" bind:value={form.port} class="w-24 rounded-md border border-border bg-background px-3 py-1.5 text-sm" />
|
||||
</SettingRow>
|
||||
<SettingRow label="STARTTLS" for="smtp-tls" hint="Encrypt the connection to the server.">
|
||||
<input
|
||||
id="smtp-tls"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={form.use_tls}
|
||||
bind:checked={form.use_tls}
|
||||
class="h-4 w-4 cursor-pointer accent-accent"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Username" for="smtp-username">
|
||||
<input id="smtp-username" type="text" bind:value={form.username} class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Password" for="smtp-password">
|
||||
<input
|
||||
id="smtp-password"
|
||||
type="password"
|
||||
placeholder={form.password === '***' ? 'Saved · leave empty to keep it' : ''}
|
||||
bind:value={passwordInput}
|
||||
class={field}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="From address" for="smtp-from">
|
||||
<input id="smtp-from" type="email" bind:value={form.from_address} placeholder="minstrel@example.com" class={field} />
|
||||
</SettingRow>
|
||||
<SettingRow label="From name" for="smtp-from-name">
|
||||
<input id="smtp-from-name" type="text" bind:value={form.from_name} class={field} />
|
||||
</SettingRow>
|
||||
<div class="flex flex-wrap items-center gap-2 px-4 py-3">
|
||||
<button type="button" disabled={testing} onclick={onTest} class={button}>
|
||||
{testing ? 'Sending…' : 'Send test email'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !loaded}
|
||||
onclick={onSave}
|
||||
class="rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { SMTPConfig } from '#lib/api/admin.js';
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createSMTPConfigQuery: vi.fn(),
|
||||
updateSMTPConfig: vi.fn(),
|
||||
testSMTPConfig: vi.fn()
|
||||
}));
|
||||
|
||||
import SmtpSettings from './SmtpSettings.svelte';
|
||||
import { createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig } from '#lib/api/admin.js';
|
||||
|
||||
const configured: SMTPConfig = {
|
||||
enabled: true,
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
username: 'user@example.com',
|
||||
password: '***',
|
||||
from_address: 'noreply@example.com',
|
||||
from_name: 'Minstrel',
|
||||
use_tls: true
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(q = mockQuery({ data: configured })) {
|
||||
vi.mocked(createSMTPConfigQuery).mockReturnValue(q as never);
|
||||
return render(SmtpSettings);
|
||||
}
|
||||
|
||||
describe('SmtpSettings', () => {
|
||||
test('the saved settings fill the rows, and a saved password is kept unless one is typed', async () => {
|
||||
setup();
|
||||
expect((screen.getByLabelText(/^server$/i) as HTMLInputElement).value).toBe('smtp.example.com');
|
||||
expect(screen.getByPlaceholderText(/saved · leave empty to keep it/i)).toBeInTheDocument();
|
||||
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ host: 'smtp.example.com', password: '' })));
|
||||
});
|
||||
|
||||
test('a typed password is sent', async () => {
|
||||
setup();
|
||||
vi.mocked(updateSMTPConfig).mockResolvedValue(undefined as never);
|
||||
await fireEvent.input(screen.getByLabelText(/^password$/i), { target: { value: 'newsecret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(updateSMTPConfig).toHaveBeenCalledWith(expect.objectContaining({ password: 'newsecret' })));
|
||||
});
|
||||
|
||||
test('Send test email sends one, and with no address on file says where to set it', async () => {
|
||||
setup();
|
||||
vi.mocked(testSMTPConfig).mockRejectedValueOnce({ code: 'no_email_on_file' });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /send test email/i }));
|
||||
await waitFor(() => expect(testSMTPConfig).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/set your email in settings › account/i));
|
||||
});
|
||||
|
||||
test('when the settings fail to load, Save is off, so defaults are never written over them', () => {
|
||||
setup(mockQuery({ isError: true, error: { code: 'server_error' } }));
|
||||
expect(screen.getByTestId('smtp-error')).toBeInTheDocument();
|
||||
expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,10 @@
|
||||
"system_playlist_readonly": "System playlists can't be edited directly.",
|
||||
"connection_refused": "Couldn't reach the server. Check the URL and try again.",
|
||||
"musicbrainz_unreachable": "MusicBrainz can't be reached right now, so search is unavailable. Try again in a minute.",
|
||||
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||
"lidarr_unreachable": "Lidarr is unreachable right now. Try again, or check Admin › Acquisition.",
|
||||
"lidarr_disabled": "Lidarr integration is not enabled.",
|
||||
"lidarr_auth_failed": "Lidarr authentication failed.",
|
||||
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin → Integrations.",
|
||||
"lidarr_defaults_incomplete": "Lidarr is missing a default quality profile or root folder. Set them in Admin › Acquisition.",
|
||||
"lidarr_server_error": "Lidarr returned an error. Check Lidarr's logs for the cause.",
|
||||
"lidarr_rejected": "Lidarr rejected the request. Check the server logs for the field-level reason.",
|
||||
"lidarr_album_lookup_failed": "Lidarr doesn't recognize this album. Try Resolve or Delete file instead.",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
|
||||
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
|
||||
{ href: '/admin/integrations', label: 'Integrations' },
|
||||
{ href: '/admin/library', label: 'Library' },
|
||||
{ href: '/admin/acquisition', label: 'Acquisition' },
|
||||
{ href: '/admin/metadata', label: 'Metadata sources' },
|
||||
{ href: '/admin/email', label: 'Email & network' },
|
||||
{ href: '/admin/users', label: 'Users' },
|
||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||
{ href: '/admin/tuning', label: 'Tuning' }
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import StageBadge from '#lib/components/StageBadge.svelte';
|
||||
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
||||
import { stageState } from './stage-state';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createAdminSummaryQuery,
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createFingerprintCoverageQuery,
|
||||
refetchMissingCovers,
|
||||
triggerScan,
|
||||
researchMissingArt
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errCode } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||
|
||||
@@ -24,8 +9,6 @@
|
||||
// waited, then the empty ones on one line (#189). The queues themselves
|
||||
// are worked on their own pages; the Overview no longer repeats them.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const summaryStore = createAdminSummaryQuery();
|
||||
const summaryQ = $derived($summaryStore);
|
||||
const summary = $derived(summaryQ.data);
|
||||
@@ -33,87 +16,10 @@
|
||||
const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0));
|
||||
const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0));
|
||||
|
||||
// ---- Library scan ----
|
||||
const scanStatusStore = $derived(createScanStatusQuery());
|
||||
const scanStatusQ = $derived($scanStatusStore);
|
||||
const scan = $derived(scanStatusQ.data);
|
||||
const scanInFlight = $derived(scan?.in_flight === true);
|
||||
|
||||
// ---- Cover-art coverage gauge ----
|
||||
const coverageStore = $derived(createCoverageQuery());
|
||||
const coverageQ = $derived($coverageStore);
|
||||
const coverage = $derived(coverageQ.data);
|
||||
|
||||
// ---- Fingerprint backfill gauge (#3908) ----
|
||||
const fingerprintStore = $derived(createFingerprintCoverageQuery());
|
||||
const fingerprintQ = $derived($fingerprintStore);
|
||||
const fingerprints = $derived(fingerprintQ.data);
|
||||
|
||||
let triggering = $state(false);
|
||||
let triggerResult = $state<string | null>(null);
|
||||
|
||||
async function onTriggerScan() {
|
||||
if (triggering || scanInFlight) return;
|
||||
triggering = true;
|
||||
triggerResult = null;
|
||||
try {
|
||||
await triggerScan();
|
||||
triggerResult = 'Scan started.';
|
||||
await client.invalidateQueries({ queryKey: qk.scanStatus() });
|
||||
await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
const status = (e as { status?: number })?.status;
|
||||
if (status === 409) {
|
||||
triggerResult = 'A scan is already running.';
|
||||
} else {
|
||||
triggerResult = `Failed: ${code}`;
|
||||
}
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- Cover art bulk refetch ----
|
||||
let bulkBusy = $state(false);
|
||||
let bulkResult = $state<string | null>(null);
|
||||
|
||||
// ---- Re-search missing art ----
|
||||
let researchSaving = $state(false);
|
||||
|
||||
async function onResearchMissingArt() {
|
||||
researchSaving = true;
|
||||
try {
|
||||
await researchMissingArt();
|
||||
pushToast('All previously-failed art will be re-attempted on the next scan.');
|
||||
} catch (e) {
|
||||
pushToast(`Re-search failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
researchSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onBulkRefetch() {
|
||||
if (bulkBusy) return;
|
||||
bulkBusy = true;
|
||||
bulkResult = null;
|
||||
try {
|
||||
const { started } = await refetchMissingCovers();
|
||||
bulkResult = started
|
||||
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
|
||||
: 'Could not start the cover refetch.';
|
||||
await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||
} catch (e) {
|
||||
bulkResult = `Failed: ${errCode(e)}`;
|
||||
} finally {
|
||||
bulkBusy = false;
|
||||
}
|
||||
}
|
||||
// A failed library scan needs you as much as a queue does. The scan
|
||||
// itself, and the jobs after it, are on Admin › Library (#5607).
|
||||
const scanStore = createScanStatusQuery();
|
||||
const scan = $derived($scanStore.data);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
||||
@@ -141,11 +47,14 @@
|
||||
{#if problems > 0}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#if scan?.error_message}
|
||||
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3" data-testid="needs-you">
|
||||
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
|
||||
{#if scan.finished_at}
|
||||
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
|
||||
{/if}
|
||||
<li data-testid="needs-you">
|
||||
<a href="/admin/library" class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 hover:bg-surface-hover">
|
||||
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
|
||||
{#if scan.finished_at}
|
||||
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-sm text-accent-fg">Open</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#each waiting as d (d.key)}
|
||||
@@ -169,194 +78,5 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Library scan -->
|
||||
<section class="rounded border border-border bg-surface p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-medium">Library scan</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Walks the music tree, fills missing MusicBrainz IDs from tags, and refetches album covers.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onTriggerScan}
|
||||
disabled={triggering || scanInFlight}
|
||||
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !scan || !scan.id}
|
||||
<p class="mt-3 text-sm text-text-muted">No scan has run yet.</p>
|
||||
{:else}
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<!-- Library stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Library walk</div>
|
||||
<StageBadge state={stageState(scan, 'library')} />
|
||||
</div>
|
||||
{#if scan.library}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Scanned</dt><dd>{scan.library.scanned}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Added</dt><dd>{scan.library.added}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Updated</dt><dd>{scan.library.updated}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.library.skipped}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Errored</dt><dd>{scan.library.errored}</dd></div>
|
||||
</dl>
|
||||
{:else if stageState(scan, 'library') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- MBID backfill stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">MBID backfill</div>
|
||||
<StageBadge state={stageState(scan, 'mbid_backfill')} />
|
||||
</div>
|
||||
{#if scan.mbid_backfill}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.mbid_backfill.processed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Healed</dt><dd>{scan.mbid_backfill.healed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Skipped</dt><dd>{scan.mbid_backfill.skipped}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Duplicates</dt><dd>{scan.mbid_backfill.duplicates ?? 0}</dd></div>
|
||||
</dl>
|
||||
{:else if stageState(scan, 'mbid_backfill') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cover enrichment stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Cover enrichment</div>
|
||||
<StageBadge state={stageState(scan, 'cover_enrich')} />
|
||||
</div>
|
||||
{#if scan.cover_enrich}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.cover_enrich.processed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.cover_enrich.succeeded}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.cover_enrich.failed}</dd></div>
|
||||
</dl>
|
||||
{:else if stageState(scan, 'cover_enrich') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Artist art enrichment stage -->
|
||||
<div class="rounded border border-border bg-bg p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs font-medium uppercase tracking-wide text-text-muted">Artist art</div>
|
||||
<StageBadge state={stageState(scan, 'artist_art_enrich')} />
|
||||
</div>
|
||||
{#if scan.artist_art_enrich}
|
||||
<dl class="mt-2 space-y-0.5 text-sm">
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Processed</dt><dd>{scan.artist_art_enrich.processed}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Succeeded</dt><dd>{scan.artist_art_enrich.succeeded}</dd></div>
|
||||
<div class="flex justify-between"><dt class="text-text-secondary">Failed</dt><dd>{scan.artist_art_enrich.failed}</dd></div>
|
||||
</dl>
|
||||
{:else if stageState(scan, 'artist_art_enrich') === 'pending'}
|
||||
<p class="mt-2 text-sm text-text-muted">Waiting for previous stage…</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-text-muted">
|
||||
Started {formatTime(scan.started_at)}
|
||||
{#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if}
|
||||
{#if scan.error_message}· <span class="text-error-fg">Error: {scan.error_message}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if triggerResult}
|
||||
<p class="mt-2 text-sm">{triggerResult}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Fingerprint backfill (#3908). A worker of its own rather than a scan
|
||||
stage, so its progress is read live here, not from the run above. -->
|
||||
{#if fingerprints && fingerprints.total > 0}
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-text-muted">Fingerprints</span>
|
||||
<span>{fingerprints.fingerprinted.toLocaleString()} of {fingerprints.total.toLocaleString()} tracks</span>
|
||||
{#if fingerprints.pending > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span>{fingerprints.pending.toLocaleString()} pending</span>
|
||||
{/if}
|
||||
{#if fingerprints.enabled === false}
|
||||
<!-- Off (#3913), pending never shrinks; say why rather than imply progress. -->
|
||||
<span class="text-text-muted">·</span>
|
||||
<a href="/admin/duplicates" class="underline hover:no-underline">fingerprinting is off</a>
|
||||
{/if}
|
||||
{#if fingerprints.rejected > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span
|
||||
class="cursor-help"
|
||||
title="The fingerprint tools could not read these files. Each is tried again when its file changes."
|
||||
>
|
||||
{fingerprints.rejected.toLocaleString()} unreadable
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Loudness analysis (#4995): a background worker like the fingerprint
|
||||
backfill, so its progress lives on its own card. -->
|
||||
<LoudnessSettingsCard />
|
||||
|
||||
<!-- Cover art bulk refetch -->
|
||||
<section class="rounded border border-border bg-surface p-4">
|
||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Refetch cover art for albums that are missing one or where the previous attempt failed.
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onBulkRefetch}
|
||||
disabled={bulkBusy}
|
||||
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{bulkBusy ? 'Queueing…' : 'Refetch missing covers'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onResearchMissingArt}
|
||||
disabled={researchSaving}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
{researchSaving ? 'Bumping…' : 'Re-search missing art'}
|
||||
</button>
|
||||
|
||||
{#if coverage}
|
||||
{#if coverage.total === 0}
|
||||
<span class="text-sm text-text-muted">Library is empty.</span>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span>{coverage.with_art.toLocaleString()} with art</span>
|
||||
<span class="text-text-muted">·</span>
|
||||
<span
|
||||
class={coverage.pending_no_mbid > 0 ? 'cursor-help' : ''}
|
||||
title={coverage.pending_no_mbid > 0
|
||||
? `${coverage.pending_no_mbid.toLocaleString()} of these have no MBID and won't be helped by another scan — fix tags or merge duplicate albums.`
|
||||
: undefined}
|
||||
>
|
||||
{coverage.pending.toLocaleString()} pending
|
||||
</span>
|
||||
<span class="text-text-muted">·</span>
|
||||
<span>{coverage.settled.toLocaleString()} settled</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if bulkResult}
|
||||
<p class="mt-2 text-sm">{bulkResult}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import AcquisitionCard from '#lib/components/AcquisitionCard.svelte';
|
||||
import SearchSettingsCard from '#lib/components/SearchSettingsCard.svelte';
|
||||
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
|
||||
import LidarrSettings from '#lib/components/admin/LidarrSettings.svelte';
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
|
||||
// Admin › Acquisition (#5607): how Minstrel gets music. Where it searches
|
||||
// and what downloads, how it searches and what it takes, what happens to
|
||||
// a file that goes missing, and the optional Lidarr connection.
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Acquisition')}</title></svelte:head>
|
||||
|
||||
<h2 class="sr-only">Acquisition</h2>
|
||||
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<AcquisitionCard />
|
||||
<SearchSettingsCard />
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<div id="reacquisition"><ReacquisitionSettingsCard /></div>
|
||||
<LidarrSettings />
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
@@ -26,30 +26,7 @@ vi.mock('#lib/api/admin.js', async () => {
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
),
|
||||
createCoverageQuery: () =>
|
||||
readable({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
createFingerprintCoverageQuery: () =>
|
||||
readable({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
isError: false
|
||||
}),
|
||||
triggerScan: vi.fn().mockResolvedValue({}),
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
// LoudnessSettingsCard, rendered by the page and tested on its own.
|
||||
getLoudnessSettings: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ enabled: true, backfill_concurrency: 2, leveled_cache_mb: 2048 }),
|
||||
updateLoudnessSettings: vi.fn(),
|
||||
getLoudnessCoverage: vi.fn().mockResolvedValue({
|
||||
total: 0, measured: 0, silent: 0, unreadable: 0, pending: 0, enabled: true
|
||||
})
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
@@ -88,11 +65,7 @@ describe('admin/+layout.ts load gate', () => {
|
||||
// where the page picked up empty data despite `mockReturnValue` being set.
|
||||
// Importing at module load means the mocks attach to the same handles the
|
||||
// page binds against.
|
||||
import {
|
||||
createAdminSummaryQuery,
|
||||
createScanStatusQuery,
|
||||
researchMissingArt
|
||||
} from '#lib/api/admin.js';
|
||||
import { createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||
import type { AdminSummary } from '#lib/api/types.js';
|
||||
import OverviewPage from './+page.svelte';
|
||||
import AdminLayout from './+layout.svelte';
|
||||
@@ -155,7 +128,9 @@ describe('admin Overview page', () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/);
|
||||
expect(screen.getByText(/the last library scan failed: library root not mounted/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/the last library scan failed: library root not mounted/i).closest('a')?.getAttribute('href')).toBe(
|
||||
'/admin/library'
|
||||
);
|
||||
});
|
||||
|
||||
test('when the queues fail to load it says so, with Try again, rather than reading as all-clear', async () => {
|
||||
@@ -167,35 +142,6 @@ describe('admin Overview page', () => {
|
||||
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders Re-search missing art button and invokes on click', async () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
const btn = screen.getByRole('button', { name: /re-search missing art/i });
|
||||
await fireEvent.click(btn);
|
||||
expect(researchMissingArt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Library scan card shows Processing badge when library tally is populated and in-flight', async () => {
|
||||
const { readable } = await import('svelte/store');
|
||||
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||
readable({
|
||||
data: {
|
||||
id: 'in-flight',
|
||||
started_at: '2026-05-06T11:00:00Z',
|
||||
finished_at: null,
|
||||
in_flight: true,
|
||||
library: { scanned: 100, added: 1, updated: 0, skipped: 99, errored: 0 }
|
||||
},
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
);
|
||||
setupOverview();
|
||||
userState.current = { id: 'admin', username: 'admin', is_admin: true };
|
||||
render(OverviewPage);
|
||||
expect(await screen.findByText('Processing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin layout', () => {
|
||||
@@ -216,7 +162,10 @@ describe('admin layout', () => {
|
||||
['/admin/duplicates', 'Duplicates 5'],
|
||||
['/admin/suspect-sources', 'Suspect sources'],
|
||||
['/admin/missing-files', 'Missing files'],
|
||||
['/admin/integrations', 'Integrations'],
|
||||
['/admin/library', 'Library'],
|
||||
['/admin/acquisition', 'Acquisition'],
|
||||
['/admin/metadata', 'Metadata sources'],
|
||||
['/admin/email', 'Email & network'],
|
||||
['/admin/users', 'Users'],
|
||||
['/admin/diagnostics', 'Diagnostics'],
|
||||
['/admin/tuning', 'Tuning']
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
DuplicatesView,
|
||||
ResolvedDuplicateActivity
|
||||
} from '#lib/api/types.js';
|
||||
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
|
||||
@@ -73,11 +72,6 @@
|
||||
{ id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved }
|
||||
]);
|
||||
|
||||
function refetchAll() {
|
||||
query.refetch();
|
||||
resolved.refetch();
|
||||
}
|
||||
|
||||
async function onRunSweep() {
|
||||
sweeping = true;
|
||||
try {
|
||||
@@ -312,8 +306,8 @@
|
||||
<p class="mt-3 text-text-primary">Nothing to compare yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{#if prints.enabled === false}
|
||||
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the
|
||||
settings below.
|
||||
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on under
|
||||
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
|
||||
{:else}
|
||||
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||
Duplicates appear here as the sweep finds them.
|
||||
@@ -485,7 +479,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Settings sit under the report whatever state it is in: turning
|
||||
fingerprinting back on is how an empty report gets out of that state. -->
|
||||
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={refetchAll} />
|
||||
<!-- Fingerprinting is set under Library (#5607); the line says where,
|
||||
since turning it back on is how an empty report gets out of that state. -->
|
||||
<p class="text-xs text-text-secondary" data-testid="fingerprint-settings-link">
|
||||
Fingerprinting is {prints?.enabled === false ? 'off' : 'on'}. It's set under
|
||||
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -15,18 +15,7 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
||||
survivor_track_id: 'www-01',
|
||||
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
||||
}),
|
||||
// The page embeds FingerprintSettingsCard, which loads its own settings from
|
||||
// this module; the card has its own suite.
|
||||
getFingerprintSettings: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
chromaprint_length_sec: 120,
|
||||
acoustic_max_bit_error_rate: 0.15,
|
||||
backfill_concurrency: 2,
|
||||
sweep_interval_hours: 1,
|
||||
auto_resolve: true
|
||||
}),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
import AdminDuplicatesPage from './+page.svelte';
|
||||
@@ -250,9 +239,11 @@ describe('admin duplicates', () => {
|
||||
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
|
||||
});
|
||||
|
||||
test('the fingerprinting settings are on this page', async () => {
|
||||
test('the fingerprinting settings are a link to Library, saying whether it is on', () => {
|
||||
renderWith(response());
|
||||
expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy();
|
||||
const line = screen.getByTestId('fingerprint-settings-link');
|
||||
expect(text(line)).toContain('Fingerprinting is on');
|
||||
expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/library#fingerprints');
|
||||
});
|
||||
|
||||
test('empty before any sweep says the sweep has not run', () => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import StageBadge from '#lib/components/StageBadge.svelte';
|
||||
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
||||
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
import {
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createFingerprintCoverageQuery,
|
||||
refetchMissingCovers,
|
||||
triggerScan,
|
||||
researchMissingArt
|
||||
} from '#lib/api/admin.js';
|
||||
import { createSearchSettingsQuery } from '#lib/api/wanted.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { stageState, type StageKey } from '../stage-state';
|
||||
|
||||
// Admin › Library (#5607): the folders Minstrel reads, the scan that reads
|
||||
// them with a row for each of its jobs, and the background work that
|
||||
// follows a scan: fingerprints, loudness and cover art. Moved here from
|
||||
// the Overview, which now only says what needs you.
|
||||
|
||||
const client = useQueryClient();
|
||||
const button =
|
||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||
|
||||
const searchStore = createSearchSettingsQuery();
|
||||
const folders = $derived($searchStore.data?.scan_paths ?? []);
|
||||
|
||||
const scanStore = createScanStatusQuery();
|
||||
const scan = $derived($scanStore.data);
|
||||
const scanInFlight = $derived(scan?.in_flight === true);
|
||||
|
||||
const coverageStore = createCoverageQuery();
|
||||
const coverage = $derived($coverageStore.data);
|
||||
|
||||
const fingerprintStore = createFingerprintCoverageQuery();
|
||||
const prints = $derived($fingerprintStore.data);
|
||||
|
||||
const n = (v: number | undefined) => (v ?? 0).toLocaleString();
|
||||
|
||||
// One line for the scan as a whole: running, failed, or when it last ran.
|
||||
const scanLine = $derived.by(() => {
|
||||
if (!scan || !scan.id) return "Hasn't run yet.";
|
||||
if (scanInFlight) return `Running · started ${relativeTime(scan.started_at)}`;
|
||||
if (scan.error_message) return `Failed ${relativeTime(scan.finished_at ?? scan.started_at)}: ${scan.error_message}`;
|
||||
return `Last ran ${relativeTime(scan.finished_at ?? scan.started_at)}`;
|
||||
});
|
||||
|
||||
// The scan's jobs in the order they run, each as one line of what it did.
|
||||
const jobs: { key: StageKey; label: string; line: () => string }[] = [
|
||||
{
|
||||
key: 'library',
|
||||
label: 'Reading files',
|
||||
line: () => {
|
||||
const l = scan?.library;
|
||||
if (!l) return '';
|
||||
let s = `${n(l.scanned)} read · ${n(l.added)} new · ${n(l.updated)} changed`;
|
||||
if (l.errored) s += ` · ${n(l.errored)} unreadable`;
|
||||
return s;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'mbid_backfill',
|
||||
label: 'MusicBrainz ids',
|
||||
line: () => {
|
||||
const m = scan?.mbid_backfill;
|
||||
if (!m) return '';
|
||||
let s = `${n(m.processed)} looked up · ${n(m.healed)} filled in`;
|
||||
if (m.duplicates) s += ` · ${n(m.duplicates)} duplicates`;
|
||||
return s;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'cover_enrich',
|
||||
label: 'Album covers',
|
||||
line: () => {
|
||||
const c = scan?.cover_enrich;
|
||||
return c ? `${n(c.processed)} tried · ${n(c.succeeded)} found · ${n(c.failed)} not found` : '';
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'artist_art_enrich',
|
||||
label: 'Artist photos',
|
||||
line: () => {
|
||||
const a = scan?.artist_art_enrich;
|
||||
return a ? `${n(a.processed)} tried · ${n(a.succeeded)} found · ${n(a.failed)} not found` : '';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
let triggering = $state(false);
|
||||
async function onRunScan() {
|
||||
if (triggering || scanInFlight) return;
|
||||
triggering = true;
|
||||
try {
|
||||
await triggerScan();
|
||||
pushToast('Scan started.');
|
||||
await client.invalidateQueries({ queryKey: qk.scanStatus() });
|
||||
await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||
} catch (e) {
|
||||
if ((e as { status?: number })?.status === 409) pushToast('A scan is already running.');
|
||||
else pushToast(`The scan didn't start: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
|
||||
let refetching = $state(false);
|
||||
async function onRefetchCovers() {
|
||||
if (refetching) return;
|
||||
refetching = true;
|
||||
try {
|
||||
const { started } = await refetchMissingCovers();
|
||||
if (started) pushToast('Looking for the missing covers. Outside sources are asked at their own pace.');
|
||||
else pushToast("Couldn't start looking for covers.", 'error');
|
||||
await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||
} catch (e) {
|
||||
pushToast(`Couldn't start looking for covers: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
refetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
let researching = $state(false);
|
||||
async function onResearch() {
|
||||
researching = true;
|
||||
try {
|
||||
await researchMissingArt();
|
||||
pushToast('Art no source had will be looked for again on the next scan.');
|
||||
} catch (e) {
|
||||
pushToast(`Not done: ${errMessage(e)}`, 'error');
|
||||
} finally {
|
||||
researching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Library')}</title></svelte:head>
|
||||
|
||||
<h2 class="sr-only">Library</h2>
|
||||
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<SettingsGroup title="Library" id="scan" hint="The folders Minstrel reads, and the scan that reads them.">
|
||||
<SettingRow label="Folders" hint="Set where Minstrel is installed.">
|
||||
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
|
||||
{#each folders as f (f)}<span>{f}</span>{:else}<span>—</span>{/each}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label="Scan" hint={scanLine}>
|
||||
<button type="button" onclick={onRunScan} disabled={triggering || scanInFlight} class={button}>
|
||||
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
{#if scan?.id}
|
||||
{#each jobs as job (job.key)}
|
||||
<div data-testid="scan-job">
|
||||
<SettingRow label={job.label} hint={job.line() || undefined}>
|
||||
<StageBadge state={stageState(scan, job.key)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup title="Cover art" id="covers">
|
||||
<SettingRow label="Covers" hint={coverage
|
||||
? coverage.total === 0
|
||||
? 'The library is empty.'
|
||||
: `${n(coverage.with_art)} with art · ${n(coverage.pending)} still to look for · ${n(coverage.settled)} no source has`
|
||||
: undefined}>
|
||||
<button type="button" onclick={onRefetchCovers} disabled={refetching} class={button}>
|
||||
{refetching ? 'Starting…' : 'Look for missing covers'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
{#if coverage && coverage.pending_no_mbid > 0}
|
||||
<SettingRow
|
||||
label="Without a MusicBrainz id"
|
||||
hint="{n(coverage.pending_no_mbid)} albums can't be looked up until their tags are fixed or they're matched."
|
||||
/>
|
||||
{/if}
|
||||
<SettingRow label="Look again" hint="Ask every source again for art none of them had.">
|
||||
<button type="button" onclick={onResearch} disabled={researching} class={button}>
|
||||
{researching ? 'Starting…' : 'Look again'}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
{#if prints && prints.total > 0}
|
||||
<SettingsGroup title="Fingerprints" id="fingerprints-progress">
|
||||
<SettingRow
|
||||
label="Progress"
|
||||
hint="{n(prints.fingerprinted)} of {n(prints.total)} tracks{prints.pending ? ` · ${n(prints.pending)} to go` : ''}{prints.rejected ? ` · ${n(prints.rejected)} unreadable` : ''}{prints.enabled === false ? ' · off' : ''}"
|
||||
/>
|
||||
</SettingsGroup>
|
||||
{/if}
|
||||
<div id="fingerprints">
|
||||
<FingerprintSettingsCard
|
||||
libraryTotal={prints?.total ?? 0}
|
||||
onSaved={() => client.invalidateQueries({ queryKey: qk.fingerprintCoverage() })}
|
||||
/>
|
||||
</div>
|
||||
<LoudnessSettingsCard />
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { ScanStatus } from '#lib/api/admin.js';
|
||||
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createScanStatusQuery: vi.fn(),
|
||||
createCoverageQuery: vi.fn(),
|
||||
createFingerprintCoverageQuery: vi.fn(),
|
||||
triggerScan: vi.fn().mockResolvedValue({}),
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
// The loudness and fingerprint settings load on mount and have their own
|
||||
// suites; left pending.
|
||||
getLoudnessSettings: vi.fn(() => new Promise(() => {})),
|
||||
updateLoudnessSettings: vi.fn(),
|
||||
getLoudnessCoverage: vi.fn(() => new Promise(() => {})),
|
||||
getFingerprintSettings: vi.fn(() => new Promise(() => {})),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#lib/api/wanted.js', async () => {
|
||||
const { mockQuery } = await import('../../../test-utils/query');
|
||||
return { createSearchSettingsQuery: vi.fn(() => mockQuery({ data: { scan_paths: ['/music', '/more-music'] } })) };
|
||||
});
|
||||
|
||||
import LibraryPage from './+page.svelte';
|
||||
import {
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createFingerprintCoverageQuery,
|
||||
triggerScan,
|
||||
researchMissingArt,
|
||||
refetchMissingCovers
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(scan: Partial<ScanStatus> = { id: '', started_at: '', finished_at: null, in_flight: false }) {
|
||||
vi.mocked(createScanStatusQuery).mockReturnValue(mockQuery({ data: scan }) as never);
|
||||
vi.mocked(createCoverageQuery).mockReturnValue(
|
||||
mockQuery({ data: { total: 100, with_art: 90, pending: 6, settled: 4, pending_no_mbid: 0 } }) as never
|
||||
);
|
||||
vi.mocked(createFingerprintCoverageQuery).mockReturnValue(
|
||||
mockQuery({ data: { total: 100, fingerprinted: 80, rejected: 1, pending: 19, enabled: true } }) as never
|
||||
);
|
||||
return render(LibraryPage);
|
||||
}
|
||||
|
||||
const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim();
|
||||
|
||||
describe('Admin › Library', () => {
|
||||
test('lists the folders Minstrel reads', () => {
|
||||
setup();
|
||||
expect(squash(screen.getByTestId('library-folders'))).toBe('/music /more-music');
|
||||
});
|
||||
|
||||
test('before any scan it says so, with no job rows', () => {
|
||||
setup();
|
||||
expect(screen.getByText("Hasn't run yet.")).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('scan-job')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a running scan has a row per job, each with what it did and where it is', () => {
|
||||
setup({
|
||||
id: 's1',
|
||||
started_at: hoursAgo(0.1),
|
||||
finished_at: null,
|
||||
in_flight: true,
|
||||
library: { scanned: 1200, added: 3, updated: 2, skipped: 1195, errored: 0 }
|
||||
});
|
||||
const jobs = screen.getAllByTestId('scan-job').map(squash);
|
||||
expect(jobs[0]).toBe('Reading files 1,200 read · 3 new · 2 changed Processing');
|
||||
expect(jobs[1]).toMatch(/^MusicBrainz ids Pending$/);
|
||||
expect((screen.getByRole('button', { name: /scanning…/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
test('a failed scan says so on the scan row', () => {
|
||||
setup({ id: 's1', started_at: hoursAgo(2), finished_at: hoursAgo(1), in_flight: false, error_message: 'library root not mounted' });
|
||||
expect(screen.getByText(/failed 1h ago: library root not mounted/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Run scan starts one', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /run scan/i }));
|
||||
await waitFor(() => expect(triggerScan).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/scan started/i));
|
||||
});
|
||||
|
||||
test('cover art says how many have art, and can look for the missing and look again', async () => {
|
||||
setup();
|
||||
expect(screen.getByText('90 with art · 6 still to look for · 4 no source has')).toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /look for missing covers/i }));
|
||||
await waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled());
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^look again$/i }));
|
||||
await waitFor(() => expect(researchMissingArt).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('fingerprint progress is one line', () => {
|
||||
setup();
|
||||
expect(screen.getByText('80 of 100 tracks · 19 to go · 1 unreadable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import AcoustIDCard from '#lib/components/AcoustIDCard.svelte';
|
||||
import ProviderGroup from '#lib/components/admin/ProviderGroup.svelte';
|
||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||
|
||||
// Admin › Metadata sources (#5607): the outside sources Minstrel asks
|
||||
// about your music. AcoustID names a recording from its sound; the cover
|
||||
// art and tag sources share one block.
|
||||
|
||||
const client = useQueryClient();
|
||||
const coverStore = createCoverProvidersQuery();
|
||||
const tagStore = createTagProvidersQuery();
|
||||
|
||||
async function saveCover(id: string, patch: { enabled?: boolean; api_key?: string }) {
|
||||
const result = await updateCoverProvider(id, patch);
|
||||
await client.invalidateQueries({ queryKey: qk.coverProviders() });
|
||||
// A change to the sources opens covers no source had to another try.
|
||||
if (result.version_bumped) await client.invalidateQueries({ queryKey: qk.coverage() });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function saveTag(id: string, patch: { enabled?: boolean; api_key?: string }) {
|
||||
const result = await updateTagProvider(id, patch);
|
||||
await client.invalidateQueries({ queryKey: qk.tagProviders() });
|
||||
return result;
|
||||
}
|
||||
|
||||
const link = 'underline hover:no-underline';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Metadata sources')}</title></svelte:head>
|
||||
|
||||
<h2 class="sr-only">Metadata sources</h2>
|
||||
|
||||
{#snippet coverNote(p: { id: string })}
|
||||
{#if p.id === 'theaudiodb'}
|
||||
Works with the public key;
|
||||
<a class={link} href="https://www.theaudiodb.com/api_apply.php" target="_blank" rel="noopener noreferrer"
|
||||
>a free key of your own</a
|
||||
> gets its own quota.
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet tagNote(p: { id: string })}
|
||||
{#if p.id === 'musicbrainz'}
|
||||
Needs no key; matches by MusicBrainz id.
|
||||
{:else if p.id === 'lastfm'}
|
||||
Matches by artist and title.
|
||||
<a class={link} href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer"
|
||||
>Get a free key</a
|
||||
>.
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<SettingsColumns>
|
||||
{#snippet main()}
|
||||
<AcoustIDCard />
|
||||
{/snippet}
|
||||
{#snippet side()}
|
||||
<ProviderGroup
|
||||
title="Cover art"
|
||||
hint="Asked in this order for covers and artist images; the first to have one wins."
|
||||
noun="cover art sources"
|
||||
query={$coverStore}
|
||||
save={saveCover}
|
||||
test={testCoverProvider}
|
||||
keyPlaceholder="Empty uses the public key"
|
||||
note={coverNote}
|
||||
/>
|
||||
<ProviderGroup
|
||||
title="Tags"
|
||||
hint="Styles and moods for each track, merged from every source switched on."
|
||||
noun="tag sources"
|
||||
query={$tagStore}
|
||||
save={saveTag}
|
||||
test={testTagProvider}
|
||||
keyPlaceholder="Paste a key to use this source"
|
||||
note={tagNote}
|
||||
/>
|
||||
{/snippet}
|
||||
</SettingsColumns>
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { CoverProvider, TagProvider } from '#lib/api/admin.js';
|
||||
|
||||
const invalidateQueries = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries }) };
|
||||
});
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createCoverProvidersQuery: vi.fn(),
|
||||
updateCoverProvider: vi.fn(),
|
||||
testCoverProvider: vi.fn(),
|
||||
createTagProvidersQuery: vi.fn(),
|
||||
updateTagProvider: vi.fn(),
|
||||
testTagProvider: vi.fn(),
|
||||
// AcoustIDCard loads on mount and has its own suite; left pending.
|
||||
getAcoustID: vi.fn(() => new Promise(() => {})),
|
||||
updateAcoustIDSettings: vi.fn(),
|
||||
runAcoustIDLookup: vi.fn(),
|
||||
listUnsettledAcoustID: vi.fn()
|
||||
}));
|
||||
|
||||
import MetadataPage from './+page.svelte';
|
||||
import {
|
||||
createCoverProvidersQuery,
|
||||
updateCoverProvider,
|
||||
testCoverProvider,
|
||||
createTagProvidersQuery,
|
||||
updateTagProvider,
|
||||
testTagProvider
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const caa: CoverProvider = {
|
||||
id: 'mbcaa',
|
||||
display_name: 'MusicBrainz CAA',
|
||||
requires_api_key: false,
|
||||
supports: ['album_cover'],
|
||||
enabled: true,
|
||||
api_key_set: false,
|
||||
display_order: 0,
|
||||
testable: true
|
||||
};
|
||||
const audiodb: CoverProvider = {
|
||||
id: 'theaudiodb',
|
||||
display_name: 'TheAudioDB',
|
||||
requires_api_key: true,
|
||||
supports: ['album_cover', 'artist_thumb', 'artist_fanart'],
|
||||
enabled: true,
|
||||
api_key_set: false,
|
||||
display_order: 1,
|
||||
testable: true
|
||||
};
|
||||
const mb: TagProvider = {
|
||||
id: 'musicbrainz',
|
||||
display_name: 'MusicBrainz',
|
||||
requires_api_key: false,
|
||||
supports: ['track_tags'],
|
||||
enabled: true,
|
||||
api_key_set: false,
|
||||
display_order: 0,
|
||||
testable: true
|
||||
};
|
||||
const lastfm: TagProvider = {
|
||||
id: 'lastfm',
|
||||
display_name: 'Last.fm',
|
||||
requires_api_key: true,
|
||||
supports: ['track_tags'],
|
||||
enabled: false,
|
||||
api_key_set: false,
|
||||
display_order: 1,
|
||||
testable: true
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateQueries.mockReset();
|
||||
});
|
||||
|
||||
function setup(opts: { cover?: ReturnType<typeof mockQuery>; covers?: CoverProvider[] } = {}) {
|
||||
vi.mocked(createCoverProvidersQuery).mockReturnValue(
|
||||
(opts.cover ?? mockQuery({ data: { providers: opts.covers ?? [caa, audiodb], sources_version: 1 } })) as never
|
||||
);
|
||||
vi.mocked(createTagProvidersQuery).mockReturnValue(
|
||||
mockQuery({ data: { providers: [mb, lastfm], sources_version: 1 } }) as never
|
||||
);
|
||||
return render(MetadataPage);
|
||||
}
|
||||
|
||||
const source = (id: string) => screen.getAllByTestId('provider').find((el) => el.dataset.provider === id)!;
|
||||
const group = (title: string) => screen.getByRole('heading', { name: title, level: 2 }).closest('section') as HTMLElement;
|
||||
|
||||
describe('Admin › Metadata sources', () => {
|
||||
test('each source is a row saying what it supplies in words, not its codes', () => {
|
||||
setup();
|
||||
const row = source('theaudiodb');
|
||||
expect(row.textContent).toMatch(/Album covers, artist photos, artist backdrops/);
|
||||
expect(row.textContent).not.toMatch(/artist_thumb|album_cover/);
|
||||
expect(within(group('Tags')).getAllByTestId('provider')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a saved key says so, and an unsaved one invites a key', () => {
|
||||
setup({ covers: [{ ...audiodb, api_key_set: true }] });
|
||||
expect(within(source('theaudiodb')).getByPlaceholderText(/key saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Save is off until something changes, then sends only the change', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...audiodb, version_bumped: false });
|
||||
const row = source('theaudiodb');
|
||||
const save = within(row).getByRole('button', { name: /^save$/i }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(true);
|
||||
await fireEvent.input(within(row).getByLabelText(/theaudiodb api key/i), { target: { value: 'newkey123' } });
|
||||
expect(save.disabled).toBe(false);
|
||||
await fireEvent.click(save);
|
||||
expect(updateCoverProvider).toHaveBeenCalledWith('theaudiodb', { api_key: 'newkey123' });
|
||||
});
|
||||
|
||||
test('switching a cover source off reopens covers no source had', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockResolvedValueOnce({ ...caa, enabled: false, version_bumped: true });
|
||||
const row = source('mbcaa');
|
||||
await fireEvent.click(within(row).getByRole('switch'));
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['coverage'] })));
|
||||
});
|
||||
|
||||
test('a save that fails says so', async () => {
|
||||
setup();
|
||||
vi.mocked(updateCoverProvider).mockRejectedValueOnce({ code: 'server_error' });
|
||||
const row = source('theaudiodb');
|
||||
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'k' } });
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/couldn't save theaudiodb/i));
|
||||
});
|
||||
|
||||
test('Test says whether the source answered', async () => {
|
||||
setup();
|
||||
vi.mocked(testCoverProvider).mockResolvedValueOnce({ ok: true, duration_ms: 120 });
|
||||
await fireEvent.click(within(source('mbcaa')).getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(within(source('mbcaa')).getByTestId('provider-test').textContent).toMatch(/works · answered in 120 ms/i));
|
||||
});
|
||||
|
||||
test('sources that fail to load say so, with Try again', () => {
|
||||
setup({ cover: mockQuery({ isError: true, error: { code: 'server_error' } }) });
|
||||
expect(within(group('Cover art')).getByTestId('providers-error').textContent).toMatch(/couldn't load the cover art sources/i);
|
||||
});
|
||||
|
||||
test('a tag source is switched on with its key in one save', async () => {
|
||||
setup();
|
||||
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true });
|
||||
const row = source('lastfm');
|
||||
await fireEvent.click(within(row).getByRole('switch'));
|
||||
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } });
|
||||
await fireEvent.click(within(row).getByRole('button', { name: /^save$/i }));
|
||||
expect(updateTagProvider).toHaveBeenCalledWith('lastfm', { enabled: true, api_key: 'lfmkey' });
|
||||
await waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['tagProviders'] })));
|
||||
});
|
||||
|
||||
test('Test on a tag source asks that source', async () => {
|
||||
setup();
|
||||
vi.mocked(testTagProvider).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
|
||||
await fireEvent.click(within(source('musicbrainz')).getByRole('button', { name: /^test$/i }));
|
||||
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
|
||||
});
|
||||
|
||||
test('where to get a key is linked from the source that needs one', () => {
|
||||
setup();
|
||||
expect(within(source('lastfm')).getByRole('link', { name: /get a free key/i })).toBeInTheDocument();
|
||||
expect(within(source('theaudiodb')).getByRole('link', { name: /a free key of your own/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { FolderX, Music2 } from 'lucide-svelte';
|
||||
import { createMissingFilesQuery } from '#lib/api/admin.js';
|
||||
import {
|
||||
createMissingFilesQuery,
|
||||
getReacquisitionSettings,
|
||||
type ReacquisitionSettings
|
||||
} from '#lib/api/admin.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import ReacquisitionSettingsCard from '#lib/components/ReacquisitionSettingsCard.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { AdminMissingGroup } from '#lib/api/types.js';
|
||||
|
||||
// Files the scan looked for and could not find. Read-only on purpose:
|
||||
@@ -35,6 +39,17 @@
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// What happens to these is set under Acquisition (#5607); the line above
|
||||
// the list says what it is now, since this is where the question comes up.
|
||||
let policy = $state<ReacquisitionSettings | null>(null);
|
||||
onMount(async () => {
|
||||
try {
|
||||
policy = await getReacquisitionSettings();
|
||||
} catch {
|
||||
policy = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Missing files')}</title></svelte:head>
|
||||
@@ -58,10 +73,16 @@
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- The policy sits above the list it governs: an operator looking at
|
||||
missing files is exactly the person deciding what should happen to
|
||||
them, and burying this under Integrations would separate the two. -->
|
||||
<ReacquisitionSettingsCard />
|
||||
<p class="text-sm text-text-secondary" data-testid="reacquisition-policy">
|
||||
{#if policy}
|
||||
{policy.enabled
|
||||
? `Albums missing for ${policy.grace_hours} hours go back on the wanted list by themselves.`
|
||||
: 'Missing albums are not wanted back automatically.'}
|
||||
{/if}
|
||||
<a href="/admin/acquisition#reacquisition" class="text-accent-fg hover:underline">
|
||||
{policy ? 'Change' : 'Set what happens to missing files'}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Checking what's missing…</p>
|
||||
|
||||
@@ -3,9 +3,8 @@ import { render, screen, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminMissingResponse } from '#lib/api/types.js';
|
||||
|
||||
// The page now embeds ReacquisitionSettingsCard, which loads its own settings
|
||||
// from this same module on mount. Stubbing both keeps these tests about the
|
||||
// missing-files list — the card has its own suite.
|
||||
// The page reads the re-acquisition policy to say what happens to these;
|
||||
// the settings themselves are on Admin › Acquisition.
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createMissingFilesQuery: vi.fn(),
|
||||
getReacquisitionSettings: vi.fn().mockResolvedValue({
|
||||
@@ -13,8 +12,7 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
grace_hours: 24,
|
||||
max_per_pass: 20,
|
||||
unnameable_albums: 0
|
||||
}),
|
||||
updateReacquisitionSettings: vi.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
import AdminMissingFilesPage from './+page.svelte';
|
||||
@@ -77,6 +75,13 @@ function renderWith(data: AdminMissingResponse | undefined, extra = {}) {
|
||||
}
|
||||
|
||||
describe('admin missing files', () => {
|
||||
test('says what happens to missing files, and links to where it is set', async () => {
|
||||
renderWith(response);
|
||||
const line = screen.getByTestId('reacquisition-policy');
|
||||
await screen.findByText(/missing for 24 hours go back on the wanted list/i);
|
||||
expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/acquisition#reacquisition');
|
||||
});
|
||||
|
||||
test('groups rows under their directory', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByText('/music/Linkin Park/Minutes to Midnight')).toBeTruthy();
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
getting for it, from user requests, new releases of the artists it follows, and albums whose
|
||||
files went missing. It searches your indexers on a schedule, checks each download against
|
||||
MusicBrainz, and files what's right into the library. Searching and quality are set under
|
||||
<a href="/admin/integrations" class="text-accent-fg hover:underline">Integrations</a>.
|
||||
<a href="/admin/acquisition" class="text-accent-fg hover:underline">Acquisition</a>.
|
||||
</p>
|
||||
{#if verdict}
|
||||
<p class="text-sm {needsYou ? 'text-text-primary' : 'text-text-secondary'}" data-testid="collection-verdict">
|
||||
|
||||
Reference in New Issue
Block a user