DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
16 changed files with 449 additions and 916 deletions
Showing only changes of commit 7f913e3e70 - Show all commits
+66 -167
View File
@@ -10,6 +10,8 @@
} from '#lib/api/admin.js';
import { 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';
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
// untagged tracks lack, so the similarity arms can see them. Off until the
@@ -122,194 +124,93 @@
}
}
const rowClass = 'flex items-center justify-between gap-4 py-2';
const inputClass =
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
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 = 'rounded-md border border-border bg-background px-3 py-1.5 text-sm';
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">AcoustID</h3>
<p class="mt-1 text-sm text-text-secondary">
Finds MusicBrainz ids for untagged tracks, so Songs-like and radio can place them.
</p>
</div>
{#if overview}
{#if overview.ready && !overview.status.problem}
<span
class="inline-flex shrink-0 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>
{overview.status.running ? 'Looking up' : 'On'}
</span>
{:else}
<span
class="inline-flex shrink-0 items-center gap-2 rounded-md border border-border px-3 py-1 text-xs text-text-muted"
data-testid="acoustid-state"
>
<span class="h-1.5 w-1.5 rounded-full bg-text-muted"></span>
{#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if}
</span>
{/if}
{/if}
</div>
<SettingsGroup title="AcoustID" id="acoustid" hint="Names untagged tracks from their sound, so radio and Songs-like can place them.">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load AcoustID settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if overview === null}
<p class="text-sm text-text-secondary">Loading AcoustID settings…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading AcoustID settings…</p>
{:else}
<SettingRow label="Status">
{#if overview.ready && !overview.status.problem}
<span class="text-sm text-text-secondary">{overview.status.running ? 'Looking up' : 'On'}</span>
{:else}
<span class="text-sm text-text-secondary" data-testid="acoustid-state">
{#if overview.ready}Needs attention{:else if !overview.settings.api_key_set}Needs a key{:else}Off{/if}
</span>
{/if}
</SettingRow>
{#if overview.status.problem}
<p class="text-sm text-action-destructive" data-testid="acoustid-problem">
{overview.status.problem}
</p>
<p class="px-4 py-3 text-sm text-error-fg" data-testid="acoustid-problem">{overview.status.problem}</p>
{/if}
<div class="divide-y divide-border">
<label class={rowClass}>
<span>
<span class="block text-sm text-text-primary">Look up untagged tracks</span>
<span class="block text-xs text-text-secondary">Sends audio fingerprints to AcoustID.</span>
</span>
<input type="checkbox" bind:checked={enabled} />
</label>
<div class={rowClass}>
<label for="acoustid-key">
<span class="block text-sm text-text-primary">API key</span>
<span class="block text-xs text-text-secondary">
Free for non-commercial use.
<a
href="https://acoustid.org/new-application"
target="_blank"
rel="noopener noreferrer"
class="underline hover:no-underline">Register an application</a
>
</span>
</label>
<div class="flex items-center gap-2">
<input
id="acoustid-key"
type="password"
autocomplete="off"
bind:value={keyInput}
placeholder={overview.settings.api_key_set ? 'Stored' : 'Paste key'}
class="{inputClass} w-40 font-mono"
/>
{#if overview.settings.api_key_set}
<button
type="button"
class="text-xs text-text-secondary underline hover:no-underline"
disabled={saving}
onclick={forgetKey}>Remove</button
>
{/if}
</div>
</div>
<label class={rowClass}>
<span>
<span class="block text-sm text-text-primary">Minimum score</span>
<span class="block text-xs text-text-secondary">How sure AcoustID must be, 0.5 to 1.</span>
</span>
<input
type="number"
min="0.5"
max="1"
step="0.01"
bind:value={minScore}
class="{inputClass} w-24 text-right"
/>
</label>
</div>
{#if !scoreOk}
<p class="text-xs text-action-destructive" data-testid="acoustid-problems">
Minimum score must be from 0.5 to 1.
</p>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !scoreOk}
onclick={save}
>
<SettingRow label="Look up untagged tracks" for="acoustid-enabled" hint="Sends audio fingerprints to AcoustID.">
<input id="acoustid-enabled" type="checkbox" role="switch" aria-checked={enabled} bind:checked={enabled} class="h-4 w-4 cursor-pointer accent-accent" />
</SettingRow>
<SettingRow label="API key" for="acoustid-key">
{#snippet hint()}
Free for non-commercial use.
<a href="https://acoustid.org/new-application" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline"
>Register an application</a
>
{/snippet}
<input
id="acoustid-key"
type="password"
autocomplete="off"
bind:value={keyInput}
placeholder={overview.settings.api_key_set ? 'Key saved' : 'Paste key'}
class="{field} w-40 font-mono"
/>
{#if overview.settings.api_key_set}
<button type="button" class={button} disabled={saving} onclick={forgetKey}>Remove</button>
{/if}
</SettingRow>
<SettingRow label="Minimum score" for="acoustid-score" hint="How sure AcoustID must be, 0.5 to 1.">
<input id="acoustid-score" type="number" min="0.5" max="1" step="0.01" bind:value={minScore} class="{field} w-24" />
</SettingRow>
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
{#if !scoreOk}
<p class="mr-auto text-xs text-error-fg" data-testid="acoustid-problems">Minimum score must be from 0.5 to 1.</p>
{/if}
<button type="button" class={button} disabled={!dirty || saving || !scoreOk} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{#if overview.coverage.total > 0}
<div class="border-t border-border pt-3" data-testid="acoustid-coverage">
<h4 class="text-sm font-medium text-text-primary">Recording ids</h4>
<dl class="mt-1 divide-y divide-border text-sm">
<div class={rowClass}>
<dt class="text-text-secondary">From tags</dt>
<dd class="tabular-nums">{overview.coverage.from_tag.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt class="text-text-secondary">Looked up</dt>
<dd class="tabular-nums">{overview.coverage.from_acoustid.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt class="text-text-secondary">None</dt>
<dd class="tabular-nums">{withoutId.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt class="pl-4 text-text-secondary">Waiting to be looked up</dt>
<dd class="tabular-nums">{overview.coverage.pending.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt class="pl-4 text-text-secondary" title="AcoustID knows no recording for these">
No match
</dt>
<dd class="tabular-nums">{overview.coverage.no_match.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt
class="pl-4 text-text-secondary"
title="Several recordings fit and nothing told them apart, so none was written"
>
Ambiguous
</dt>
<dd class="tabular-nums">{overview.coverage.ambiguous.toLocaleString()}</dd>
</div>
<div class={rowClass}>
<dt class="pl-4 text-text-secondary" title="The file couldn't be fingerprinted">
Failed
</dt>
<dd class="tabular-nums">{overview.coverage.failed.toLocaleString()}</dd>
</div>
</dl>
<div class="mt-2 flex justify-end">
{@const c = overview.coverage}
<div data-testid="acoustid-coverage">
<SettingRow
label="Recording ids"
hint="{c.from_tag.toLocaleString()} from tags · {c.from_acoustid.toLocaleString()} looked up · {withoutId.toLocaleString()} none"
/>
<SettingRow
label="Without one"
hint="{c.pending.toLocaleString()} waiting · {c.no_match.toLocaleString()} no match · {c.ambiguous.toLocaleString()} ambiguous · {c.failed.toLocaleString()} failed"
>
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
class={button}
disabled={!overview.ready || overview.status.running || starting}
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
onclick={runNow}
>
{overview.status.running ? 'Looking up…' : 'Look up now'}
</button>
</div>
</SettingRow>
</div>
{#if overview.coverage.ambiguous + overview.coverage.failed > 0}
<details class="border-t border-border pt-3" ontoggle={toggleUnsettled}>
{#if c.ambiguous + c.failed > 0}
<details class="px-4 py-3" ontoggle={toggleUnsettled}>
<summary class="cursor-pointer text-sm text-text-primary">
Tracks the lookup couldn't settle ({(
overview.coverage.ambiguous + overview.coverage.failed
).toLocaleString()})
Tracks the lookup couldn't settle ({(c.ambiguous + c.failed).toLocaleString()})
</summary>
{#if unsettled === null}
<p class="mt-2 text-sm text-text-secondary">Loading tracks…</p>
@@ -319,12 +220,10 @@
<li class="flex items-center justify-between gap-4 py-2">
<span class="min-w-0">
<span class="block truncate text-text-primary">{t.title}</span>
<span class="block truncate text-xs text-text-secondary"
>{t.artist} · {t.album}</span
>
<span class="block truncate text-xs text-text-secondary">{t.artist} · {t.album}</span>
</span>
<span class="shrink-0 text-xs text-text-muted" title={t.detail ?? undefined}>
{t.state === 'ambiguous' ? `${t.candidates} recordings` : 'failed'}
{t.state === 'ambiguous' ? `${t.candidates} recordings` : "couldn't fingerprint"}
</span>
</li>
{/each}
@@ -334,4 +233,4 @@
{/if}
{/if}
{/if}
</section>
</SettingsGroup>
+4 -16
View File
@@ -43,7 +43,7 @@ afterEach(() => vi.clearAllMocks());
async function renderCard(o: AcoustIDOverview = base) {
vi.mocked(getAcoustID).mockResolvedValue(o);
render(AcoustIDCard);
await screen.findByRole("checkbox", { name: /look up untagged tracks/i });
await screen.findByRole("switch", { name: /look up untagged tracks/i });
}
const saveButton = () => screen.getByRole("button", { name: /^save$/i });
@@ -52,22 +52,10 @@ const lookUpNow = () => screen.getByRole("button", { name: /look up now/i });
describe("AcoustIDCard", () => {
test("shows where the library’s recording ids came from", async () => {
await renderCard();
const cov = screen.getByTestId("acoustid-coverage");
const cov = screen.getByTestId("acoustid-coverage").textContent?.replace(/\s+/g, " ");
// 1000 - 700 - 150 have no id.
for (const [label, n] of [
["From tags", "700"],
["Looked up", "150"],
["None", "150"],
["Waiting to be looked up", "55"],
["No match", "80"],
["Ambiguous", "12"],
["Failed", "3"],
]) {
const row = [...cov.querySelectorAll("dt")].find(
(dt) => dt.textContent?.trim() === label,
);
expect(row?.nextElementSibling?.textContent?.trim(), label).toBe(n);
}
expect(cov).toMatch(/Recording ids 700 from tags · 150 looked up · 150 none/);
expect(cov).toMatch(/Without one 55 waiting · 80 no match · 12 ambiguous · 3 failed/);
});
test("never shows the stored key, and sends one only when typed", async () => {
+51 -81
View File
@@ -9,6 +9,7 @@
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import AcquisitionServiceRow from './AcquisitionServiceRow.svelte';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
// Where Minstrel looks for music and what downloads it (M502 #5518),
// Fabled Librarian's model: two lists of connections the operator adds and
@@ -88,109 +89,78 @@
}
const addButtonClass =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover ' +
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary';
</script>
{#snippet addMenu(kind: 'search' | 'download', label: string)}
{#if adding === kind}
<div class="space-y-2" data-testid="add-{kind}">
{#each choices[kind] as choice (choice.protocol)}
<button
type="button"
class="block w-full rounded-lg border border-border px-4 py-3 text-left hover:bg-surface-hover
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
onclick={() => add(choice.protocol)}
<div class="px-4 py-3">
{#if adding === kind}
<div class="space-y-2" data-testid="add-{kind}">
{#each choices[kind] as choice (choice.protocol)}
<button
type="button"
class="block w-full rounded-md border border-border px-4 py-2 text-left hover:bg-surface-hover"
onclick={() => add(choice.protocol)}
>
<span class="block text-sm text-text-primary">{choice.name}</span>
<span class="block text-xs text-text-secondary">{choice.note}</span>
</button>
{/each}
<button type="button" class="text-sm text-text-secondary underline hover:no-underline" onclick={() => (adding = null)}
>Cancel</button
>
<span class="block text-sm font-medium text-text-primary">{choice.name}</span>
<span class="block text-xs text-text-secondary">{choice.note}</span>
</button>
{/each}
<button
type="button"
class="text-sm text-text-secondary underline hover:no-underline"
onclick={() => (adding = null)}>Cancel</button
>
</div>
{:else}
<button type="button" class={addButtonClass} onclick={() => (adding = kind)}>{label}</button>
{/if}
</div>
{:else}
<button type="button" class={addButtonClass} onclick={() => (adding = kind)}>{label}</button>
{/if}
</div>
{/snippet}
<section
class="space-y-5 rounded-xl border border-border bg-surface p-5"
data-testid="acquisition-card"
>
<div>
<h3 class="font-display text-lg font-medium text-text-primary">
Indexers and download clients
</h3>
<p class="mt-1 text-sm text-text-secondary">
Two jobs, kept apart: something to search with, and something to download with. Minstrel
speaks Prowlarr, Torznab and the qBittorrent WebUI, so a stock setup is a complete one.
</p>
</div>
<div class="space-y-6" data-testid="acquisition-card">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="text-sm text-error-fg">
Couldn't load indexers and download clients.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if services === null}
<p class="text-sm text-text-secondary">Loading indexers and download clients…</p>
{:else}
<!-- The verdict first (#189): can music be found and downloaded at all. -->
<p class="text-sm" data-testid="acquisition-health">
{#if !canFetch}
<span class="text-action-destructive">
No download client is switched on, so nothing can be downloaded.
</span>
<span class="text-error-fg">No download client is switched on, so nothing can be downloaded.</span>
{:else if !canSearch}
<span class="text-action-destructive">
No indexer is switched on, so nothing new can be found.
</span>
<span class="text-error-fg">No indexer is switched on, so nothing new can be found.</span>
{:else}
<span class="text-text-secondary">Music can be searched for and downloaded.</span>
{/if}
</p>
<div class="space-y-3" data-testid="indexers">
<div>
<h4 class="text-sm font-medium text-text-primary">Indexers: where music is looked for</h4>
<p class="text-xs text-text-secondary">
Every indexer switched on here is searched, and the results are merged. If one is
unreachable the search still runs, narrower.
</p>
</div>
{#each indexers as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="text-sm text-text-secondary">No indexer yet. Prowlarr is the usual choice.</p>
{/each}
{#if searchingExchanges.length > 0}
<p class="text-xs text-text-secondary">
Also searched: {searchingExchanges.map((s) => s.label).join(', ')}, set up under download
clients below.
</p>
{/if}
{@render addMenu('search', 'Add indexer')}
<div data-testid="indexers">
<SettingsGroup title="Indexers" id="indexers" hint="Where music is looked for. Every one switched on is searched.">
{#each indexers as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="px-4 py-3 text-sm text-text-secondary">No indexer yet. Prowlarr is the usual choice.</p>
{/each}
{#if searchingExchanges.length > 0}
<p class="px-4 py-3 text-xs text-text-secondary">
Also searched: {searchingExchanges.map((s) => s.label).join(', ')}, under download clients.
</p>
{/if}
{@render addMenu('search', 'Add indexer')}
</SettingsGroup>
</div>
<div class="space-y-3 border-t border-border pt-5" data-testid="download-clients">
<div>
<h4 class="text-sm font-medium text-text-primary">
Download clients: what actually downloads
</h4>
<p class="text-xs text-text-secondary">
What a grab is handed to. A FabledExchange here can do more than download, and its entry
says which of its jobs are switched on.
</p>
</div>
{#each clients as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="text-sm text-text-secondary">No download client yet.</p>
{/each}
{@render addMenu('download', 'Add download client')}
<div data-testid="download-clients">
<SettingsGroup title="Download clients" id="download-clients" hint="What a grab is handed to.">
{#each clients as service (service.code)}
<AcquisitionServiceRow {service} onChanged={load} />
{:else}
<p class="px-4 py-3 text-sm text-text-secondary">No download client yet.</p>
{/each}
{@render addMenu('download', 'Add download client')}
</SettingsGroup>
</div>
{/if}
</section>
</div>
@@ -159,7 +159,7 @@
'focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<article class="space-y-3 rounded-lg border border-border p-4" data-testid="acquisition-service">
<article class="space-y-3 px-4 py-3" data-testid="acquisition-service">
<div class="flex flex-wrap items-center justify-between gap-3">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={form.enabled} aria-label="Use {form.label}" />
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { TriangleAlert } from 'lucide-svelte';
import {
getFingerprintSettings,
updateFingerprintSettings,
@@ -8,9 +7,11 @@
} from '#lib/api/admin.js';
import { 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';
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
// Lives on the Duplicates page, beside the results these settings shape.
// On Admin › Library (#5607); Duplicates says whether it is on and links here.
let { libraryTotal = 0, onSaved }: { libraryTotal?: number; onSaved?: () => void } =
$props();
@@ -81,150 +82,70 @@
}
}
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
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-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
</script>
<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">Fingerprinting</h3>
<p class="mt-1 text-sm text-text-secondary">
How tracks are fingerprinted, and how alike two must sound to be proposed as the same
recording. Identical files are always found, whatever these say.
</p>
</div>
<SettingsGroup title="Fingerprinting" id="fingerprint-settings" hint="How alike two tracks must sound to be one recording. Identical files are always found.">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load fingerprinting settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Fingerprint tracks</span>
<span class="block text-xs text-text-secondary">
Off stops all audio decoding: new files aren't fingerprinted and the library isn't
worked through in the background. Moved files are still recognised, because that only
reads the file. Tracks without a fingerprint aren't compared.
</span>
</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.auto_resolve} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
<span class="block text-xs text-text-secondary">
Merges a song held twice on one album, and a video rip with a clean copy elsewhere, into
the copy that fits best. A song kept on several releases counts as one song, and video
rips are kept out of radio and the mixes. Off, every group waits here for you.
</span>
</span>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
<span class="block text-xs text-text-secondary">
More tells apart recordings that only differ later in the track, and takes longer for
each file.
</span>
<input
type="number"
min="30"
max="600"
bind:value={form.chromaprint_length_sec}
class={inputClass}
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Minimum match (%)</span>
<span class="block text-xs text-text-secondary">
How alike two recordings must sound to be proposed as one. Higher proposes fewer, surer
groups. Unrelated songs score around 50%.
</span>
<input
type="number"
min="65"
max="99"
value={matchPercent}
oninput={(e) => onMatchInput(e.currentTarget.value)}
class={inputClass}
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Files fingerprinted at once</span>
<span class="block text-xs text-text-secondary">
The background pass competes with playback for CPU and with streaming for the disk.
Lower it if playback stutters while it runs.
</span>
<input
type="number"
min="1"
max="8"
bind:value={form.backfill_concurrency}
class={inputClass}
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Hours between sweeps</span>
<span class="block text-xs text-text-secondary">
The least time between automatic duplicate sweeps. A sweep only runs when something has
changed, and Sweep now doesn't wait.
</span>
<input
type="number"
min="1"
max="168"
bind:value={form.sweep_interval_hours}
class={inputClass}
/>
</label>
</div>
<SettingRow label="Fingerprint tracks" for="fp-enabled" hint="Off stops decoding audio; unfingerprinted tracks aren't compared.">
<input id="fp-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="Resolve duplicates automatically"
for="fp-auto"
hint="Merges clear cases into the best copy. Off, every group waits for you."
>
<input id="fp-auto" type="checkbox" role="switch" aria-checked={form.auto_resolve} bind:checked={form.auto_resolve} class="h-4 w-4 cursor-pointer accent-accent" />
</SettingRow>
<SettingRow label="Seconds of audio to fingerprint" for="fp-length" hint="More tells apart late differences; slower per file.">
<input id="fp-length" type="number" min="30" max="600" bind:value={form.chromaprint_length_sec} class={field} />
</SettingRow>
<SettingRow label="Minimum match" for="fp-match" hint="Higher proposes fewer, surer groups. Unrelated songs score about 50%.">
<input
id="fp-match"
type="number"
min="65"
max="99"
value={matchPercent}
oninput={(e) => onMatchInput(e.currentTarget.value)}
class={field}
/>
<span class="text-sm text-text-secondary">%</span>
</SettingRow>
<SettingRow label="Files fingerprinted at once" for="fp-concurrency" hint="Lower it if playback stutters while it runs.">
<input id="fp-concurrency" type="number" min="1" max="8" bind:value={form.backfill_concurrency} class={field} />
</SettingRow>
<SettingRow label="Hours between sweeps" for="fp-sweep" hint="At least this long between automatic duplicate sweeps.">
<input id="fp-sweep" type="number" min="1" max="168" bind:value={form.sweep_interval_hours} class={field} />
</SettingRow>
{#if lengthChanged}
<p
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
data-testid="length-warning"
>
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Saving re-fingerprints {libraryTotal > 0
? `all ${libraryTotal.toLocaleString()} tracks`
: 'every track'} at the new length. Fingerprints taken at different lengths can't be
compared, so a track is left out of the duplicate search until it's redone, and groups
of similar recordings come back as that work finishes.
</span>
<!-- The one setting with a cost not visible here: fingerprints of two
lengths can't be compared, so a new length redoes the library. -->
<p class="px-4 py-3 text-sm text-text-primary" data-testid="length-warning">
Saving re-fingerprints {libraryTotal > 0 ? `all ${libraryTotal.toLocaleString()} tracks` : 'every track'};
duplicates come back as that finishes.
</p>
{/if}
{#if problems.length > 0}
<ul class="space-y-1 text-xs text-action-destructive" data-testid="settings-problems">
{#each problems as problem (problem)}
<li>{problem}</li>
{/each}
</ul>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || problems.length > 0}
onclick={save}
>
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
{#if problems.length > 0}
<ul class="mr-auto space-y-1 text-xs text-error-fg" data-testid="settings-problems">
{#each problems as problem (problem)}
<li>{problem}</li>
{/each}
</ul>
{/if}
<button type="button" class={button} disabled={!dirty || saving || problems.length > 0} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
</SettingsGroup>
@@ -121,7 +121,7 @@ describe('FingerprintSettingsCard', () => {
test('auto-resolve can be turned off and is saved with the rest', async () => {
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, auto_resolve: false });
await renderCard();
const toggle = screen.getByRole('checkbox', { name: /resolve duplicates automatically/i }) as HTMLInputElement;
const toggle = screen.getByRole('switch', { name: /resolve duplicates automatically/i }) as HTMLInputElement;
expect(toggle.checked).toBe(true);
await fireEvent.click(toggle);
@@ -137,7 +137,7 @@ describe('FingerprintSettingsCard', () => {
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, enabled: false });
await renderCard({}, { onSaved });
await fireEvent.click(screen.getByRole('checkbox', { name: /fingerprint tracks/i }));
await fireEvent.click(screen.getByRole('switch', { name: /fingerprint tracks/i }));
await fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
expect(updateFingerprintSettings).toHaveBeenCalledWith(
@@ -9,6 +9,8 @@
} from '#lib/api/admin.js';
import { 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';
// Loudness analysis (#4995): the background measurement that loudness
// normalization levels playback from, its progress, and its knobs. The
@@ -77,121 +79,59 @@
}
}
const inputClass =
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
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-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
</script>
<section class="space-y-4 rounded border border-border bg-surface p-4">
<div>
<h2 class="font-display text-lg font-medium">Loudness analysis</h2>
<p class="mt-1 text-sm text-text-secondary">
Measures how loud each track is, so playback can even out the volume between tracks. It runs
in the background; until a track is measured, it plays at its own volume.
</p>
</div>
<SettingsGroup title="Loudness" id="loudness" hint="Measures each track so playback can even out the volume.">
{#if coverage && coverage.total > 0}
<div class="flex flex-wrap items-center gap-3 text-sm" data-testid="loudness-coverage">
<span>
{coverage.measured.toLocaleString()} of {coverage.total.toLocaleString()} tracks measured
</span>
{#if coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>{coverage.pending.toLocaleString()} pending</span>
{/if}
{#if !coverage.enabled && coverage.pending > 0}
<span class="text-text-muted">·</span>
<span>paused while analysis is off</span>
{/if}
{#if coverage.silent > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="These tracks are silent or too short to measure. They play without adjustment."
>
{coverage.silent.toLocaleString()} silent
</span>
{/if}
{#if coverage.unreadable > 0}
<span class="text-text-muted">·</span>
<span
class="cursor-help"
title="ffmpeg couldn't decode these files. Each is tried again when its file changes."
>
{coverage.unreadable.toLocaleString()} unreadable
</span>
{/if}
{@const c = coverage}
<div data-testid="loudness-coverage">
<SettingRow label="Progress">
{#snippet hint()}
{c.measured.toLocaleString()} of {c.total.toLocaleString()} tracks measured
{#if c.pending > 0}{` · ${c.pending.toLocaleString()} pending`}{/if}
{#if !c.enabled && c.pending > 0}{' · paused while analysis is off'}{/if}
{#if c.silent > 0}
<span title="Silent or too short to measure. They play without adjustment.">{` · ${c.silent.toLocaleString()} silent`}</span>
{/if}
{#if c.unreadable > 0}
<span title="ffmpeg couldn't decode these files. Each is tried again when its file changes.">{` · ${c.unreadable.toLocaleString()} unreadable`}</span>
{/if}
{/snippet}
</SettingRow>
</div>
{/if}
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load loudness analysis settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Analyze tracks</span>
<span class="block text-xs text-text-secondary">
Off stops the background analysis, including for new tracks. Tracks already measured keep
their values, so they stay leveled.
</span>
</span>
</label>
<label class="block">
<span class="text-sm text-text-primary">Files analyzed at once</span>
<span class="block text-xs text-text-secondary">
Each analysis reads the whole file, competing with playback for CPU and with streaming for
the disk. Lower it if playback stutters while it runs.
</span>
<input
type="number"
min="1"
max="8"
bind:value={form.backfill_concurrency}
class={inputClass}
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Speaker cache (MB)</span>
<span class="block text-xs text-text-secondary">
Leveled copies for Sonos and UPnP speakers. The least recently played go first.
</span>
<input
type="number"
min="256"
max="65536"
step="256"
bind:value={form.leveled_cache_mb}
class={inputClass}
/>
</label>
{#if !valid}
<div class="text-xs text-action-destructive" data-testid="settings-problems">
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
</div>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving || !valid}
onclick={save}
>
<SettingRow label="Analyze tracks" for="loudness-enabled" hint="Off stops it; tracks already measured stay leveled.">
<input id="loudness-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="Files analyzed at once" for="loudness-concurrency" hint="Lower it if playback stutters while it runs.">
<input id="loudness-concurrency" type="number" min="1" max="8" bind:value={form.backfill_concurrency} class={field} />
</SettingRow>
<SettingRow label="Speaker cache" for="loudness-cache" hint="Leveled copies for Sonos and UPnP speakers.">
<input id="loudness-cache" type="number" min="256" max="65536" step="256" bind:value={form.leveled_cache_mb} class={field} />
<span class="text-sm text-text-secondary">MB</span>
</SettingRow>
<div class="flex flex-wrap items-center justify-end gap-2 px-4 py-3">
{#if !valid}
<div class="mr-auto text-xs text-error-fg" data-testid="settings-problems">
{#if !concurrencyOk}<p>Files analyzed at once must be a whole number from 1 to 8.</p>{/if}
{#if !cacheOk}<p>Speaker cache must be a whole number of MB from 256 to 65536.</p>{/if}
</div>
{/if}
<button type="button" class={button} disabled={!dirty || saving || !valid} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
</SettingsGroup>
@@ -1,12 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Save, TriangleAlert } from 'lucide-svelte';
import {
getNetworkSettings,
updateNetworkSettings,
type NetworkSettings
} from '#lib/api/admin.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';
// Client IP detection, on Admin › Email & network: decides which address
// is recorded for each sign-in on the Active sessions list.
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';
let settings = $state<NetworkSettings | null>(null);
let hops = $state(1);
@@ -50,86 +56,55 @@
}
</script>
<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">Client IP detection</h3>
<p class="mt-1 text-sm text-text-secondary">
How many reverse proxies sit in front of Minstrel. This decides which address is
recorded for each sign-in on the <span class="whitespace-nowrap">Active sessions</span> card,
so getting it right is what makes an unfamiliar login visible.
</p>
</div>
<SettingsGroup title="Proxies" id="proxies" hint="How many reverse proxies sit in front of Minstrel.">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load network settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if settings === null}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm text-text-secondary">Trusted proxies</span>
<input
type="number"
min="0"
max={settings.max_hops}
bind:value={hops}
class="w-24 rounded border border-border bg-background px-2 py-1
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5
text-sm hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
disabled={saving || !dirty}
onclick={save}
>
<Save size={14} aria-hidden="true" />
<SettingRow
label="Trusted proxies"
for="proxy-hops"
hint="0 is none, 1 a proxy such as nginx or Caddy, 2 a CDN in front of it."
>
<input
id="proxy-hops"
type="number"
min="0"
max={settings.max_hops}
bind:value={hops}
class="w-20 rounded-md border border-border bg-background px-2 py-1.5 text-sm"
/>
<button type="button" class={button} disabled={saving || !dirty} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
<!-- Verification, not decoration: the number is abstract, but "the address
Minstrel currently sees for YOU" is checkable against the machine
you're sitting at. -->
<dl class="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-[auto_1fr]">
<dt class="text-text-secondary">Your address right now</dt>
<dd class="font-mono">{settings.detected_client_ip || 'unknown'}</dd>
<dt class="text-text-secondary">Direct connection from</dt>
<dd class="font-mono">{settings.remote_addr || 'unknown'}</dd>
<dt class="text-text-secondary">Forwarded chain</dt>
<dd class="font-mono break-all">{settings.forwarded_chain || '(none)'}</dd>
</dl>
</SettingRow>
{#if suggested > 0 && settings.trusted_proxy_hops !== suggested}
<p class="text-sm text-text-secondary">
<p class="px-4 py-3 text-sm text-text-primary">
This request arrived with {suggested}
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, which usually means
{suggested}
{suggested === 1 ? 'proxy' : 'proxies'} in front of Minstrel.
{suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, so {suggested}
{suggested === 1 ? 'proxy is' : 'proxies are'} likely.
</p>
{/if}
<div class="space-y-2 rounded border border-border bg-background p-3 text-sm">
<p class="flex items-start gap-2 text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Count your proxies — don't guess high. This number tells Minstrel how much of the
<span class="font-mono">X-Forwarded-For</span> header to believe, and that header is
written by whoever connects. Set it higher than your real chain, or above 0 with no
proxy at all, and a visitor can choose which address their own session shows — which
defeats the point of the sessions list.
</span>
</p>
<ul class="ml-6 list-disc space-y-1 text-text-secondary">
<li><strong>0</strong> — no proxy; Minstrel is reached directly.</li>
<li><strong>1</strong> — one reverse proxy, e.g. nginx, Caddy or Traefik terminating TLS.</li>
<li><strong>2</strong> — a CDN in front of your own proxy, e.g. Cloudflare → nginx.</li>
</ul>
</div>
<!-- Verification, not decoration: "the address Minstrel sees for you"
can be checked against the machine you're at. -->
<SettingRow label="Your address right now">
<span class="font-mono text-sm">{settings.detected_client_ip || 'unknown'}</span>
</SettingRow>
<SettingRow label="Direct connection from">
<span class="font-mono text-sm">{settings.remote_addr || 'unknown'}</span>
</SettingRow>
<SettingRow label="Forwarded chain">
<span class="break-all font-mono text-sm">{settings.forwarded_chain || 'none'}</span>
</SettingRow>
<!-- X-Forwarded-For is written by whoever connects, so a depth set too
high lets a visitor pick the address their session shows. -->
<p class="px-4 py-3 text-xs text-text-secondary">
Count your proxies, don't guess high: set too high, a visitor can choose the address their
session shows.
</p>
{/if}
</section>
</SettingsGroup>
@@ -7,10 +7,16 @@
} from '#lib/api/admin.js';
import { 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';
// When notification emails go out (M489 #5346). Nothing is emailed per
// event: new music is a daily summary, everything else a batch.
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-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
let saved = $state<NotificationEmailSettings | null>(null);
let form = $state<NotificationEmailSettings | null>(null);
let saving = $state(false);
@@ -58,69 +64,30 @@
}
</script>
<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">Notification emails</h3>
<p class="mt-1 text-sm text-text-secondary">
Nothing is emailed per event. Each person chooses which notifications reach their email in
their own settings.
</p>
</div>
<SettingsGroup title="Notification emails" id="notification-emails" hint="Never one per event. Each person picks theirs in their own settings.">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load the email schedule.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading the email schedule…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading the email schedule…</p>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">New music summary</span>
<span class="block text-xs text-text-secondary">
Once a day at this hour, in each person's own timezone.
</span>
<select
bind:value={form.summary_hour}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
>
{#each hours as h (h)}
<option value={h}>{hourLabel(h)}</option>
{/each}
</select>
</label>
<label class="block">
<span class="text-sm text-text-primary">Batch everything else (minutes)</span>
<span class="block text-xs text-text-secondary">
One email this long after the first item, holding all that came in.
</span>
<input
type="number"
min="15"
max="1440"
bind:value={form.batch_window_minutes}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
</div>
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
>
<SettingRow label="New music summary" for="summary-hour" hint="Daily, at this hour in each person's timezone.">
<select id="summary-hour" bind:value={form.summary_hour} class={field}>
{#each hours as h (h)}
<option value={h}>{hourLabel(h)}</option>
{/each}
</select>
</SettingRow>
<SettingRow label="Batch everything else" for="batch-window" hint="One email this long after the first item.">
<input id="batch-window" type="number" min="15" max="1440" bind:value={form.batch_window_minutes} class={field} />
<span class="text-sm text-text-secondary">minutes</span>
</SettingRow>
<div class="flex justify-end px-4 py-3">
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
</SettingsGroup>
+30 -52
View File
@@ -1,9 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Save, TriangleAlert } from 'lucide-svelte';
import { getNetworkSettings, updatePublicUrl } from '#lib/api/admin.js';
import { 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';
// The address links in email point at (#4981), on Admin › Email & network.
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';
let saved = $state<string | null>(null);
let value = $state('');
@@ -43,65 +48,38 @@
}
</script>
<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">Public address</h3>
<p class="mt-1 text-sm text-text-secondary">
The address people use to reach Minstrel. Password-reset emails link here.
</p>
</div>
<SettingsGroup title="Public address" id="public-address" hint="Where people reach Minstrel. Password-reset emails link here.">
{#if loadError}
<p class="text-sm text-action-destructive">
Couldn't load network settings.
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load the public address.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if saved === null}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<div class="flex flex-wrap items-end gap-3">
<label class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-text-secondary">Address</span>
<input
type="url"
inputmode="url"
placeholder="https://music.example.com"
bind:value
class="w-full rounded border border-border bg-background px-2 py-1 font-mono
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
</label>
{#if here && value.trim() !== here}
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm hover:bg-surface-hover
focus-visible:ring-2 focus-visible:ring-accent"
onclick={() => (value = here)}
>
Use {here}
</button>
{/if}
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5
text-sm hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
disabled={saving || !dirty}
onclick={save}
>
<Save size={14} aria-hidden="true" />
<SettingRow label="Address" for="public-url">
<input
id="public-url"
type="url"
inputmode="url"
placeholder="https://music.example.com"
bind:value
class="w-64 max-w-full rounded-md border border-border bg-background px-3 py-1.5 font-mono text-sm"
/>
<button type="button" class={button} disabled={saving || !dirty} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</SettingRow>
{#if here && value.trim() !== here}
<SettingRow label="This page's address" hint={here}>
<button type="button" class={button} onclick={() => (value = here)}>Use {here}</button>
</SettingRow>
{/if}
{#if !saved}
<p class="flex items-start gap-2 text-sm text-text-secondary" role="status">
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
Not set, so password-reset emails are not being sent. Minstrel won't build the link from
whatever address a request claims, because anyone can claim any address.
</span>
<!-- Not built from the request's own Host: anyone can claim any address. -->
<p class="px-4 py-3 text-sm text-error-fg" role="status">
Not set, so password-reset emails are not being sent.
</p>
{/if}
{/if}
</section>
</SettingsGroup>
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { TriangleAlert } from 'lucide-svelte';
import {
getReacquisitionSettings,
updateReacquisitionSettings,
@@ -8,6 +7,8 @@
} from '#lib/api/admin.js';
import { 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';
// Policy for putting an album whose files went missing back on the wanted
// list (milestone #290, M502 #5520). On Admin › Acquisition with the rest
@@ -15,6 +16,10 @@
// links here. Searching for a want, and retrying, is the search
// schedule's job, so there is no retry policy here.
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-24 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
let saved = $state<ReacquisitionSettings | null>(null);
let form = $state<ReacquisitionSettings | null>(null);
let saving = $state(false);
@@ -69,97 +74,37 @@
</script>
<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">Automatic re-acquisition</h3>
<p class="mt-1 text-sm text-text-secondary">
When a file has been missing for a while, Minstrel can put its album back on the
<a href="/collection/missing" class="text-accent-fg hover:underline">wanted list</a>
by itself. It's wanted per album, not per track: a whole folder going missing is one
want, not forty.
</p>
</div>
<SettingsGroup title="Missing files" id="reacquisition" hint="Put an album back on the wanted list when its files go missing.">
{#if loadError}
<p class="text-sm text-action-destructive">
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load re-acquisition settings.
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
</p>
{:else if form === null}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.enabled} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Want missing albums back automatically</span>
<span class="block text-xs text-text-secondary">
Off leaves missing files listed here and does nothing about them.
</span>
</span>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">Wait before wanting it back</span>
<span class="block text-xs text-text-secondary">
Hours a file must be missing before its album is wanted. A drive that
didn't mount, or a container that started before its media, resolves
itself well inside a day; this is what stops those becoming wants.
</span>
<input
type="number"
min="1"
max="720"
bind:value={form.grace_hours}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
<label class="block">
<span class="text-sm text-text-primary">Albums per sweep</span>
<span class="block text-xs text-text-secondary">
Ceiling on albums wanted each hour, so a large loss trickles.
</span>
<input
type="number"
min="1"
max="200"
bind:value={form.max_per_pass}
class="mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm
text-text-primary focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent"
/>
</label>
</div>
<SettingRow label="Want missing albums back" for="reacq-enabled" hint="Per album, not per track: a lost folder is one want.">
<input id="reacq-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="Wait before wanting it back" for="reacq-grace" hint="Rides out a drive that didn't mount in time.">
<input id="reacq-grace" type="number" min="1" max="720" bind:value={form.grace_hours} class={field} />
<span class="text-sm text-text-secondary">hours</span>
</SettingRow>
<SettingRow label="Albums per sweep" for="reacq-max" hint="Each hour, so a large loss trickles in.">
<input id="reacq-max" type="number" min="1" max="200" bind:value={form.max_per_pass} class={field} />
</SettingRow>
{#if saved && saved.unnameable_albums > 0}
<!-- Stated rather than left to be inferred: without this, an operator
watching those rows never get wanted would reasonably conclude
the feature is broken. -->
<p class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary">
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
<span>
{saved.unnameable_albums}
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no
MusicBrainz ID, so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted:
there's no way to identify the release. A re-scan after tagging fixes it.
</span>
<!-- Said outright: otherwise rows that never get wanted read as a broken feature. -->
<p class="px-4 py-3 text-sm text-text-primary">
{saved.unnameable_albums}
{saved.unnameable_albums === 1 ? 'album has' : 'albums have'} missing files but no MusicBrainz ID,
so {saved.unnameable_albums === 1 ? 'it' : 'they'} can't be wanted. Tag and re-scan to fix.
</p>
{/if}
<div class="flex justify-end">
<button
type="button"
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50"
disabled={!dirty || saving}
onclick={save}
>
<div class="flex justify-end px-4 py-3">
<button type="button" class={button} disabled={!dirty || saving} onclick={save}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
</SettingsGroup>
+97 -147
View File
@@ -6,6 +6,8 @@
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import type { SearchSettings } from '#lib/api/types.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// Searching and grabbing (M502 #5521): how often each want is searched
// for, whether the best release is taken without asking, and which
@@ -16,6 +18,9 @@
// is given up after the hours below (#5523).
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 field = 'w-20 rounded-md border border-border bg-background px-2 py-1.5 text-sm';
const store = createSearchSettingsQuery();
const query = $derived($store);
@@ -99,170 +104,115 @@
}
</script>
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="search-settings-card">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Searching and grabbing</h3>
<p class="mt-1 text-sm text-text-secondary">
Minstrel searches your indexers for each album on the
<a href="/collection/wanted" class="text-accent-fg hover:underline">wanted list</a>, judges every
release it finds, and hands the best one to your download client. Each album's releases and
the reason for every verdict are on the Wanted page.
</p>
</div>
<SettingsGroup title="Searching" id="searching" hint="Each wanted album is searched for, judged, and the best release downloaded.">
{#if query.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else if query.isError || !form || !query.data}
<p class="text-sm text-action-destructive">Couldn't load the search settings.</p>
<p class="px-4 py-3 text-sm text-error-fg">
Couldn't load the search settings.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else}
<label class="flex items-start gap-3">
{@const data = query.data}
<SettingRow label="Last pass">
{#snippet hint()}<span data-testid="last-pass">{lastPass(data)}</span>{/snippet}
<a href="/collection/wanted" class="text-sm text-accent-fg hover:underline">Wanted</a>
</SettingRow>
<SettingRow label="Take the best release without asking" for="search-auto" hint="Off, you choose from the Wanted page.">
<input
id="search-auto"
type="checkbox"
role="switch"
aria-checked={form.auto_grab}
checked={form.auto_grab}
onchange={(e) => edit({ auto_grab: e.currentTarget.checked })}
class="mt-1"
class="h-4 w-4 cursor-pointer accent-accent"
/>
<span>
<span class="text-sm text-text-primary">Take the best release without asking</span>
<span class="block text-xs text-text-secondary">
Off, a search only lists what it found, and you choose from the Wanted page.
</span>
</span>
</label>
<label class="block">
<span class="text-sm text-text-primary">Search each album again after</span>
<span class="block text-xs text-text-secondary">
Hours between searches for an album still wanted. 0 switches the schedule off; Search on the
Wanted page still works.
</span>
<span class="mt-1 flex items-center gap-2">
<input
type="number"
min="0"
max="720"
value={form.search_interval_hours}
oninput={(e) => edit({ search_interval_hours: Number(e.currentTarget.value) })}
class="w-24 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
<span class="text-sm text-text-secondary">hours</span>
</span>
</label>
<fieldset>
<legend class="text-sm text-text-primary">Qualities to take</legend>
<span class="block text-xs text-text-secondary">
The best of these is taken first. A release that names no format has its file list read to
find out.
</span>
<div class="mt-2 space-y-1.5">
{#each query.data.tiers as t (t.tier)}
<label class="flex items-start gap-2">
<input type="checkbox" checked={form.allowed.includes(t.tier)} onchange={() => toggle(t.tier)} class="mt-0.5" />
<span class="text-sm text-text-primary">
{t.label}
<span class="block text-xs text-text-secondary">{t.what}</span>
</span>
</label>
{/each}
</SettingRow>
<SettingRow label="Search again after" for="search-interval" hint="For an album still wanted. 0 turns the schedule off.">
<input
id="search-interval"
type="number"
min="0"
max="720"
value={form.search_interval_hours}
oninput={(e) => edit({ search_interval_hours: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">hours</span>
</SettingRow>
<SettingRow label="Qualities to take" hint="The best of these is taken first." />
{#each data.tiers as t (t.tier)}
<div class="pl-4">
<SettingRow label={t.label} for="tier-{t.tier}" hint={t.what}>
<input
id="tier-{t.tier}"
type="checkbox"
checked={form.allowed.includes(t.tier)}
onchange={() => toggle(t.tier)}
class="h-4 w-4 cursor-pointer accent-accent"
/>
</SettingRow>
</div>
</fieldset>
{#if query.data.scan_paths.length > 1}
<label class="block">
<span class="text-sm text-text-primary">File new music into</span>
<span class="block text-xs text-text-secondary">
A download that checks out is copied here as Artist/Album (Year)/Artist - Album - NN -
Title, tagged with its MusicBrainz ids.
</span>
{/each}
{#if data.scan_paths.length > 1}
<SettingRow label="File new music into" for="search-root" hint="As Artist/Album (Year), tagged with its MusicBrainz ids.">
<select
value={form.import_root || query.data.scan_paths[0]}
id="search-root"
value={form.import_root || data.scan_paths[0]}
onchange={(e) => edit({ import_root: e.currentTarget.value })}
class="mt-1 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
class="rounded-md border border-border bg-background px-2 py-1.5 font-mono text-sm"
>
{#each query.data.scan_paths as p (p)}
{#each data.scan_paths as p (p)}
<option value={p}>{p}</option>
{/each}
</select>
</label>
{:else if query.data.scan_paths.length === 1}
<p class="text-xs text-text-secondary">
A download that checks out is copied into <span class="font-mono">{query.data.scan_paths[0]}</span>
as Artist/Album (Year)/Artist - Album - NN - Title, tagged with its MusicBrainz ids.
</p>
</SettingRow>
{:else if data.scan_paths.length === 1}
<SettingRow label="New music goes into" hint="As Artist/Album (Year), tagged with its MusicBrainz ids.">
<span class="font-mono text-sm text-text-secondary">{data.scan_paths[0]}</span>
</SettingRow>
{/if}
<fieldset class="space-y-3">
<legend class="text-sm text-text-primary">After the download</legend>
<label class="block">
<span class="block text-xs text-text-secondary">
The files are copied into the library, so the download goes on sharing until either limit,
then is removed with its files. A ratio of 0 removes it once imported; 0 days is no time
limit.
</span>
<span class="mt-1 flex flex-wrap items-center gap-2 text-sm text-text-secondary">
Seed to a ratio of
<input
type="number"
min="0"
max="100"
step="0.1"
aria-label="Seed ratio"
value={form.seed_ratio}
oninput={(e) => edit({ seed_ratio: Number(e.currentTarget.value) })}
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
or for
<input
type="number"
min="0"
max="365"
aria-label="Seed days"
value={form.seed_days}
oninput={(e) => edit({ seed_days: Number(e.currentTarget.value) })}
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
days
</span>
</label>
<label class="block">
<span class="block text-xs text-text-secondary">
A download nobody is sharing is asked for again three times, ten minutes apart, then given
up: removed, held back for a week, and the album searched for again. 0 never gives up for
that; a download the client fails is always given up.
</span>
<span class="mt-1 flex flex-wrap items-center gap-2 text-sm text-text-secondary">
Give up on a stalled download after
<input
type="number"
min="0"
max="720"
aria-label="Stall hours"
value={form.stall_hours}
oninput={(e) => edit({ stall_hours: Number(e.currentTarget.value) })}
class="w-20 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
/>
hours
</span>
</label>
</fieldset>
<p class="text-xs text-text-secondary" data-testid="last-pass">{lastPass(query.data)}</p>
<div class="flex justify-end">
<button
type="button"
onclick={save}
disabled={!dirty || saving || form.allowed.length === 0}
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
<SettingRow label="Seed ratio" for="seed-ratio" hint="Share a finished download to this ratio. 0 stops once it's filed.">
<input
id="seed-ratio"
type="number"
min="0"
max="100"
step="0.1"
value={form.seed_ratio}
oninput={(e) => edit({ seed_ratio: Number(e.currentTarget.value) })}
class={field}
/>
</SettingRow>
<SettingRow label="Seed days" for="seed-days" hint="Or for this long, whichever comes first. 0 is no limit.">
<input
id="seed-days"
type="number"
min="0"
max="365"
value={form.seed_days}
oninput={(e) => edit({ seed_days: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">days</span>
</SettingRow>
<SettingRow label="Give up on a stalled download after" for="stall-hours" hint="When nobody shares it. 0 never gives up.">
<input
id="stall-hours"
type="number"
min="0"
max="720"
value={form.stall_hours}
oninput={(e) => edit({ stall_hours: Number(e.currentTarget.value) })}
class={field}
/>
<span class="text-sm text-text-secondary">hours</span>
</SettingRow>
<div class="flex justify-end px-4 py-3">
<button type="button" class={button} onclick={save} disabled={!dirty || saving || form.allowed.length === 0}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
</SettingsGroup>
@@ -44,7 +44,7 @@ describe('SearchSettingsCard', () => {
test('shows the settings and what the last pass did', () => {
setup();
expect((screen.getByLabelText(/take the best release without asking/i) as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText(/hours between searches/i) as HTMLInputElement).value).toBe('24');
expect((screen.getByLabelText(/search again after/i) as HTMLInputElement).value).toBe('24');
expect((screen.getByLabelText(/^unknown/i) as HTMLInputElement).checked).toBe(false);
const last = screen.getByTestId('last-pass').textContent ?? '';
expect(last).toMatch(/searched 12, took 3, 2 waiting for you, 7 with nothing found\./);
@@ -93,7 +93,7 @@ describe('SearchSettingsCard', () => {
expect((screen.getByLabelText(/seed ratio/i) as HTMLInputElement).value).toBe('2');
await fireEvent.input(screen.getByLabelText(/seed ratio/i), { target: { value: '1.5' } });
await fireEvent.input(screen.getByLabelText(/seed days/i), { target: { value: '7' } });
await fireEvent.input(screen.getByLabelText(/stall hours/i), { target: { value: '0' } });
await fireEvent.input(screen.getByLabelText(/give up on a stalled download/i), { target: { value: '0' } });
await fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(putSearchSettings).toHaveBeenCalledWith(
@@ -103,7 +103,7 @@
let isSaving = $state(false);
let isTesting = $state(false);
function takeLists(t: LidarrTestResult) {
function takeLists(t: Extract<LidarrTestResult, { ok: true }>) {
testQualityProfiles = t.quality_profiles ?? [];
testMetadataProfiles = t.metadata_profiles ?? [];
testRootFolders = t.root_folders ?? [];
@@ -119,7 +119,7 @@
<SettingRow label={p.display_name} for="src-{p.id}">
{#snippet hint()}
{supplies(p)}
{#if note}{' · '}{@render note(p)}{/if}
{#if note}<span class="block">{@render note(p)}</span>{/if}
{/snippet}
{#if l}
<input
@@ -150,7 +150,7 @@ describe('Admin › Metadata sources', () => {
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 });
vi.mocked(updateTagProvider).mockResolvedValueOnce({ ...lastfm, enabled: true, api_key_set: true, version_bumped: true });
const row = source('lastfm');
await fireEvent.click(within(row).getByRole('switch'));
await fireEvent.input(within(row).getByLabelText(/api key/i), { target: { value: 'lfmkey' } });