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;
|
||||
|
||||
Reference in New Issue
Block a user