Files
minstrel/web/src/lib/components/FingerprintSettingsCard.svelte
T
bvandeusenandClaude Opus 5.5 7f913e3e70
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
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)
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>
2026-10-11 11:25:46 -04:00

152 lines
6.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import {
getFingerprintSettings,
updateFingerprintSettings,
type FingerprintSettings
} 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).
// On Admin › Library (#5607); Duplicates says whether it is on and links here.
let { libraryTotal = 0, onSaved }: { libraryTotal?: number; onSaved?: () => void } =
$props();
let saved = $state<FingerprintSettings | null>(null);
let form = $state<FingerprintSettings | null>(null);
let saving = $state(false);
let loadError = $state(false);
const dirty = $derived(!!saved && !!form && JSON.stringify(saved) !== JSON.stringify(form));
// Stored as the share of bits two fingerprints may disagree on; the report
// speaks in match percentages, so the card does too.
const matchPercent = $derived(
form ? Math.round((1 - form.acoustic_max_bit_error_rate) * 100) : 0
);
function onMatchInput(value: string) {
if (!form) return;
form.acoustic_max_bit_error_rate = value === '' ? NaN : Math.round(100 - Number(value)) / 100;
}
// The one setting with a cost the operator can't see from here: fingerprints
// taken at two lengths can't be compared, so a new length redoes the library.
const lengthChanged = $derived(
!!saved && !!form && form.chromaprint_length_sec !== saved.chromaprint_length_sec
);
// Mirrors the server's ranges, so a bad value is named in the card's own terms
// (a percentage, not a bit-error rate) before anything is sent.
const between = (v: number, lo: number, hi: number) => Number.isInteger(v) && v >= lo && v <= hi;
const problems = $derived.by(() => {
if (!form) return [] as string[];
const out: string[] = [];
if (!between(form.chromaprint_length_sec, 30, 600))
out.push('Seconds of audio must be a whole number from 30 to 600.');
if (!between(matchPercent, 65, 99)) out.push('Minimum match must be from 65% to 99%.');
if (!between(form.backfill_concurrency, 1, 8))
out.push('Files fingerprinted at once must be from 1 to 8.');
if (!between(form.sweep_interval_hours, 1, 168))
out.push('Hours between sweeps must be from 1 to 168.');
return out;
});
async function load() {
try {
saved = await getFingerprintSettings();
form = { ...saved };
loadError = false;
} catch {
loadError = true;
}
}
onMount(load);
async function save() {
if (!form || problems.length > 0) return;
saving = true;
try {
saved = await updateFingerprintSettings(form);
form = { ...saved };
pushToast('Fingerprinting settings saved.');
onSaved?.();
} catch (e) {
pushToast(errMessage(e), 'error');
} finally {
saving = false;
}
}
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>
<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="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="px-4 py-3 text-sm text-text-secondary">Loading…</p>
{:else}
<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}
<!-- 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}
<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}
</SettingsGroup>