feat: the admin settings cards read as menus: each a titled group of rows with one short line, status first, failures in words (M503 #5607)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m27s
release / go (push) Successful in 1m44s
release / android (push) Successful in 5m13s
release / integration (push) Successful in 5m18s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
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

Indexers and clients, searching, missing files, Lidarr's neighbours, AcoustID, fingerprinting, loudness, outgoing email's schedule, public address and proxies move from paragraph cards to SettingsGroup and SettingRow. Switches are role="switch". Also fixes the two type errors run 9023 found: the Lidarr test result is narrowed before its lists are read, and the tag-source save mock carries version_bumped.

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