feat(admin): fingerprinting settings — on/off, length, match threshold, concurrency, sweep interval (M400 #3913)
test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
getFingerprintSettings,
|
||||
updateFingerprintSettings,
|
||||
type FingerprintSettings
|
||||
} from '$lib/api/admin';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
||||
// Lives on the Duplicates page, beside the results these settings shape.
|
||||
|
||||
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 inputClass =
|
||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
|
||||
</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>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-action-destructive">
|
||||
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>
|
||||
{: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>
|
||||
|
||||
<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>
|
||||
|
||||
{#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 flex-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>
|
||||
</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 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'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import type { FingerprintSettings } from '$lib/api/admin';
|
||||
import { ERROR_COPY } from '$lib/api/error-copy';
|
||||
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
getFingerprintSettings: vi.fn(),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
||||
|
||||
import FingerprintSettingsCard from './FingerprintSettingsCard.svelte';
|
||||
import { getFingerprintSettings, updateFingerprintSettings } from '$lib/api/admin';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
|
||||
const base: FingerprintSettings = {
|
||||
enabled: true,
|
||||
chromaprint_length_sec: 120,
|
||||
acoustic_max_bit_error_rate: 0.15,
|
||||
backfill_concurrency: 2,
|
||||
sweep_interval_hours: 1
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
async function renderCard(
|
||||
over: Partial<FingerprintSettings> = {},
|
||||
props: { libraryTotal?: number; onSaved?: () => void } = {}
|
||||
) {
|
||||
vi.mocked(getFingerprintSettings).mockResolvedValue({ ...base, ...over });
|
||||
const r = render(FingerprintSettingsCard, { props });
|
||||
await screen.findByRole('spinbutton', { name: /seconds of audio/i });
|
||||
return r;
|
||||
}
|
||||
|
||||
const saveButton = () => screen.getByRole('button', { name: /save/i });
|
||||
|
||||
describe('FingerprintSettingsCard', () => {
|
||||
test('save is disabled until something changes', async () => {
|
||||
await renderCard();
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
await fireEvent.input(screen.getByRole('spinbutton', { name: /hours between sweeps/i }), {
|
||||
target: { value: '6' }
|
||||
});
|
||||
await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false));
|
||||
});
|
||||
|
||||
// The matcher works in bit-error rates; the report shows match percentages.
|
||||
// A card that showed 0.15 beside a report saying "96% match" would leave the
|
||||
// operator converting in their head.
|
||||
test('the threshold reads as a match percentage and saves as a bit-error rate', async () => {
|
||||
vi.mocked(updateFingerprintSettings).mockResolvedValue({
|
||||
...base,
|
||||
acoustic_max_bit_error_rate: 0.1
|
||||
});
|
||||
await renderCard();
|
||||
const match = screen.getByRole('spinbutton', { name: /minimum match/i }) as HTMLInputElement;
|
||||
expect(match.value).toBe('85');
|
||||
|
||||
await fireEvent.input(match, { target: { value: '90' } });
|
||||
await fireEvent.click(saveButton());
|
||||
await waitFor(() =>
|
||||
expect(updateFingerprintSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ acoustic_max_bit_error_rate: 0.1 })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// A new length re-fingerprints the whole library. Nothing else on the page
|
||||
// would say so before the operator commits to it.
|
||||
test('changing the length warns that every track is re-fingerprinted', async () => {
|
||||
await renderCard({}, { libraryTotal: 18026 });
|
||||
expect(screen.queryByTestId('length-warning')).toBeNull();
|
||||
|
||||
await fireEvent.input(screen.getByRole('spinbutton', { name: /seconds of audio/i }), {
|
||||
target: { value: '60' }
|
||||
});
|
||||
const warning = await screen.findByTestId('length-warning');
|
||||
expect(warning.textContent).toMatch(/all\s+18,026\s+tracks/);
|
||||
});
|
||||
|
||||
test('other changes carry no re-fingerprinting warning', async () => {
|
||||
await renderCard({}, { libraryTotal: 18026 });
|
||||
await fireEvent.input(screen.getByRole('spinbutton', { name: /files fingerprinted at once/i }), {
|
||||
target: { value: '4' }
|
||||
});
|
||||
await waitFor(() => expect(saveButton()).toHaveProperty('disabled', false));
|
||||
expect(screen.queryByTestId('length-warning')).toBeNull();
|
||||
});
|
||||
|
||||
test('a value out of range blocks saving and says which', async () => {
|
||||
await renderCard();
|
||||
await fireEvent.input(screen.getByRole('spinbutton', { name: /files fingerprinted at once/i }), {
|
||||
target: { value: '12' }
|
||||
});
|
||||
const problems = await screen.findByTestId('settings-problems');
|
||||
expect(problems.textContent).toMatch(/files fingerprinted at once must be from 1 to 8/i);
|
||||
expect(saveButton()).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('a rejected save surfaces the field the server names', async () => {
|
||||
const message = 'fingerprint setting out of range: sweep_interval_hours must be 1-168';
|
||||
vi.mocked(updateFingerprintSettings).mockRejectedValue({
|
||||
code: 'invalid_setting',
|
||||
message,
|
||||
status: 400
|
||||
});
|
||||
await renderCard();
|
||||
await fireEvent.input(screen.getByRole('spinbutton', { name: /hours between sweeps/i }), {
|
||||
target: { value: '6' }
|
||||
});
|
||||
await fireEvent.click(saveButton());
|
||||
await waitFor(() =>
|
||||
expect(pushToast).toHaveBeenCalledWith(`${ERROR_COPY.invalid_setting} ${message}`, 'error')
|
||||
);
|
||||
});
|
||||
|
||||
// Switching fingerprinting off changes what the page's counts mean.
|
||||
test('a save tells the page, so its counts refresh', async () => {
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, enabled: false });
|
||||
await renderCard({}, { onSaved });
|
||||
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: /fingerprint tracks/i }));
|
||||
await fireEvent.click(saveButton());
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1));
|
||||
expect(updateFingerprintSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ enabled: false })
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed load offers a retry rather than an empty card', async () => {
|
||||
vi.mocked(getFingerprintSettings).mockRejectedValue(new Error('nope'));
|
||||
render(FingerprintSettingsCard);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/couldn't load fingerprinting settings/i)).toBeTruthy()
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /try again/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user