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:
@@ -1,8 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getFingerprintCoverage, type FingerprintCoverage } from './admin';
|
||||
import {
|
||||
getFingerprintCoverage,
|
||||
getFingerprintSettings,
|
||||
updateFingerprintSettings,
|
||||
type FingerprintCoverage,
|
||||
type FingerprintSettings
|
||||
} from './admin';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn() }
|
||||
api: { get: vi.fn(), post: vi.fn(), put: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from './client';
|
||||
@@ -15,7 +21,8 @@ describe('admin fingerprint coverage API', () => {
|
||||
total: 18026,
|
||||
fingerprinted: 9400,
|
||||
rejected: 12,
|
||||
pending: 8614
|
||||
pending: 8614,
|
||||
enabled: true
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await getFingerprintCoverage();
|
||||
@@ -24,9 +31,31 @@ describe('admin fingerprint coverage API', () => {
|
||||
});
|
||||
|
||||
it('buckets sum to the total', async () => {
|
||||
const sample: FingerprintCoverage = { total: 10, fingerprinted: 6, rejected: 1, pending: 3 };
|
||||
const sample: FingerprintCoverage = {
|
||||
total: 10,
|
||||
fingerprinted: 6,
|
||||
rejected: 1,
|
||||
pending: 3,
|
||||
enabled: true
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await getFingerprintCoverage();
|
||||
expect(got.fingerprinted + got.rejected + got.pending).toBe(got.total);
|
||||
});
|
||||
|
||||
it('reads and saves the fingerprinting settings at one path', async () => {
|
||||
const settings: FingerprintSettings = {
|
||||
enabled: true,
|
||||
chromaprint_length_sec: 120,
|
||||
acoustic_max_bit_error_rate: 0.15,
|
||||
backfill_concurrency: 2,
|
||||
sweep_interval_hours: 1
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
||||
(api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
||||
await getFingerprintSettings();
|
||||
await updateFingerprintSettings(settings);
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/library/fingerprint-settings');
|
||||
expect(api.put).toHaveBeenCalledWith('/api/admin/library/fingerprint-settings', settings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,6 +322,9 @@ export type FingerprintCoverage = {
|
||||
fingerprinted: number;
|
||||
rejected: number;
|
||||
pending: number;
|
||||
// False when the operator has switched fingerprinting off (#3913): pending
|
||||
// then never shrinks, and nothing should read as progress.
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export async function getFingerprintCoverage(): Promise<FingerprintCoverage> {
|
||||
@@ -339,6 +342,28 @@ export function createFingerprintCoverageQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Fingerprinting settings (#3913) ------------------------------------------
|
||||
|
||||
export type FingerprintSettings = {
|
||||
enabled: boolean;
|
||||
chromaprint_length_sec: number;
|
||||
// The share of fingerprint bits two copies may disagree on and still be
|
||||
// proposed as one recording. The card shows it as a match percentage.
|
||||
acoustic_max_bit_error_rate: number;
|
||||
backfill_concurrency: number;
|
||||
sweep_interval_hours: number;
|
||||
};
|
||||
|
||||
export async function getFingerprintSettings(): Promise<FingerprintSettings> {
|
||||
return api.get<FingerprintSettings>('/api/admin/library/fingerprint-settings');
|
||||
}
|
||||
|
||||
export async function updateFingerprintSettings(
|
||||
s: FingerprintSettings
|
||||
): Promise<FingerprintSettings> {
|
||||
return api.put<FingerprintSettings>('/api/admin/library/fingerprint-settings', s);
|
||||
}
|
||||
|
||||
// Cover-art providers ------------------------------------------------------
|
||||
|
||||
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
|
||||
|
||||
@@ -75,6 +75,13 @@ describe('errMessage detail codes (#3918)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid_setting appends the field and range the server names', () => {
|
||||
const msg = 'fingerprint setting out of range: chromaprint_length_sec must be 30-600';
|
||||
expect(errMessage({ code: 'invalid_setting', message: msg })).toBe(
|
||||
`${ERROR_COPY.invalid_setting} ${msg}`
|
||||
);
|
||||
});
|
||||
|
||||
// Server messages are usually internal detail. Appending them for every code
|
||||
// would leak things like driver errors into toasts; this pins the scope.
|
||||
test('other codes never carry the server message', () => {
|
||||
|
||||
@@ -15,7 +15,12 @@ export function errCode(err: unknown): string {
|
||||
* server messages are internal detail and must never reach a toast. Mirrored
|
||||
* in Android's ErrorCopy.
|
||||
*/
|
||||
const DETAIL_CODES: ReadonlySet<string> = new Set(['library_not_writable', 'file_delete_failed']);
|
||||
const DETAIL_CODES: ReadonlySet<string> = new Set([
|
||||
'library_not_writable',
|
||||
'file_delete_failed',
|
||||
// The server names the field and its range (#3913).
|
||||
'invalid_setting'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns user-facing copy for an unknown error value. Looks up the
|
||||
|
||||
@@ -471,7 +471,13 @@ export type MergeDuplicateResult = {
|
||||
|
||||
export type AdminDuplicatesResponse = {
|
||||
sweep: AdminDuplicateSweep;
|
||||
fingerprints: { total: number; fingerprinted: number; rejected: number; pending: number };
|
||||
fingerprints: {
|
||||
total: number;
|
||||
fingerprinted: number;
|
||||
rejected: number;
|
||||
pending: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@
|
||||
"sweep_in_progress": "A duplicate sweep is already running.",
|
||||
"duplicate_group_not_pending": "That group has already been resolved.",
|
||||
"survivor_not_in_group": "That copy isn't part of this group any more.",
|
||||
"invalid_setting": "That setting is out of range.",
|
||||
"album_not_found": "That album no longer exists.",
|
||||
"artist_not_found": "That artist no longer exists.",
|
||||
"playlist_not_found": "That playlist no longer exists.",
|
||||
|
||||
@@ -445,6 +445,11 @@
|
||||
<span class="text-text-muted">·</span>
|
||||
<span>{fingerprints.pending.toLocaleString()} pending</span>
|
||||
{/if}
|
||||
{#if fingerprints.enabled === false}
|
||||
<!-- Off (#3913), pending never shrinks; say why rather than imply progress. -->
|
||||
<span class="text-text-muted">·</span>
|
||||
<a href="/admin/duplicates" class="underline hover:no-underline">fingerprinting is off</a>
|
||||
{/if}
|
||||
{#if fingerprints.rejected > 0}
|
||||
<span class="text-text-muted">·</span>
|
||||
<span
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import { relativeTime } from '$lib/utils/relativeTime';
|
||||
import type { AdminDuplicateGroup, AdminDuplicateMember } from '$lib/api/types';
|
||||
import FingerprintSettingsCard from '$lib/components/FingerprintSettingsCard.svelte';
|
||||
|
||||
// Tracks the duplicate sweep believes hold one recording (#3912). Dismissing a
|
||||
// group says "these are not duplicates", and the sweep will not propose that
|
||||
@@ -173,8 +174,13 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if prints && prints.pending > 0}
|
||||
{prints.pending.toLocaleString()} tracks are still waiting for a fingerprint and join
|
||||
the comparison once they have one.
|
||||
{#if prints.enabled === false}
|
||||
Fingerprinting is off, so {prints.pending.toLocaleString()} tracks without a current
|
||||
fingerprint aren't compared.
|
||||
{:else}
|
||||
{prints.pending.toLocaleString()} tracks are still waiting for a fingerprint and join
|
||||
the comparison once they have one.
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -191,8 +197,13 @@
|
||||
{#if prints && prints.total > 0 && prints.fingerprinted === 0}
|
||||
<p class="mt-3 text-text-primary">Nothing to compare yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||
Duplicates appear here as the sweep finds them.
|
||||
{#if prints.enabled === false}
|
||||
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the
|
||||
settings below.
|
||||
{:else}
|
||||
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||
Duplicates appear here as the sweep finds them.
|
||||
{/if}
|
||||
</p>
|
||||
{:else if sweep?.state === 'never'}
|
||||
<p class="mt-3 text-text-primary">The sweep hasn't run yet.</p>
|
||||
@@ -341,4 +352,8 @@
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Settings sit under the report whatever state it is in: turning
|
||||
fingerprinting back on is how an empty report gets out of that state. -->
|
||||
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={() => query.refetch()} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,17 @@ vi.mock('$lib/api/admin', () => ({
|
||||
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
||||
survivor_track_id: 'www-01',
|
||||
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
||||
})
|
||||
}),
|
||||
// The page embeds FingerprintSettingsCard, which loads its own settings from
|
||||
// this module; the card has its own suite.
|
||||
getFingerprintSettings: vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
chromaprint_length_sec: 120,
|
||||
acoustic_max_bit_error_rate: 0.15,
|
||||
backfill_concurrency: 2,
|
||||
sweep_interval_hours: 1
|
||||
}),
|
||||
updateFingerprintSettings: vi.fn()
|
||||
}));
|
||||
|
||||
import AdminDuplicatesPage from './+page.svelte';
|
||||
@@ -33,7 +43,7 @@ const finishedSweep = {
|
||||
oversize_clusters: 0,
|
||||
error_message: null
|
||||
};
|
||||
const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0 };
|
||||
const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0, enabled: true };
|
||||
|
||||
function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
|
||||
return {
|
||||
@@ -120,12 +130,33 @@ describe('admin duplicates', () => {
|
||||
response({
|
||||
groups: [],
|
||||
total: 0,
|
||||
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200 }
|
||||
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: true }
|
||||
})
|
||||
);
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted');
|
||||
});
|
||||
|
||||
// Switched off, the backlog never shrinks. "Still being fingerprinted" would
|
||||
// promise work nothing is doing (#3913).
|
||||
test('with fingerprinting off the page says so instead of promising progress', () => {
|
||||
renderWith(
|
||||
response({
|
||||
groups: [],
|
||||
total: 0,
|
||||
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: false }
|
||||
})
|
||||
);
|
||||
const empty = text(screen.getByTestId('empty-state'));
|
||||
expect(empty).toContain('Fingerprinting is off');
|
||||
expect(empty).not.toContain('still being fingerprinted');
|
||||
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
|
||||
});
|
||||
|
||||
test('the fingerprinting settings are on this page', async () => {
|
||||
renderWith(response());
|
||||
expect(await screen.findByRole('spinbutton', { name: /seconds of audio/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('empty before any sweep says the sweep has not run', () => {
|
||||
renderWith(
|
||||
response({
|
||||
|
||||
Reference in New Issue
Block a user