From 077ae612353ee73eb508b74ad9ffbe4b2fd2e652 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Fri, 11 Sep 2026 17:53:55 -0400
Subject: [PATCH] =?UTF-8?q?feat(admin):=20fingerprinting=20settings=20?=
=?UTF-8?q?=E2=80=94=20on/off,=20length,=20match=20threshold,=20concurrenc?=
=?UTF-8?q?y,=20sweep=20interval=20(M400=20#3913)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
---
cmd/minstrel/main.go | 15 +-
internal/api/admin_coverage.go | 32 ++-
internal/api/admin_duplicates.go | 20 +-
internal/api/admin_fingerprint_settings.go | 67 ++++++
.../api/admin_fingerprint_settings_test.go | 55 +++++
internal/api/api.go | 49 ++--
internal/db/dbq/duplicates.sql.go | 18 +-
internal/db/dbq/fingerprint_settings.sql.go | 72 ++++++
internal/db/dbq/fingerprints.sql.go | 70 ++++--
internal/db/dbq/models.go | 21 +-
.../0061_fingerprint_settings.down.sql | 2 +
.../0061_fingerprint_settings.up.sql | 50 ++++
internal/db/queries/duplicates.sql | 4 +
internal/db/queries/fingerprint_settings.sql | 15 ++
internal/db/queries/fingerprints.sql | 33 ++-
internal/dbtest/reset.go | 11 +
internal/library/duplicate_sweep.go | 104 ++++++---
internal/library/duplicate_sweep_test.go | 83 ++++++-
internal/library/fingerprint.go | 71 ++++--
internal/library/fingerprint_backfill.go | 76 ++++--
internal/library/fingerprint_backfill_test.go | 78 ++++++-
internal/library/fingerprint_scan_test.go | 68 +++++-
internal/library/fingerprint_settings.go | 163 +++++++++++++
internal/library/fingerprint_settings_test.go | 146 ++++++++++++
internal/library/scanner.go | 27 ++-
internal/library/scanner_test.go | 4 +-
internal/server/server.go | 17 +-
web/src/lib/api/admin.fingerprints.test.ts | 37 ++-
web/src/lib/api/admin.ts | 25 ++
web/src/lib/api/errors.test.ts | 7 +
web/src/lib/api/errors.ts | 7 +-
web/src/lib/api/types.ts | 8 +-
.../components/FingerprintSettingsCard.svelte | 218 ++++++++++++++++++
.../FingerprintSettingsCard.test.ts | 141 +++++++++++
web/src/lib/styles/error-copy.json | 1 +
web/src/routes/admin/+page.svelte | 5 +
web/src/routes/admin/duplicates/+page.svelte | 23 +-
.../admin/duplicates/duplicates.test.ts | 37 ++-
38 files changed, 1679 insertions(+), 201 deletions(-)
create mode 100644 internal/api/admin_fingerprint_settings.go
create mode 100644 internal/api/admin_fingerprint_settings_test.go
create mode 100644 internal/db/dbq/fingerprint_settings.sql.go
create mode 100644 internal/db/migrations/0061_fingerprint_settings.down.sql
create mode 100644 internal/db/migrations/0061_fingerprint_settings.up.sql
create mode 100644 internal/db/queries/fingerprint_settings.sql
create mode 100644 internal/library/fingerprint_settings.go
create mode 100644 internal/library/fingerprint_settings_test.go
create mode 100644 web/src/lib/components/FingerprintSettingsCard.svelte
create mode 100644 web/src/lib/components/FingerprintSettingsCard.test.ts
diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go
index 6ad3e0ec..ed6dd522 100644
--- a/cmd/minstrel/main.go
+++ b/cmd/minstrel/main.go
@@ -122,7 +122,15 @@ func run() error {
}
defer pool.Close()
- scanner := library.New(pool, logger, cfg.Library.ScanPaths)
+ // Fingerprinting settings (M400 #3913): one instance, shared by the scanner,
+ // the fingerprint backfill, the duplicate sweep and the admin API, so a save
+ // reaches all of them without a restart. A load failure is logged, not fatal:
+ // the service falls back to the shipped defaults.
+ fpSettings, fpErr := library.NewFingerprintSettingsService(ctx, pool)
+ if fpErr != nil {
+ logger.Warn("fingerprint settings: using defaults", "err", fpErr)
+ }
+ scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
contact := cfg.Library.ContactEmail
if contact == "" {
@@ -218,12 +226,12 @@ func run() error {
// will — everything imported before fingerprinting existed, and rows derived
// by an older method. A worker of its own rather than a scan stage; see
// internal/library/fingerprint_backfill.go for why.
- go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill")).Run(ctx)
+ go library.NewFingerprintBackfillWorker(pool, logger.With("component", "fingerprint_backfill"), fpSettings).Run(ctx)
// Duplicate sweep (M400 #3910): proposes groups of tracks holding one
// recording, from the fingerprints above. Sweeps only when fingerprints have
// changed since the last sweep.
- go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep")).Run(ctx)
+ go library.NewDuplicateSweepWorker(pool, logger.With("component", "duplicate_sweep"), fpSettings).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
@@ -368,6 +376,7 @@ func run() error {
srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
+ srv.FingerprintSettings = fpSettings
srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{
Addr: cfg.Server.Address,
diff --git a/internal/api/admin_coverage.go b/internal/api/admin_coverage.go
index 3c5e0d58..7bbe259c 100644
--- a/internal/api/admin_coverage.go
+++ b/internal/api/admin_coverage.go
@@ -1,6 +1,7 @@
package api
import (
+ "context"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
@@ -40,12 +41,32 @@ func (h *handlers) handleGetLibraryCoverage(w http.ResponseWriter, r *http.Reque
// fingerprintCoverageResp is the wire shape for GET /api/admin/library/fingerprints.
// fingerprinted + rejected + pending = total. Missing tracks are not counted:
-// there is no file to fingerprint.
+// there is no file to fingerprint. Enabled travels with the counts because with
+// fingerprinting off (#3913) pending never shrinks, and a gauge that implies
+// progress would be promising work nothing is doing.
type fingerprintCoverageResp struct {
Total int64 `json:"total"`
Fingerprinted int64 `json:"fingerprinted"`
Rejected int64 `json:"rejected"`
Pending int64 `json:"pending"`
+ Enabled bool `json:"enabled"`
+}
+
+// fingerprintCoverage reads the gauge against the current settings: a print at
+// another length counts as pending, because the backfill will re-derive it.
+func (h *handlers) fingerprintCoverage(ctx context.Context) (fingerprintCoverageResp, error) {
+ cfg := h.fingerprintSettings.Get()
+ row, err := library.FingerprintCoverage(ctx, h.pool, cfg)
+ if err != nil {
+ return fingerprintCoverageResp{}, err
+ }
+ return fingerprintCoverageResp{
+ Total: row.Total,
+ Fingerprinted: row.Fingerprinted,
+ Rejected: row.Rejected,
+ Pending: row.Pending,
+ Enabled: cfg.Enabled,
+ }, nil
}
// handleGetFingerprintCoverage implements GET /api/admin/library/fingerprints:
@@ -53,15 +74,10 @@ type fingerprintCoverageResp struct {
// worker spanning many passes, with no scan run to attach a tally to, so its
// progress is read live here. Always 200; zeros on an empty library.
func (h *handlers) handleGetFingerprintCoverage(w http.ResponseWriter, r *http.Request) {
- row, err := library.FingerprintCoverage(r.Context(), h.pool)
+ cov, err := h.fingerprintCoverage(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin: get fingerprint coverage", apierror.InternalMsg("lookup failed", err))
return
}
- writeJSON(w, http.StatusOK, fingerprintCoverageResp{
- Total: row.Total,
- Fingerprinted: row.Fingerprinted,
- Rejected: row.Rejected,
- Pending: row.Pending,
- })
+ writeJSON(w, http.StatusOK, cov)
}
diff --git a/internal/api/admin_duplicates.go b/internal/api/admin_duplicates.go
index db319b3a..cbe2809d 100644
--- a/internal/api/admin_duplicates.go
+++ b/internal/api/admin_duplicates.go
@@ -95,7 +95,7 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
return
}
- cov, err := library.FingerprintCoverage(ctx, h.pool)
+ cov, err := h.fingerprintCoverage(ctx)
if err != nil {
h.logger.Error("admin: fingerprint coverage", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -117,14 +117,12 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
}
writeJSON(w, http.StatusOK, adminDuplicatesResponse{
- Sweep: sweep,
- Fingerprints: fingerprintCoverageResp{
- Total: cov.Total, Fingerprinted: cov.Fingerprinted, Rejected: cov.Rejected, Pending: cov.Pending,
- },
- Total: total,
- Limit: limit,
- Offset: offset,
- Groups: foldDuplicateGroups(rows),
+ Sweep: sweep,
+ Fingerprints: cov,
+ Total: total,
+ Limit: limit,
+ Offset: offset,
+ Groups: foldDuplicateGroups(rows),
})
}
@@ -194,8 +192,10 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
// The sweep outlives the request, so it runs on a background context, as
// handleTriggerScan's scan does.
func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Request) {
+ // Runs whatever the sweep interval says: the interval paces the automatic
+ // sweep, and an operator pressing the button has already decided.
started, err := library.TryStartDuplicateSweep(
- context.Background(), h.pool, h.logger.With("source", "manual"),
+ context.Background(), h.pool, h.logger.With("source", "manual"), h.fingerprintSettings.Get(),
)
if err != nil {
h.logger.Error("admin: start duplicate sweep", "err", err)
diff --git a/internal/api/admin_fingerprint_settings.go b/internal/api/admin_fingerprint_settings.go
new file mode 100644
index 00000000..bf049b68
--- /dev/null
+++ b/internal/api/admin_fingerprint_settings.go
@@ -0,0 +1,67 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+)
+
+// fingerprintSettingsBody is the wire shape for GET and PUT
+// /api/admin/library/fingerprint-settings (M400 #3913). The threshold travels as
+// the bit-error rate the matcher uses; the card presents it as a match percentage.
+type fingerprintSettingsBody struct {
+ Enabled bool `json:"enabled"`
+ ChromaprintLengthSec int32 `json:"chromaprint_length_sec"`
+ AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
+ BackfillConcurrency int32 `json:"backfill_concurrency"`
+ SweepIntervalHours int32 `json:"sweep_interval_hours"`
+}
+
+func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody {
+ return fingerprintSettingsBody{
+ Enabled: s.Enabled,
+ ChromaprintLengthSec: s.ChromaprintLengthSec,
+ AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate,
+ BackfillConcurrency: s.BackfillConcurrency,
+ SweepIntervalHours: s.SweepIntervalHours,
+ }
+}
+
+// handleGetFingerprintSettings implements GET /api/admin/library/fingerprint-settings.
+func (h *handlers) handleGetFingerprintSettings(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, fingerprintSettingsBodyOf(h.fingerprintSettings.Get()))
+}
+
+// handleUpdateFingerprintSettings implements PUT /api/admin/library/fingerprint-settings.
+//
+// A whole-row write. A body that leaves a field out decodes it as zero, which no
+// field accepts, so a partial save is refused rather than zeroing what it omitted.
+// The saved settings reach the scanner and both workers at once: they share the
+// service instance.
+func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
+ var req fingerprintSettingsBody
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
+ return
+ }
+ saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{
+ Enabled: req.Enabled,
+ ChromaprintLengthSec: req.ChromaprintLengthSec,
+ AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate,
+ BackfillConcurrency: req.BackfillConcurrency,
+ SweepIntervalHours: req.SweepIntervalHours,
+ })
+ if err != nil {
+ // Validation mirrors migration 0061's CHECKs and names the field.
+ if errors.Is(err, library.ErrFingerprintSettingOutOfRange) {
+ writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
+ return
+ }
+ writeErrWithLog(w, h.logger, "admin fingerprint settings: update failed", apierror.Internal(err))
+ return
+ }
+ writeJSON(w, http.StatusOK, fingerprintSettingsBodyOf(saved))
+}
diff --git a/internal/api/admin_fingerprint_settings_test.go b/internal/api/admin_fingerprint_settings_test.go
new file mode 100644
index 00000000..056447ea
--- /dev/null
+++ b/internal/api/admin_fingerprint_settings_test.go
@@ -0,0 +1,55 @@
+package api
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/library"
+)
+
+func TestGetFingerprintSettings_ServesDefaultsWithoutAService(t *testing.T) {
+ h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ rec := httptest.NewRecorder()
+ h.handleGetFingerprintSettings(rec, httptest.NewRequest(http.MethodGet, "/api/admin/library/fingerprint-settings", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ var got fingerprintSettingsBody
+ if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings); got != want {
+ t.Fatalf("body = %+v, want the defaults %+v", got, want)
+ }
+}
+
+func TestUpdateFingerprintSettings_Rejects(t *testing.T) {
+ h := &handlers{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ for name, tc := range map[string]struct {
+ body string
+ code string
+ mentions string
+ }{
+ "a value out of range, naming the field": {
+ body: `{"enabled":true,"chromaprint_length_sec":5,"acoustic_max_bit_error_rate":0.15,"backfill_concurrency":2,"sweep_interval_hours":1}`,
+ code: "invalid_setting",
+ mentions: "chromaprint_length_sec",
+ },
+ // A partial body would otherwise zero every field it left out.
+ "a body missing fields": {body: `{"enabled":false}`, code: "invalid_setting"},
+ "malformed JSON": {body: `{"enabled":`, code: "invalid_body"},
+ } {
+ rec := httptest.NewRecorder()
+ h.handleUpdateFingerprintSettings(rec, httptest.NewRequest(
+ http.MethodPut, "/api/admin/library/fingerprint-settings", strings.NewReader(tc.body)))
+ body := rec.Body.String()
+ if rec.Code != http.StatusBadRequest || !strings.Contains(body, `"`+tc.code+`"`) || !strings.Contains(body, tc.mentions) {
+ t.Errorf("%s: status %d body %s; want 400 %s mentioning %q", name, rec.Code, body, tc.code, tc.mentions)
+ }
+ }
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index 85647a5c..6f21467c 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -33,30 +33,31 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
-func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService) {
+func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
- recSettings: recSettings,
- rng: rng.Float64,
- lidarrCfg: lidarrCfg,
- lidarrRequests: lidarrReqs,
- lidarrQuarantine: lidarrQuar,
- tracks: tracksSvc,
- playlists: playlistsSvc,
- coverart: coverEnricher,
- coverSettings: coverSettings,
- tagSettings: tagSettings,
- scanner: scanner,
- scanCfg: scanCfg,
- dataDir: dataDir,
- mailer: sender,
- eventbus: bus,
- playlistScheduler: playlistScheduler,
- streamSecret: streamSecret,
- netSettings: netSettings,
- reacqSettings: reacqSettings,
- librarySize: recommendation.NewLibrarySize(nil),
+ recSettings: recSettings,
+ rng: rng.Float64,
+ lidarrCfg: lidarrCfg,
+ lidarrRequests: lidarrReqs,
+ lidarrQuarantine: lidarrQuar,
+ tracks: tracksSvc,
+ playlists: playlistsSvc,
+ coverart: coverEnricher,
+ coverSettings: coverSettings,
+ tagSettings: tagSettings,
+ scanner: scanner,
+ scanCfg: scanCfg,
+ dataDir: dataDir,
+ mailer: sender,
+ eventbus: bus,
+ playlistScheduler: playlistScheduler,
+ streamSecret: streamSecret,
+ netSettings: netSettings,
+ reacqSettings: reacqSettings,
+ fingerprintSettings: fpSettings,
+ librarySize: recommendation.NewLibrarySize(nil),
}
r.Route("/api", func(api chi.Router) {
@@ -216,6 +217,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
+ admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
+ admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
// Duplicates report (#3912): proposals from the duplicate sweep, a
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
// the removed copies' files after moving their history onto the kept one.
@@ -306,6 +309,10 @@ type handlers struct {
// missing files (milestone #290) — grace window, backoff, attempt caps.
// Cached in the service, so the admin card reads it without a query.
reacqSettings *reacquisition.SettingsService
+ // fingerprintSettings is the fingerprinting policy (M400 #3913), the same
+ // instance the scanner and the fingerprint workers read, so a save from the
+ // admin card reaches them without a restart. Nil serves the defaults.
+ fingerprintSettings *library.FingerprintSettingsService
// netSettings caches the trusted reverse-proxy depth read by the auth
// middleware on every request and edited from the admin network card.
netSettings *netsettings.Service
diff --git a/internal/db/dbq/duplicates.sql.go b/internal/db/dbq/duplicates.sql.go
index 0f979e34..c3dab489 100644
--- a/internal/db/dbq/duplicates.sql.go
+++ b/internal/db/dbq/duplicates.sql.go
@@ -211,16 +211,21 @@ SELECT t.id, t.duration_ms, f.audio_stream_sha256, f.chromaprint
WHERE t.missing_since IS NULL
AND f.fingerprint_version >= $1
AND f.chromaprint IS NOT NULL
- AND (t.duration_ms, t.id) > ($2::integer, $3::uuid)
+ -- Only chromaprints taken at the current length: prints at two lengths are not
+ -- comparable, and after a length change the backfill is still re-deriving the
+ -- rest (#3913).
+ AND f.chromaprint_length_sec = $2
+ AND (t.duration_ms, t.id) > ($3::integer, $4::uuid)
ORDER BY t.duration_ms, t.id
- LIMIT $4
+ LIMIT $5
`
type ListDuplicateCandidatesParams struct {
- CurrentVersion int16
- AfterDurationMs int32
- AfterID pgtype.UUID
- PageLimit int32
+ CurrentVersion int16
+ ChromaprintLengthSec int32
+ AfterDurationMs int32
+ AfterID pgtype.UUID
+ PageLimit int32
}
type ListDuplicateCandidatesRow struct {
@@ -237,6 +242,7 @@ type ListDuplicateCandidatesRow struct {
func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicateCandidatesParams) ([]ListDuplicateCandidatesRow, error) {
rows, err := q.db.Query(ctx, listDuplicateCandidates,
arg.CurrentVersion,
+ arg.ChromaprintLengthSec,
arg.AfterDurationMs,
arg.AfterID,
arg.PageLimit,
diff --git a/internal/db/dbq/fingerprint_settings.sql.go b/internal/db/dbq/fingerprint_settings.sql.go
new file mode 100644
index 00000000..ab1ab634
--- /dev/null
+++ b/internal/db/dbq/fingerprint_settings.sql.go
@@ -0,0 +1,72 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.31.1
+// source: fingerprint_settings.sql
+
+package dbq
+
+import (
+ "context"
+)
+
+const getFingerprintSettings = `-- name: GetFingerprintSettings :one
+SELECT id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at FROM fingerprint_settings WHERE id = true
+`
+
+func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSetting, error) {
+ row := q.db.QueryRow(ctx, getFingerprintSettings)
+ var i FingerprintSetting
+ err := row.Scan(
+ &i.ID,
+ &i.Enabled,
+ &i.ChromaprintLengthSec,
+ &i.AcousticMaxBitErrorRate,
+ &i.BackfillConcurrency,
+ &i.SweepIntervalHours,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const updateFingerprintSettings = `-- name: UpdateFingerprintSettings :one
+UPDATE fingerprint_settings
+ SET enabled = $1,
+ chromaprint_length_sec = $2,
+ acoustic_max_bit_error_rate = $3,
+ backfill_concurrency = $4,
+ sweep_interval_hours = $5,
+ updated_at = now()
+ WHERE id = true
+RETURNING id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at
+`
+
+type UpdateFingerprintSettingsParams struct {
+ Enabled bool
+ ChromaprintLengthSec int32
+ AcousticMaxBitErrorRate float64
+ BackfillConcurrency int32
+ SweepIntervalHours int32
+}
+
+// Whole-row write from the admin card; migration 0061's CHECKs are the backstop
+// behind the service's own validation.
+func (q *Queries) UpdateFingerprintSettings(ctx context.Context, arg UpdateFingerprintSettingsParams) (FingerprintSetting, error) {
+ row := q.db.QueryRow(ctx, updateFingerprintSettings,
+ arg.Enabled,
+ arg.ChromaprintLengthSec,
+ arg.AcousticMaxBitErrorRate,
+ arg.BackfillConcurrency,
+ arg.SweepIntervalHours,
+ )
+ var i FingerprintSetting
+ err := row.Scan(
+ &i.ID,
+ &i.Enabled,
+ &i.ChromaprintLengthSec,
+ &i.AcousticMaxBitErrorRate,
+ &i.BackfillConcurrency,
+ &i.SweepIntervalHours,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/db/dbq/fingerprints.sql.go b/internal/db/dbq/fingerprints.sql.go
index ce1bdf03..f92f0cb2 100644
--- a/internal/db/dbq/fingerprints.sql.go
+++ b/internal/db/dbq/fingerprints.sql.go
@@ -27,20 +27,29 @@ const getFingerprintCoverage = `-- name: GetFingerprintCoverage :one
SELECT count(*)::bigint AS total,
count(*) FILTER (
WHERE f.fingerprint_version >= $1
+ AND f.chromaprint_length_sec = $2
AND f.audio_stream_sha256 IS NOT NULL AND f.chromaprint IS NOT NULL
)::bigint AS fingerprinted,
count(*) FILTER (
WHERE f.fingerprint_version >= $1
+ AND f.chromaprint_length_sec = $2
AND (f.audio_stream_sha256 IS NULL OR f.chromaprint IS NULL)
)::bigint AS rejected,
count(*) FILTER (
- WHERE f.track_id IS NULL OR f.fingerprint_version < $1
+ WHERE f.track_id IS NULL
+ OR f.fingerprint_version < $1
+ OR f.chromaprint_length_sec <> $2
)::bigint AS pending
FROM tracks t
LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL
`
+type GetFingerprintCoverageParams struct {
+ CurrentVersion int16
+ ChromaprintLengthSec int32
+}
+
type GetFingerprintCoverageRow struct {
Total int64
Fingerprinted int64
@@ -49,11 +58,13 @@ type GetFingerprintCoverageRow struct {
}
// The admin gauge for the backfill. fingerprinted + rejected + pending = total.
-// rejected is a row at the current version with a NULL half: a tool ran and
-// refused the file, which is settled rather than waiting. Missing tracks are
-// excluded, or the gauge could never reach the end.
-func (q *Queries) GetFingerprintCoverage(ctx context.Context, currentVersion int16) (GetFingerprintCoverageRow, error) {
- row := q.db.QueryRow(ctx, getFingerprintCoverage, currentVersion)
+// "Current" means derived by the current method AT the current length: a row at
+// another length is pending, because the backfill will re-derive it. rejected is
+// a current row with a NULL half: a tool ran and refused the file, which is
+// settled rather than waiting. Missing tracks are excluded, or the gauge could
+// never reach the end.
+func (q *Queries) GetFingerprintCoverage(ctx context.Context, arg GetFingerprintCoverageParams) (GetFingerprintCoverageRow, error) {
+ row := q.db.QueryRow(ctx, getFingerprintCoverage, arg.CurrentVersion, arg.ChromaprintLengthSec)
var i GetFingerprintCoverageRow
err := row.Scan(
&i.Total,
@@ -69,16 +80,21 @@ SELECT t.id, t.file_path
FROM tracks t
LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL
- AND (f.track_id IS NULL OR f.fingerprint_version < $1)
- AND t.id > $2
+ -- A row taken at another length is as stale as one from an older method:
+ -- chromaprints at two lengths cannot be compared (#3913).
+ AND (f.track_id IS NULL
+ OR f.fingerprint_version < $1
+ OR f.chromaprint_length_sec <> $2)
+ AND t.id > $3
ORDER BY t.id
- LIMIT $3
+ LIMIT $4
`
type ListTracksNeedingFingerprintParams struct {
- CurrentVersion int16
- AfterID pgtype.UUID
- BatchLimit int32
+ CurrentVersion int16
+ ChromaprintLengthSec int32
+ AfterID pgtype.UUID
+ BatchLimit int32
}
type ListTracksNeedingFingerprintRow struct {
@@ -93,7 +109,12 @@ type ListTracksNeedingFingerprintRow struct {
// and retried in a tight loop. Missing tracks are skipped — there is no file to
// read.
func (q *Queries) ListTracksNeedingFingerprint(ctx context.Context, arg ListTracksNeedingFingerprintParams) ([]ListTracksNeedingFingerprintRow, error) {
- rows, err := q.db.Query(ctx, listTracksNeedingFingerprint, arg.CurrentVersion, arg.AfterID, arg.BatchLimit)
+ rows, err := q.db.Query(ctx, listTracksNeedingFingerprint,
+ arg.CurrentVersion,
+ arg.ChromaprintLengthSec,
+ arg.AfterID,
+ arg.BatchLimit,
+ )
if err != nil {
return nil, err
}
@@ -114,23 +135,25 @@ func (q *Queries) ListTracksNeedingFingerprint(ctx context.Context, arg ListTrac
const upsertTrackFingerprint = `-- name: UpsertTrackFingerprint :exec
INSERT INTO track_fingerprints (
- track_id, audio_stream_sha256, chromaprint, fingerprint_version
+ track_id, audio_stream_sha256, chromaprint, fingerprint_version, chromaprint_length_sec
) VALUES (
$1, $2, $3,
- $4
+ $4, $5
)
ON CONFLICT (track_id) DO UPDATE SET
- audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
- chromaprint = EXCLUDED.chromaprint,
- fingerprint_version = EXCLUDED.fingerprint_version,
- computed_at = now()
+ audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
+ chromaprint = EXCLUDED.chromaprint,
+ fingerprint_version = EXCLUDED.fingerprint_version,
+ chromaprint_length_sec = EXCLUDED.chromaprint_length_sec,
+ computed_at = now()
`
type UpsertTrackFingerprintParams struct {
- TrackID pgtype.UUID
- AudioStreamSha256 []byte
- Chromaprint []int32
- FingerprintVersion int16
+ TrackID pgtype.UUID
+ AudioStreamSha256 []byte
+ Chromaprint []int32
+ FingerprintVersion int16
+ ChromaprintLengthSec int32
}
// Written whenever a track's fingerprint is derived: by the scan when a file is
@@ -143,6 +166,7 @@ func (q *Queries) UpsertTrackFingerprint(ctx context.Context, arg UpsertTrackFin
arg.AudioStreamSha256,
arg.Chromaprint,
arg.FingerprintVersion,
+ arg.ChromaprintLengthSec,
)
return err
}
diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go
index cddadd0c..17a9c89d 100644
--- a/internal/db/dbq/models.go
+++ b/internal/db/dbq/models.go
@@ -323,6 +323,16 @@ type DuplicateSweep struct {
ErrorMessage *string
}
+type FingerprintSetting struct {
+ ID bool
+ Enabled bool
+ ChromaprintLengthSec int32
+ AcousticMaxBitErrorRate float64
+ BackfillConcurrency int32
+ SweepIntervalHours int32
+ UpdatedAt pgtype.Timestamptz
+}
+
type GeneralLike struct {
UserID pgtype.UUID
TrackID pgtype.UUID
@@ -694,11 +704,12 @@ type Track struct {
}
type TrackFingerprint struct {
- TrackID pgtype.UUID
- AudioStreamSha256 []byte
- Chromaprint []int32
- FingerprintVersion int16
- ComputedAt pgtype.Timestamptz
+ TrackID pgtype.UUID
+ AudioStreamSha256 []byte
+ Chromaprint []int32
+ FingerprintVersion int16
+ ComputedAt pgtype.Timestamptz
+ ChromaprintLengthSec int32
}
type TrackSimilarity struct {
diff --git a/internal/db/migrations/0061_fingerprint_settings.down.sql b/internal/db/migrations/0061_fingerprint_settings.down.sql
new file mode 100644
index 00000000..31d6d9cc
--- /dev/null
+++ b/internal/db/migrations/0061_fingerprint_settings.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE track_fingerprints DROP COLUMN chromaprint_length_sec;
+DROP TABLE fingerprint_settings;
diff --git a/internal/db/migrations/0061_fingerprint_settings.up.sql b/internal/db/migrations/0061_fingerprint_settings.up.sql
new file mode 100644
index 00000000..021f85af
--- /dev/null
+++ b/internal/db/migrations/0061_fingerprint_settings.up.sql
@@ -0,0 +1,50 @@
+-- 0061_fingerprint_settings.up.sql — fingerprinting's knobs, in admin Settings
+-- (Scribe #3913, milestone #400). Rule 25: anything an operator might tune is a
+-- database row, changed without a restart. Singleton in the style of
+-- reacquisition_settings (0056).
+CREATE TABLE fingerprint_settings (
+ id boolean PRIMARY KEY DEFAULT true,
+
+ -- Fingerprinting new files, the backfill, and the duplicate sweep. Off stops
+ -- the decode work entirely — the reason to turn it off is a slow NAS, and
+ -- that is the operator's call. On by default: a library that cannot tell its
+ -- duplicates apart is what milestone #400 exists to end.
+ enabled boolean NOT NULL DEFAULT true,
+
+ -- Seconds of audio fpcalc fingerprints. Chromaprints taken at different
+ -- lengths cannot be compared, which is why track_fingerprints records the
+ -- length each row was taken at (below): change this and every chromaprint is
+ -- re-derived, and until then only rows at the new length are compared.
+ chromaprint_length_sec integer NOT NULL DEFAULT 120,
+
+ -- The most disagreement two aligned fingerprints may show and still be
+ -- proposed as one recording. Unrelated audio sits near 0.5, so the ceiling
+ -- stays well clear of it.
+ acoustic_max_bit_error_rate double precision NOT NULL DEFAULT 0.15,
+
+ -- Files the backfill decodes at once. Decoding competes with playback
+ -- transcoding for CPU and with streaming for the mount.
+ backfill_concurrency integer NOT NULL DEFAULT 2,
+
+ -- The least time between duplicate sweeps. A sweep still runs only when
+ -- fingerprints have changed since the last one.
+ sweep_interval_hours integer NOT NULL DEFAULT 1,
+
+ -- When the settings were last saved. A new threshold or length can change
+ -- what a sweep finds, so a save makes a sweep due.
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ CONSTRAINT fingerprint_settings_singleton CHECK (id = true),
+ CONSTRAINT fingerprint_settings_length_range
+ CHECK (chromaprint_length_sec >= 30 AND chromaprint_length_sec <= 600),
+ CONSTRAINT fingerprint_settings_threshold_range
+ CHECK (acoustic_max_bit_error_rate >= 0.01 AND acoustic_max_bit_error_rate <= 0.35),
+ CONSTRAINT fingerprint_settings_concurrency_range
+ CHECK (backfill_concurrency >= 1 AND backfill_concurrency <= 8),
+ CONSTRAINT fingerprint_settings_sweep_interval_range
+ CHECK (sweep_interval_hours >= 1 AND sweep_interval_hours <= 168)
+);
+INSERT INTO fingerprint_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
+
+-- Every row written so far was taken at fpcalc's default length.
+ALTER TABLE track_fingerprints ADD COLUMN chromaprint_length_sec integer NOT NULL DEFAULT 120;
diff --git a/internal/db/queries/duplicates.sql b/internal/db/queries/duplicates.sql
index 22c3b1fe..9150ccc5 100644
--- a/internal/db/queries/duplicates.sql
+++ b/internal/db/queries/duplicates.sql
@@ -53,6 +53,10 @@ SELECT t.id, t.duration_ms, f.audio_stream_sha256, f.chromaprint
WHERE t.missing_since IS NULL
AND f.fingerprint_version >= sqlc.arg(current_version)
AND f.chromaprint IS NOT NULL
+ -- Only chromaprints taken at the current length: prints at two lengths are not
+ -- comparable, and after a length change the backfill is still re-deriving the
+ -- rest (#3913).
+ AND f.chromaprint_length_sec = sqlc.arg(chromaprint_length_sec)
AND (t.duration_ms, t.id) > (sqlc.arg(after_duration_ms)::integer, sqlc.arg(after_id)::uuid)
ORDER BY t.duration_ms, t.id
LIMIT sqlc.arg(page_limit);
diff --git a/internal/db/queries/fingerprint_settings.sql b/internal/db/queries/fingerprint_settings.sql
new file mode 100644
index 00000000..f1022bb1
--- /dev/null
+++ b/internal/db/queries/fingerprint_settings.sql
@@ -0,0 +1,15 @@
+-- name: GetFingerprintSettings :one
+SELECT * FROM fingerprint_settings WHERE id = true;
+
+-- name: UpdateFingerprintSettings :one
+-- Whole-row write from the admin card; migration 0061's CHECKs are the backstop
+-- behind the service's own validation.
+UPDATE fingerprint_settings
+ SET enabled = sqlc.arg(enabled),
+ chromaprint_length_sec = sqlc.arg(chromaprint_length_sec),
+ acoustic_max_bit_error_rate = sqlc.arg(acoustic_max_bit_error_rate),
+ backfill_concurrency = sqlc.arg(backfill_concurrency),
+ sweep_interval_hours = sqlc.arg(sweep_interval_hours),
+ updated_at = now()
+ WHERE id = true
+RETURNING *;
diff --git a/internal/db/queries/fingerprints.sql b/internal/db/queries/fingerprints.sql
index 90a8a978..ae68f233 100644
--- a/internal/db/queries/fingerprints.sql
+++ b/internal/db/queries/fingerprints.sql
@@ -4,16 +4,17 @@
-- older method. Replaces the row wholesale — a fingerprint of the old bytes has
-- no standing once the file has changed.
INSERT INTO track_fingerprints (
- track_id, audio_stream_sha256, chromaprint, fingerprint_version
+ track_id, audio_stream_sha256, chromaprint, fingerprint_version, chromaprint_length_sec
) VALUES (
sqlc.arg(track_id), sqlc.narg(audio_stream_sha256), sqlc.narg(chromaprint),
- sqlc.arg(fingerprint_version)
+ sqlc.arg(fingerprint_version), sqlc.arg(chromaprint_length_sec)
)
ON CONFLICT (track_id) DO UPDATE SET
- audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
- chromaprint = EXCLUDED.chromaprint,
- fingerprint_version = EXCLUDED.fingerprint_version,
- computed_at = now();
+ audio_stream_sha256 = EXCLUDED.audio_stream_sha256,
+ chromaprint = EXCLUDED.chromaprint,
+ fingerprint_version = EXCLUDED.fingerprint_version,
+ chromaprint_length_sec = EXCLUDED.chromaprint_length_sec,
+ computed_at = now();
-- name: DeleteTrackFingerprint :exec
-- A file changed but could not be fingerprinted, for a reason unrelated to the
@@ -32,27 +33,37 @@ SELECT t.id, t.file_path
FROM tracks t
LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL
- AND (f.track_id IS NULL OR f.fingerprint_version < sqlc.arg(current_version))
+ -- A row taken at another length is as stale as one from an older method:
+ -- chromaprints at two lengths cannot be compared (#3913).
+ AND (f.track_id IS NULL
+ OR f.fingerprint_version < sqlc.arg(current_version)
+ OR f.chromaprint_length_sec <> sqlc.arg(chromaprint_length_sec))
AND t.id > sqlc.arg(after_id)
ORDER BY t.id
LIMIT sqlc.arg(batch_limit);
-- name: GetFingerprintCoverage :one
-- The admin gauge for the backfill. fingerprinted + rejected + pending = total.
--- rejected is a row at the current version with a NULL half: a tool ran and
--- refused the file, which is settled rather than waiting. Missing tracks are
--- excluded, or the gauge could never reach the end.
+-- "Current" means derived by the current method AT the current length: a row at
+-- another length is pending, because the backfill will re-derive it. rejected is
+-- a current row with a NULL half: a tool ran and refused the file, which is
+-- settled rather than waiting. Missing tracks are excluded, or the gauge could
+-- never reach the end.
SELECT count(*)::bigint AS total,
count(*) FILTER (
WHERE f.fingerprint_version >= sqlc.arg(current_version)
+ AND f.chromaprint_length_sec = sqlc.arg(chromaprint_length_sec)
AND f.audio_stream_sha256 IS NOT NULL AND f.chromaprint IS NOT NULL
)::bigint AS fingerprinted,
count(*) FILTER (
WHERE f.fingerprint_version >= sqlc.arg(current_version)
+ AND f.chromaprint_length_sec = sqlc.arg(chromaprint_length_sec)
AND (f.audio_stream_sha256 IS NULL OR f.chromaprint IS NULL)
)::bigint AS rejected,
count(*) FILTER (
- WHERE f.track_id IS NULL OR f.fingerprint_version < sqlc.arg(current_version)
+ WHERE f.track_id IS NULL
+ OR f.fingerprint_version < sqlc.arg(current_version)
+ OR f.chromaprint_length_sec <> sqlc.arg(chromaprint_length_sec)
)::bigint AS pending
FROM tracks t
LEFT JOIN track_fingerprints f ON f.track_id = t.id
diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go
index 25e0b076..a5b2380e 100644
--- a/internal/dbtest/reset.go
+++ b/internal/dbtest/reset.go
@@ -130,4 +130,15 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
+ // Fingerprinting settings (M400 #3913), a singleton like the counters above.
+ // Every column goes back to its migration default rather than to literals
+ // written here, so a test can pin the Go defaults to the migration's.
+ if _, err := pool.Exec(ctx, `
+ UPDATE fingerprint_settings
+ SET enabled = DEFAULT, chromaprint_length_sec = DEFAULT,
+ acoustic_max_bit_error_rate = DEFAULT, backfill_concurrency = DEFAULT,
+ sweep_interval_hours = DEFAULT, updated_at = DEFAULT`,
+ ); err != nil {
+ t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
+ }
}
diff --git a/internal/library/duplicate_sweep.go b/internal/library/duplicate_sweep.go
index f1c3cf0f..34dd2cb8 100644
--- a/internal/library/duplicate_sweep.go
+++ b/internal/library/duplicate_sweep.go
@@ -32,10 +32,17 @@ import (
// carries a ~4 KB fingerprint, so a page is about 2 MB.
const duplicateCandidatePage = 500
-// duplicateSweepTick is how often the worker checks for anything new to sweep.
-// With nothing new, a tick is two cheap aggregate queries.
+// duplicateSweepTick is how often the worker checks whether a sweep is due. The
+// operator's sweep interval (#3913) is the least time between sweeps; the tick
+// only bounds how late past it one starts. With nothing due, a tick is two cheap
+// aggregate queries.
const duplicateSweepTick = time.Hour
+// sweepIntervalSlack absorbs the moment between a tick and the sweep it starts
+// stamping started_at. Without it a one-hour interval checked on a one-hour tick
+// would find the last sweep a moment under an hour old, and skip every other tick.
+const sweepIntervalSlack = 5 * time.Minute
+
// staleDuplicateSweepThreshold is the age past which an in-flight sweep is
// assumed dead — a crash mid-sweep leaves finished_at NULL for ever — and another
// may start. Twice the library scan's threshold, because a sweep compares
@@ -58,13 +65,16 @@ type DuplicateSweepResult struct {
Oversize int // acoustic clusters too large to propose
}
-// RunDuplicateSweep runs one sweep and records it in duplicate_sweeps.
-func RunDuplicateSweep(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (DuplicateSweepResult, error) {
- return runDuplicateSweep(ctx, pool, logger, duplicateCandidatePage)
+// RunDuplicateSweep runs one sweep and records it in duplicate_sweeps. cfg is a
+// snapshot: one sweep applies one threshold and one length throughout.
+func RunDuplicateSweep(
+ ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, cfg FingerprintSettings,
+) (DuplicateSweepResult, error) {
+ return runDuplicateSweep(ctx, pool, logger, cfg, duplicateCandidatePage)
}
func runDuplicateSweep(
- ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, pageSize int32,
+ ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, cfg FingerprintSettings, pageSize int32,
) (DuplicateSweepResult, error) {
q := dbq.New(pool)
sweep, err := q.StartDuplicateSweep(ctx)
@@ -72,7 +82,7 @@ func runDuplicateSweep(
return DuplicateSweepResult{}, fmt.Errorf("start duplicate sweep: %w", err)
}
- res, runErr := sweepDuplicates(ctx, q, sweep.ID, pageSize)
+ res, runErr := sweepDuplicates(ctx, q, sweep.ID, cfg, pageSize)
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), duplicateSweepFinishTimeout)
defer cancel()
@@ -98,7 +108,7 @@ func runDuplicateSweep(
}
func sweepDuplicates(
- ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, pageSize int32,
+ ctx context.Context, q *dbq.Queries, sweepID pgtype.UUID, cfg FingerprintSettings, pageSize int32,
) (DuplicateSweepResult, error) {
var res DuplicateSweepResult
@@ -119,9 +129,12 @@ func sweepDuplicates(
// Acoustic tier, streamed in duration order. The first member of an exact
// group the stream meets stands in for the whole group; the rest are skipped.
- grouper := newStreamGrouper(defaultAcousticMaxBitErrorRate)
+ // Only prints at the current length are streamed: a print at another length
+ // cannot be compared, and is waiting on the backfill to be re-derived.
+ grouper := newStreamGrouper(cfg.AcousticMaxBitErrorRate)
params := dbq.ListDuplicateCandidatesParams{
- CurrentVersion: fingerprintVersion,
+ CurrentVersion: fingerprintVersion,
+ ChromaprintLengthSec: cfg.ChromaprintLengthSec,
// Durations are never negative, and the all-zero uuid sorts first: every
// row is after this cursor. Valid must be true, or "> NULL" matches nothing.
AfterDurationMs: -1,
@@ -262,7 +275,9 @@ func formatUUIDs(ids []pgtype.UUID) []string {
// running, reaping a sweep that has been in flight past
// staleDuplicateSweepThreshold. Mirrors TryStartScan. The sweep runs on ctx, so
// a caller answering an HTTP request must pass a context that outlives it.
-func TryStartDuplicateSweep(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (bool, error) {
+func TryStartDuplicateSweep(
+ ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, cfg FingerprintSettings,
+) (bool, error) {
q := dbq.New(pool)
row, err := q.GetInFlightDuplicateSweep(ctx)
switch {
@@ -282,23 +297,28 @@ func TryStartDuplicateSweep(ctx context.Context, pool *pgxpool.Pool, logger *slo
}
go func() {
- if _, err := RunDuplicateSweep(ctx, pool, logger); err != nil {
+ if _, err := RunDuplicateSweep(ctx, pool, logger, cfg); err != nil {
logger.Warn("duplicate sweep failed", "err", err)
}
}()
return true, nil
}
-// DuplicateSweepWorker sweeps whenever fingerprints have changed.
+// DuplicateSweepWorker sweeps whenever its input has changed, at most once per
+// the operator's sweep interval.
type DuplicateSweepWorker struct {
- pool *pgxpool.Pool
- logger *slog.Logger
- tick time.Duration
+ pool *pgxpool.Pool
+ logger *slog.Logger
+ settings *FingerprintSettingsService
+ tick time.Duration
}
-// NewDuplicateSweepWorker builds a worker with the production cadence.
-func NewDuplicateSweepWorker(pool *pgxpool.Pool, logger *slog.Logger) *DuplicateSweepWorker {
- return &DuplicateSweepWorker{pool: pool, logger: logger, tick: duplicateSweepTick}
+// NewDuplicateSweepWorker builds a worker with the production cadence. settings
+// is shared with the admin API; nil runs on defaults.
+func NewDuplicateSweepWorker(
+ pool *pgxpool.Pool, logger *slog.Logger, settings *FingerprintSettingsService,
+) *DuplicateSweepWorker {
+ return &DuplicateSweepWorker{pool: pool, logger: logger, settings: settings, tick: duplicateSweepTick}
}
// Run blocks until ctx is cancelled, checking once at start and then each tick.
@@ -323,7 +343,8 @@ func (w *DuplicateSweepWorker) tickOnce(ctx context.Context) {
w.logger.Error("duplicate sweep: tick panicked", "panic", r)
}
}()
- due, err := duplicateSweepDue(ctx, dbq.New(w.pool))
+ cfg := w.settings.Get()
+ due, err := duplicateSweepDue(ctx, dbq.New(w.pool), cfg, time.Now())
if err != nil {
if ctx.Err() == nil {
w.logger.Warn("duplicate sweep: due check failed", "err", err)
@@ -333,28 +354,45 @@ func (w *DuplicateSweepWorker) tickOnce(ctx context.Context) {
if !due {
return
}
- if _, err := TryStartDuplicateSweep(ctx, w.pool, w.logger); err != nil {
+ if _, err := TryStartDuplicateSweep(ctx, w.pool, w.logger, cfg); err != nil {
w.logger.Warn("duplicate sweep: start failed", "err", err)
}
}
-// duplicateSweepDue reports whether any fingerprint was written after the latest
-// sweep started. Fingerprints are the sweep's only input, so nothing else can
-// change its answer; while the backfill is running this is true every tick.
-func duplicateSweepDue(ctx context.Context, q *dbq.Queries) (bool, error) {
+// duplicateSweepDue reads what sweepIsDue decides on.
+func duplicateSweepDue(ctx context.Context, q *dbq.Queries, cfg FingerprintSettings, now time.Time) (bool, error) {
latest, err := q.GetLatestFingerprintComputedAt(ctx)
if err != nil {
return false, fmt.Errorf("latest fingerprint: %w", err)
}
- if !latest.Valid {
- return false, nil // nothing fingerprinted yet
- }
+ var lastStart pgtype.Timestamptz
last, err := q.GetLatestDuplicateSweep(ctx)
- if errors.Is(err, pgx.ErrNoRows) {
- return true, nil
- }
- if err != nil {
+ switch {
+ case err == nil:
+ lastStart = last.StartedAt
+ case !errors.Is(err, pgx.ErrNoRows):
return false, fmt.Errorf("latest duplicate sweep: %w", err)
}
- return latest.Time.After(last.StartedAt.Time), nil
+ return sweepIsDue(latest, lastStart, cfg, now), nil
+}
+
+// sweepIsDue reports whether a sweep should start: something it reads has
+// changed since the last sweep started, and the operator's interval has passed.
+//
+// Two things can change its answer. Fingerprints are its input, so any written
+// after the last sweep began count; while the backfill runs that is true every
+// tick, which is what the interval is for. And a settings save counts, because a
+// new threshold or length changes what the same fingerprints group into.
+func sweepIsDue(latestFingerprint, lastSweepStart pgtype.Timestamptz, cfg FingerprintSettings, now time.Time) bool {
+ if !latestFingerprint.Valid {
+ return false // nothing fingerprinted yet
+ }
+ if !lastSweepStart.Valid {
+ return true // never swept
+ }
+ interval := time.Duration(cfg.SweepIntervalHours) * time.Hour
+ if now.Sub(lastSweepStart.Time) < interval-sweepIntervalSlack {
+ return false
+ }
+ return latestFingerprint.Time.After(lastSweepStart.Time) || cfg.UpdatedAt.After(lastSweepStart.Time)
}
diff --git a/internal/library/duplicate_sweep_test.go b/internal/library/duplicate_sweep_test.go
index 9f30c778..15bc478b 100644
--- a/internal/library/duplicate_sweep_test.go
+++ b/internal/library/duplicate_sweep_test.go
@@ -9,6 +9,9 @@ import (
"sort"
"strings"
"testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
@@ -58,6 +61,7 @@ func TestDuplicateSweep_Integration(t *testing.T) {
}
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
TrackID: tr.ID, AudioStreamSha256: sum, Chromaprint: print, FingerprintVersion: fingerprintVersion,
+ ChromaprintLengthSec: defaultChromaprintLengthSec,
}); err != nil {
t.Fatalf("fingerprint %s: %v", name, err)
}
@@ -105,7 +109,7 @@ func TestDuplicateSweep_Integration(t *testing.T) {
}
// 1. A page size of one forces the keyset cursor across every candidate.
- res, err := runDuplicateSweep(ctx, pool, logger, 1)
+ res, err := runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, 1)
if err != nil {
t.Fatalf("first sweep: %v", err)
}
@@ -133,7 +137,7 @@ func TestDuplicateSweep_Integration(t *testing.T) {
if _, err := pool.Exec(ctx, "UPDATE duplicate_groups SET status = 'dismissed' WHERE member_key = $1", acousticKey); err != nil {
t.Fatalf("dismiss: %v", err)
}
- res, err = runDuplicateSweep(ctx, pool, logger, duplicateCandidatePage)
+ res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage)
if err != nil {
t.Fatalf("second sweep: %v", err)
}
@@ -150,7 +154,7 @@ func TestDuplicateSweep_Integration(t *testing.T) {
"DELETE FROM track_fingerprints f USING tracks t WHERE f.track_id = t.id AND t.file_path LIKE '%www-02.mp3'"); err != nil {
t.Fatalf("drop fingerprint: %v", err)
}
- res, err = runDuplicateSweep(ctx, pool, logger, duplicateCandidatePage)
+ res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage)
if err != nil {
t.Fatalf("third sweep: %v", err)
}
@@ -170,4 +174,77 @@ func TestDuplicateSweep_Integration(t *testing.T) {
if !last.FinishedAt.Valid || last.ErrorMessage != nil {
t.Fatalf("latest sweep = %+v, want finished without error", last)
}
+
+ // 5. Prints taken at another length are never compared (#3913). Every print
+ // here was taken at the default length, so a sweep at 60s has nothing to read,
+ // rather than scoring 120s prints against each other as if they were 60s ones.
+ atOtherLength := DefaultFingerprintSettings
+ atOtherLength.ChromaprintLengthSec = 60
+ res, err = runDuplicateSweep(ctx, pool, logger, atOtherLength, duplicateCandidatePage)
+ if err != nil {
+ t.Fatalf("sweep at another length: %v", err)
+ }
+ if res.Candidates != 0 || res.Groups != 0 {
+ t.Fatalf("sweep at another length = %+v, want no candidates and no groups", res)
+ }
+
+ // 6. The sweep applies the threshold it is given. The recording's two copies
+ // disagree on about 5% of their bits: grouped at the default, not at 1%.
+ if _, err := pool.Exec(ctx, "DELETE FROM duplicate_groups"); err != nil {
+ t.Fatalf("clear groups: %v", err)
+ }
+ strict := DefaultFingerprintSettings
+ strict.AcousticMaxBitErrorRate = 0.01
+ res, err = runDuplicateSweep(ctx, pool, logger, strict, duplicateCandidatePage)
+ if err != nil {
+ t.Fatalf("strict sweep: %v", err)
+ }
+ if res.Groups != 0 {
+ t.Fatalf("sweep at a 1%% threshold = %+v, want the copies 5%% apart left ungrouped", res)
+ }
+ res, err = runDuplicateSweep(ctx, pool, logger, DefaultFingerprintSettings, duplicateCandidatePage)
+ if err != nil {
+ t.Fatalf("default sweep: %v", err)
+ }
+ if got := groups(); res.Groups != 1 || got[acousticKey] != (stored{"acoustic", "pending"}) {
+ t.Fatalf("sweep at the default threshold = %+v, groups %+v; want the recording's copies proposed", res, got)
+ }
+}
+
+func TestSweepIsDue(t *testing.T) {
+ now := time.Date(2026, 9, 11, 12, 0, 0, 0, time.UTC)
+ at := func(ago time.Duration) pgtype.Timestamptz {
+ return pgtype.Timestamptz{Time: now.Add(-ago), Valid: true}
+ }
+ never := pgtype.Timestamptz{}
+ hourly := DefaultFingerprintSettings
+ daily := DefaultFingerprintSettings
+ daily.SweepIntervalHours = 24
+ savedAgo := func(ago time.Duration) FingerprintSettings {
+ s := DefaultFingerprintSettings
+ s.UpdatedAt = now.Add(-ago)
+ return s
+ }
+ for _, tc := range []struct {
+ name string
+ latestPrint, lastSweep pgtype.Timestamptz
+ cfg FingerprintSettings
+ want bool
+ }{
+ {"nothing fingerprinted", never, never, hourly, false},
+ {"never swept", at(time.Minute), never, hourly, true},
+ {"new fingerprints since the last sweep", at(10 * time.Minute), at(2 * time.Hour), hourly, true},
+ {"nothing new since the last sweep", at(3 * time.Hour), at(2 * time.Hour), hourly, false},
+ // The sweep started a moment after the previous tick, so one tick later
+ // it is a moment under an hour old. Without the slack this is false.
+ {"one tick after an hourly sweep", at(time.Minute), at(time.Hour - 2*time.Second), hourly, true},
+ {"new fingerprints inside the interval", at(time.Minute), at(3 * time.Hour), daily, false},
+ {"new fingerprints past the interval", at(time.Minute), at(25 * time.Hour), daily, true},
+ {"settings saved since the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(time.Hour), true},
+ {"settings saved before the last sweep", at(3 * time.Hour), at(2 * time.Hour), savedAgo(4 * time.Hour), false},
+ } {
+ if got := sweepIsDue(tc.latestPrint, tc.lastSweep, tc.cfg, now); got != tc.want {
+ t.Errorf("%s: sweepIsDue = %v, want %v", tc.name, got, tc.want)
+ }
+ }
}
diff --git a/internal/library/fingerprint.go b/internal/library/fingerprint.go
index ebd6b3db..4a33117d 100644
--- a/internal/library/fingerprint.go
+++ b/internal/library/fingerprint.go
@@ -50,21 +50,28 @@ const fingerprintTimeout = 60 * time.Second
const fingerprintWaitDelay = 5 * time.Second
// fingerprintVersion stamps how a track_fingerprints row was derived. Bump it
-// whenever the derivation changes — the hash arguments, fpcalc's flags or its
-// length — and the backfill re-derives every row below it. Fingerprints taken
-// by two methods are not comparable, and nothing else would reveal that the
-// library held a mix.
+// whenever the derivation changes — the hash arguments or fpcalc's flags — and
+// the backfill re-derives every row below it. Fingerprints taken by two methods
+// are not comparable, and nothing else would reveal that the library held a mix.
+//
+// The length is deliberately not part of it: it is an operator setting (#3913),
+// so each row records the length it was taken at and readers compare only rows
+// at the current one. See fingerprint_settings.go.
const fingerprintVersion int16 = 1
// errFingerprintTimeout marks a tool that ran out of time. Distinct from a
// failed exit because a stall is a fact about the mount, not about the file.
var errFingerprintTimeout = errors.New("fingerprint tool timed out")
-// defaultChromaprintLengthSec is how many seconds of audio fpcalc fingerprints.
-// 120 is fpcalc's own default. Fingerprints taken at different lengths are not
-// comparable, so changing this has to re-derive every stored one.
+// defaultChromaprintLengthSec is the shipped value of the length setting (#3913):
+// how many seconds of audio fpcalc fingerprints. 120 is fpcalc's own default.
const defaultChromaprintLengthSec = 120
+// errChromaprintSkipped marks a chromaprint not taken because fingerprinting is
+// switched off. Inconclusive rather than a verdict: nothing was learned about
+// the file.
+var errChromaprintSkipped = errors.New("chromaprint skipped: fingerprinting is off")
+
// fpcalcStderrTail caps how much of a failing tool's stderr reaches the log.
const fpcalcStderrTail = 512
@@ -113,11 +120,24 @@ type fingerprintResult struct {
printErr error
}
-// computeFingerprint derives both halves for the file at path.
-func computeFingerprint(ctx context.Context, path string) fingerprintResult {
+// fingerprintOptions is what the settings decide for one attempt. Captured once
+// per file, so the length a chromaprint was taken at is the length stored with it
+// even if the setting changes mid-attempt.
+type fingerprintOptions struct {
+ lengthSec int32
+ // chromaprint false takes the stream hash alone: a demux, no decode.
+ chromaprint bool
+}
+
+// computeFingerprint derives the halves opts asks for, for the file at path.
+func computeFingerprint(ctx context.Context, path string, opts fingerprintOptions) fingerprintResult {
var r fingerprintResult
r.streamSHA256, r.hashErr = computeAudioStreamSHA256(ctx, path)
- r.chromaprint, r.printErr = computeChromaprint(ctx, path, defaultChromaprintLengthSec)
+ if !opts.chromaprint {
+ r.printErr = errChromaprintSkipped
+ return r
+ }
+ r.chromaprint, r.printErr = computeChromaprint(ctx, path, opts.lengthSec)
return r
}
@@ -130,11 +150,13 @@ func (r fingerprintResult) inconclusive() bool {
}
// isInconclusive names the failures that are not a verdict on the file: a
-// stall, a cancelled scan, and a tool that is not installed. The last matters
-// outside the image — a dev binary run without fpcalc on PATH must not stamp
-// every track in the library as unfingerprintable.
+// stall, a cancelled scan, a tool that is not installed, and a chromaprint
+// skipped because fingerprinting is off. A missing tool matters outside the
+// image — a dev binary run without fpcalc on PATH must not stamp every track in
+// the library as unfingerprintable.
func isInconclusive(err error) bool {
return errors.Is(err, errFingerprintTimeout) ||
+ errors.Is(err, errChromaprintSkipped) ||
errors.Is(err, context.Canceled) ||
errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, exec.ErrNotFound)
@@ -142,11 +164,11 @@ func isInconclusive(err error) bool {
// fingerprintFile runs the scanner's fingerprinter. A Scanner built without New
// gets the real tools rather than a nil-func panic halfway through a scan.
-func (s *Scanner) fingerprintFile(ctx context.Context, path string) fingerprintResult {
+func (s *Scanner) fingerprintFile(ctx context.Context, path string, opts fingerprintOptions) fingerprintResult {
if s.fingerprint == nil {
- return computeFingerprint(ctx, path)
+ return computeFingerprint(ctx, path, opts)
}
- return s.fingerprint(ctx, path)
+ return s.fingerprint(ctx, path, opts)
}
// fingerprintOutcome is what storeFingerprint did with one attempt.
@@ -163,9 +185,11 @@ const (
// the backfill (#3908) alike, so there is one rule for what gets written. It
// never fails its caller: a missing fingerprint only keeps a track out of
// duplicate detection, which is not worth dropping a scan or a pass over.
+//
+// lengthSec is the length fp's chromaprint was taken at, stored with it (#3913).
func storeFingerprint(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
- trackID pgtype.UUID, path string, fp fingerprintResult,
+ trackID pgtype.UUID, path string, fp fingerprintResult, lengthSec int32,
) fingerprintOutcome {
if fp.hashErr != nil {
logger.Warn("fingerprint: audio stream hash failed", "path", path, "err", fp.hashErr)
@@ -186,10 +210,11 @@ func storeFingerprint(
// is stamped at the current version so the backfill does not retry it on
// every pass. It is retried when the file changes.
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
- TrackID: trackID,
- AudioStreamSha256: fp.streamSHA256,
- Chromaprint: fp.chromaprint,
- FingerprintVersion: fingerprintVersion,
+ TrackID: trackID,
+ AudioStreamSha256: fp.streamSHA256,
+ Chromaprint: fp.chromaprint,
+ FingerprintVersion: fingerprintVersion,
+ ChromaprintLengthSec: lengthSec,
}); err != nil {
logger.Warn("fingerprint: storing fingerprint failed", "path", path, "err", err)
return outcomeStoreFailed
@@ -211,8 +236,8 @@ func computeAudioStreamSHA256(ctx context.Context, path string) ([]byte, error)
// computeChromaprint returns the raw acoustic fingerprint of the first
// lengthSec seconds of the file.
-func computeChromaprint(ctx context.Context, path string, lengthSec int) ([]int32, error) {
- out, err := runFingerprintTool(ctx, "fpcalc", fpcalcArgs(path, lengthSec))
+func computeChromaprint(ctx context.Context, path string, lengthSec int32) ([]int32, error) {
+ out, err := runFingerprintTool(ctx, "fpcalc", fpcalcArgs(path, int(lengthSec)))
if err != nil {
return nil, err
}
diff --git a/internal/library/fingerprint_backfill.go b/internal/library/fingerprint_backfill.go
index 316a335b..eb19c1c8 100644
--- a/internal/library/fingerprint_backfill.go
+++ b/internal/library/fingerprint_backfill.go
@@ -40,10 +40,11 @@ const fingerprintBackfillTick = time.Hour
// so tracks the scan adds mid-pass are not stuck behind one enormous page.
const fingerprintBackfillBatch = 50
-// fingerprintBackfillConcurrency is how many files are decoded at once. Two is
-// deliberately low: fpcalc and the stream hash compete with playback transcoding
-// for CPU and with streaming for the mount, and a backfill that makes playback
-// stutter is worse than one that takes longer. Operator-tunable in #3913.
+// fingerprintBackfillConcurrency is the shipped value of the concurrency setting
+// (#3913): how many files are decoded at once. Two is deliberately low: fpcalc
+// and the stream hash compete with playback transcoding for CPU and with
+// streaming for the mount, and a backfill that makes playback stutter is worse
+// than one that takes longer.
const fingerprintBackfillConcurrency = 2
// BackfillFingerprintsResult tallies one pass.
@@ -68,24 +69,27 @@ func (r *BackfillFingerprintsResult) add(o fingerprintOutcome) {
// FingerprintBackfillWorker fingerprints the tracks the scan never will.
type FingerprintBackfillWorker struct {
- pool *pgxpool.Pool
- logger *slog.Logger
- tick time.Duration
- batch int32
- concurrency int
+ pool *pgxpool.Pool
+ logger *slog.Logger
+ settings *FingerprintSettingsService
+ tick time.Duration
+ batch int32
// fingerprint is a field for the same reason as Scanner.fingerprint: an
// integration test pins which tracks a pass touches, not what the tools print.
- fingerprint func(ctx context.Context, path string) fingerprintResult
+ fingerprint func(ctx context.Context, path string, opts fingerprintOptions) fingerprintResult
}
// NewFingerprintBackfillWorker builds a worker with the production cadence.
-func NewFingerprintBackfillWorker(pool *pgxpool.Pool, logger *slog.Logger) *FingerprintBackfillWorker {
+// settings is shared with the scanner and the admin API; nil runs on defaults.
+func NewFingerprintBackfillWorker(
+ pool *pgxpool.Pool, logger *slog.Logger, settings *FingerprintSettingsService,
+) *FingerprintBackfillWorker {
return &FingerprintBackfillWorker{
pool: pool,
logger: logger,
+ settings: settings,
tick: fingerprintBackfillTick,
batch: fingerprintBackfillBatch,
- concurrency: fingerprintBackfillConcurrency,
fingerprint: computeFingerprint,
}
}
@@ -129,6 +133,12 @@ func (w *FingerprintBackfillWorker) runOnce(ctx context.Context) {
// cursor is what lets a pass end: an inconclusive attempt writes no row, so a
// file that keeps timing out would otherwise be listed again immediately and
// retried forever within the pass.
+//
+// Settings are read before every batch, so a save takes effect within a batch
+// rather than an hour (#3913): switching fingerprinting off ends the pass, a new
+// concurrency applies to the next batch, and a new length restarts the walk from
+// the top at that length, because every row written at the old one went stale
+// the moment it changed.
func (w *FingerprintBackfillWorker) pass(ctx context.Context) (BackfillFingerprintsResult, error) {
q := dbq.New(w.pool)
var (
@@ -137,15 +147,25 @@ func (w *FingerprintBackfillWorker) pass(ctx context.Context) (BackfillFingerpri
)
// The all-zero uuid sorts before every real id. Valid must be true: a NULL
// cursor would make "id > NULL" match nothing and every pass a silent no-op.
- after := pgtype.UUID{Valid: true}
+ start := pgtype.UUID{Valid: true}
+ after := start
+ lengthSec := w.settings.Get().ChromaprintLengthSec
for {
if err := ctx.Err(); err != nil {
return res, err
}
+ cfg := w.settings.Get()
+ if !cfg.Enabled {
+ return res, nil
+ }
+ if cfg.ChromaprintLengthSec != lengthSec {
+ lengthSec, after = cfg.ChromaprintLengthSec, start
+ }
rows, err := q.ListTracksNeedingFingerprint(ctx, dbq.ListTracksNeedingFingerprintParams{
- CurrentVersion: fingerprintVersion,
- AfterID: after,
- BatchLimit: w.batch,
+ CurrentVersion: fingerprintVersion,
+ ChromaprintLengthSec: lengthSec,
+ AfterID: after,
+ BatchLimit: w.batch,
})
if err != nil {
return res, fmt.Errorf("list tracks needing fingerprint: %w", err)
@@ -154,7 +174,10 @@ func (w *FingerprintBackfillWorker) pass(ctx context.Context) (BackfillFingerpri
return res, nil
}
- sem := make(chan struct{}, w.concurrency)
+ opts := fingerprintOptions{lengthSec: lengthSec, chromaprint: true}
+ // Validation keeps concurrency at one or more; the floor guards a zero
+ // that would block the first send for ever.
+ sem := make(chan struct{}, max(1, int(cfg.BackfillConcurrency)))
var wg sync.WaitGroup
for _, row := range rows {
if ctx.Err() != nil {
@@ -170,7 +193,7 @@ func (w *FingerprintBackfillWorker) pass(ctx context.Context) (BackfillFingerpri
w.logger.Error("fingerprint backfill: track panicked", "path", path, "panic", r)
}
}()
- outcome := storeFingerprint(ctx, q, w.logger, trackID, path, w.fingerprintFile(ctx, path))
+ outcome := storeFingerprint(ctx, q, w.logger, trackID, path, w.fingerprintFile(ctx, path, opts), lengthSec)
mu.Lock()
res.add(outcome)
mu.Unlock()
@@ -181,16 +204,21 @@ func (w *FingerprintBackfillWorker) pass(ctx context.Context) (BackfillFingerpri
}
}
-func (w *FingerprintBackfillWorker) fingerprintFile(ctx context.Context, path string) fingerprintResult {
+func (w *FingerprintBackfillWorker) fingerprintFile(ctx context.Context, path string, opts fingerprintOptions) fingerprintResult {
if w.fingerprint == nil {
- return computeFingerprint(ctx, path)
+ return computeFingerprint(ctx, path, opts)
}
- return w.fingerprint(ctx, path)
+ return w.fingerprint(ctx, path, opts)
}
// FingerprintCoverage reports how much of the library carries a current
// fingerprint, for the admin gauge. It lives here, beside the backfill, so the
-// version it counts against is the one the backfill writes.
-func FingerprintCoverage(ctx context.Context, pool *pgxpool.Pool) (dbq.GetFingerprintCoverageRow, error) {
- return dbq.New(pool).GetFingerprintCoverage(ctx, fingerprintVersion)
+// version and length it counts against are the ones the backfill writes.
+func FingerprintCoverage(
+ ctx context.Context, pool *pgxpool.Pool, cfg FingerprintSettings,
+) (dbq.GetFingerprintCoverageRow, error) {
+ return dbq.New(pool).GetFingerprintCoverage(ctx, dbq.GetFingerprintCoverageParams{
+ CurrentVersion: fingerprintVersion,
+ ChromaprintLengthSec: cfg.ChromaprintLengthSec,
+ })
}
diff --git a/internal/library/fingerprint_backfill_test.go b/internal/library/fingerprint_backfill_test.go
index 8b5b81c9..fb3a166a 100644
--- a/internal/library/fingerprint_backfill_test.go
+++ b/internal/library/fingerprint_backfill_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
+ "maps"
"path/filepath"
"sync"
"testing"
@@ -48,7 +49,7 @@ func TestFingerprintBackfill_Integration(t *testing.T) {
} {
if err := q.UpsertTrackFingerprint(ctx, dbq.UpsertTrackFingerprintParams{
TrackID: seed.track.ID, AudioStreamSha256: sum, Chromaprint: []int32{1},
- FingerprintVersion: seed.version,
+ FingerprintVersion: seed.version, ChromaprintLengthSec: defaultChromaprintLengthSec,
}); err != nil {
t.Fatalf("seed fingerprint: %v", err)
}
@@ -59,13 +60,24 @@ func TestFingerprintBackfill_Integration(t *testing.T) {
var mu sync.Mutex
calls := map[string]int{}
- w := NewFingerprintBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ settings, err := NewFingerprintSettingsService(ctx, pool)
+ if err != nil {
+ t.Fatalf("fingerprint settings: %v", err)
+ }
+ t.Cleanup(func() {
+ if _, err := settings.Set(context.Background(), DefaultFingerprintSettings); err != nil {
+ t.Errorf("restore fingerprint settings: %v", err)
+ }
+ })
+ w := NewFingerprintBackfillWorker(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), settings)
// A batch of one forces the keyset cursor across several queries in a pass.
w.batch = 1
- w.fingerprint = func(_ context.Context, path string) fingerprintResult {
+ lengths := map[int32]int{}
+ w.fingerprint = func(_ context.Context, path string, opts fingerprintOptions) fingerprintResult {
name := filepath.Base(path)
mu.Lock()
calls[name]++
+ lengths[opts.lengthSec]++
mu.Unlock()
switch name {
case "stall.mp3":
@@ -126,7 +138,7 @@ func TestFingerprintBackfill_Integration(t *testing.T) {
}
// 4. The gauge counts what the passes wrote, and its buckets add up.
- cov, err := FingerprintCoverage(ctx, pool)
+ cov, err := FingerprintCoverage(ctx, pool, settings.Get())
if err != nil {
t.Fatalf("coverage: %v", err)
}
@@ -138,6 +150,64 @@ func TestFingerprintBackfill_Integration(t *testing.T) {
if cov.Fingerprinted+cov.Rejected+cov.Pending != cov.Total {
t.Errorf("coverage buckets %+v do not sum to the total", cov)
}
+
+ // 5. Changing the length (#3913) makes every stored row stale at once — the
+ // gauge shows the whole library pending before the backfill has touched a
+ // file — and the next pass re-derives each at the new length. The failure
+ // this prevents is invisible from the UI: prints at two lengths that silently
+ // never match.
+ shorter := DefaultFingerprintSettings
+ shorter.ChromaprintLengthSec = 60
+ if _, err := settings.Set(ctx, shorter); err != nil {
+ t.Fatalf("change length: %v", err)
+ }
+ cov, err = FingerprintCoverage(ctx, pool, settings.Get())
+ if err != nil {
+ t.Fatalf("coverage after length change: %v", err)
+ }
+ if cov.Total != 5 || cov.Pending != 5 {
+ t.Fatalf("coverage after length change = %+v, want all 5 tracks pending", cov)
+ }
+ mu.Lock()
+ clear(lengths)
+ mu.Unlock()
+ res, err = w.pass(ctx)
+ if err != nil {
+ t.Fatalf("new-length pass: %v", err)
+ }
+ if res.Processed != 5 {
+ t.Fatalf("new-length pass = %+v, want all 5 present tracks re-derived", res)
+ }
+ mu.Lock()
+ asked := maps.Clone(lengths)
+ mu.Unlock()
+ if len(asked) != 1 || asked[60] != 5 {
+ t.Fatalf("new-length pass asked for lengths %v, want 60s for all 5", asked)
+ }
+ var atOldLength int
+ if err := pool.QueryRow(ctx,
+ "SELECT count(*) FROM track_fingerprints WHERE chromaprint_length_sec <> 60").Scan(&atOldLength); err != nil {
+ t.Fatalf("count old-length rows: %v", err)
+ }
+ if atOldLength != 0 {
+ t.Fatalf("%d fingerprints are still at the old length after a complete pass", atOldLength)
+ }
+
+ // 6. Switched off, the backfill does nothing, even with work waiting.
+ off := DefaultFingerprintSettings
+ off.Enabled = false
+ if _, err := settings.Set(ctx, off); err != nil {
+ t.Fatalf("switch fingerprinting off: %v", err)
+ }
+ addTrack("later")
+ res, err = w.pass(ctx)
+ if err != nil {
+ t.Fatalf("pass with fingerprinting off: %v", err)
+ }
+ if res.Processed != 0 || callCount("later.mp3") != 0 {
+ t.Fatalf("pass with fingerprinting off = %+v (later.mp3 tried %d times), want nothing done",
+ res, callCount("later.mp3"))
+ }
}
func TestBackfillFingerprintsResult_Add(t *testing.T) {
diff --git a/internal/library/fingerprint_scan_test.go b/internal/library/fingerprint_scan_test.go
index b2edb800..1e4c1d0a 100644
--- a/internal/library/fingerprint_scan_test.go
+++ b/internal/library/fingerprint_scan_test.go
@@ -60,9 +60,24 @@ func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) {
result := fingerprintResult{streamSHA256: sum, chromaprint: chroma}
calls := map[string]int{}
- scanner := New(pool, logger, []string{root})
- scanner.fingerprint = func(_ context.Context, path string) fingerprintResult {
+ settings, err := NewFingerprintSettingsService(ctx, pool)
+ if err != nil {
+ t.Fatalf("fingerprint settings: %v", err)
+ }
+ if _, err := settings.Set(ctx, DefaultFingerprintSettings); err != nil {
+ t.Fatalf("reset fingerprint settings: %v", err)
+ }
+ t.Cleanup(func() {
+ if _, err := settings.Set(context.Background(), DefaultFingerprintSettings); err != nil {
+ t.Errorf("restore fingerprint settings: %v", err)
+ }
+ })
+
+ var lastOpts fingerprintOptions
+ scanner := New(pool, logger, []string{root}, settings)
+ scanner.fingerprint = func(_ context.Context, path string, opts fingerprintOptions) fingerprintResult {
calls[path]++
+ lastOpts = opts
return result
}
@@ -78,14 +93,15 @@ func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) {
sha []byte
chroma []int32
version int16
+ length int32
}
stored := func(path string) (row, bool) {
t.Helper()
var r row
err := pool.QueryRow(ctx, `
- SELECT f.audio_stream_sha256, f.chromaprint, f.fingerprint_version
+ SELECT f.audio_stream_sha256, f.chromaprint, f.fingerprint_version, f.chromaprint_length_sec
FROM track_fingerprints f JOIN tracks t ON t.id = f.track_id
- WHERE t.file_path = $1`, path).Scan(&r.sha, &r.chroma, &r.version)
+ WHERE t.file_path = $1`, path).Scan(&r.sha, &r.chroma, &r.version, &r.length)
if errors.Is(err, pgx.ErrNoRows) {
return row{}, false
}
@@ -113,8 +129,13 @@ func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) {
if !ok {
t.Fatal("first scan stored no fingerprint")
}
- if !bytes.Equal(got.sha, sum) || !slices.Equal(got.chroma, chroma) || got.version != fingerprintVersion {
- t.Fatalf("stored %+v, want sha %x chromaprint %v version %d", got, sum, chroma, fingerprintVersion)
+ if !bytes.Equal(got.sha, sum) || !slices.Equal(got.chroma, chroma) || got.version != fingerprintVersion ||
+ got.length != defaultChromaprintLengthSec {
+ t.Fatalf("stored %+v, want sha %x chromaprint %v version %d length %d",
+ got, sum, chroma, fingerprintVersion, defaultChromaprintLengthSec)
+ }
+ if !lastOpts.chromaprint || lastOpts.lengthSec != defaultChromaprintLengthSec {
+ t.Fatalf("first scan asked for %+v, want a chromaprint at the default length", lastOpts)
}
// 2. A tag-repair pass re-reads every unchanged file and must not
@@ -171,4 +192,39 @@ func TestScanner_FingerprintsOnlyNewOrChangedBytes_Integration(t *testing.T) {
if got.sha != nil || got.chroma != nil || got.version != fingerprintVersion {
t.Fatalf("rejected file stored %+v, want both halves NULL at version %d", got, fingerprintVersion)
}
+
+ // 6. With fingerprinting off (#3913) the scan decodes nothing. It still asks
+ // for the stream hash — a demux, and what recognises a moved file — but stores
+ // no row, and a changed file's old row goes: it describes bytes that are gone.
+ result = fingerprintResult{streamSHA256: sum, chromaprint: chroma}
+ off := DefaultFingerprintSettings
+ off.Enabled = false
+ if _, err := settings.Set(ctx, off); err != nil {
+ t.Fatalf("switch fingerprinting off: %v", err)
+ }
+ touch(a, 4*time.Hour)
+ scan("fingerprinting-off scan")
+ if calls[a] != 5 || lastOpts.chromaprint {
+ t.Fatalf("fingerprinting-off scan: calls = %v, last options %+v; want a fifth call asking for no chromaprint",
+ calls, lastOpts)
+ }
+ if _, ok := stored(a); ok {
+ t.Fatal("with fingerprinting off, a changed file kept the previous bytes' fingerprint")
+ }
+ if _, ok := stored(b); !ok {
+ t.Fatal("with fingerprinting off, an unchanged file lost its fingerprint")
+ }
+
+ // 7. The length setting reaches the scan, and is stored with the row.
+ longer := DefaultFingerprintSettings
+ longer.ChromaprintLengthSec = 90
+ if _, err := settings.Set(ctx, longer); err != nil {
+ t.Fatalf("change length: %v", err)
+ }
+ touch(a, 5*time.Hour)
+ scan("new-length scan")
+ got, ok = stored(a)
+ if !ok || got.length != 90 || lastOpts.lengthSec != 90 || !lastOpts.chromaprint {
+ t.Fatalf("new-length scan stored %+v (present %v) after asking for %+v; want a chromaprint at 90s", got, ok, lastOpts)
+ }
}
diff --git a/internal/library/fingerprint_settings.go b/internal/library/fingerprint_settings.go
new file mode 100644
index 00000000..fa971b80
--- /dev/null
+++ b/internal/library/fingerprint_settings.go
@@ -0,0 +1,163 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
+)
+
+// Fingerprinting settings (M400 #3913).
+//
+// Rule 25: what an operator might tune lives in the database and changes without
+// a restart. One service instance is shared by the scanner, the backfill, the
+// duplicate sweep and the admin API, so a save reaches all of them at once.
+//
+// The length is the dangerous knob: chromaprints taken at two lengths cannot be
+// compared. Rather than bumping fingerprintVersion when it changes, every row
+// records the length it was taken at (migration 0061) and every reader filters
+// on the current one. A row at another length is stale to the backfill, pending
+// to the gauge and invisible to the sweep, which is the effect a version bump
+// would have, with one difference worth having: setting the length back makes
+// rows not yet re-derived current again, instead of redoing the library twice.
+
+// Bounds for each setting, mirrored by migration 0061's CHECKs.
+const (
+ // Below 30s a print has too few items to align at the matcher's larger
+ // offsets and still overlap by minOverlapItems. Above 600s each print passes
+ // 20 KB and a candidate page of them grows past what one sweep should hold.
+ minChromaprintLengthSec = 30
+ maxChromaprintLengthSec = 600
+ // Unrelated audio sits near 0.5; a ceiling of 0.35 keeps a loosened
+ // threshold well clear of proposing noise.
+ minAcousticMaxBitErrorRate = 0.01
+ maxAcousticMaxBitErrorRate = 0.35
+ minBackfillConcurrency = 1
+ maxBackfillConcurrency = 8
+ minSweepIntervalHours = 1
+ maxSweepIntervalHours = 168
+
+ defaultSweepIntervalHours = 1
+)
+
+// FingerprintSettings mirrors the fingerprint_settings row.
+type FingerprintSettings struct {
+ // Enabled off stops every decode: the scan takes only the stream hash (a
+ // demux, and what recognises a moved file) and stores nothing, and the
+ // backfill idles.
+ Enabled bool
+ ChromaprintLengthSec int32
+ AcousticMaxBitErrorRate float64
+ BackfillConcurrency int32
+ SweepIntervalHours int32
+ // UpdatedAt is when the settings were last saved. Set by the database;
+ // ignored by Set.
+ UpdatedAt time.Time
+}
+
+// DefaultFingerprintSettings mirrors migration 0061's column defaults, so a
+// database that cannot be read still fingerprints the way a fresh install does.
+var DefaultFingerprintSettings = FingerprintSettings{
+ Enabled: true,
+ ChromaprintLengthSec: defaultChromaprintLengthSec,
+ AcousticMaxBitErrorRate: defaultAcousticMaxBitErrorRate,
+ BackfillConcurrency: fingerprintBackfillConcurrency,
+ SweepIntervalHours: defaultSweepIntervalHours,
+}
+
+// ErrFingerprintSettingOutOfRange is returned by Set for a value migration
+// 0061's CHECKs would reject, so the API answers 400 naming the field rather
+// than surfacing a constraint violation.
+var ErrFingerprintSettingOutOfRange = errors.New("fingerprint setting out of range")
+
+// FingerprintSettingsService caches the settings and owns their persistence.
+// Cached because the scanner reads them for every file it fingerprints.
+type FingerprintSettingsService struct {
+ pool *pgxpool.Pool
+
+ mu sync.RWMutex
+ cur FingerprintSettings
+}
+
+// NewFingerprintSettingsService loads once and caches. It always returns a
+// usable service, holding the defaults when the load fails; the error says so.
+func NewFingerprintSettingsService(ctx context.Context, pool *pgxpool.Pool) (*FingerprintSettingsService, error) {
+ s := &FingerprintSettingsService{pool: pool, cur: DefaultFingerprintSettings}
+ row, err := dbq.New(pool).GetFingerprintSettings(ctx)
+ if err != nil {
+ return s, fmt.Errorf("fingerprint settings: load: %w", err)
+ }
+ s.cur = fingerprintSettingsFromRow(row)
+ return s, nil
+}
+
+// Get returns the cached settings. A nil service answers with the defaults, so
+// a Scanner or worker built without one fingerprints as a fresh install would.
+func (s *FingerprintSettingsService) Get() FingerprintSettings {
+ if s == nil {
+ return DefaultFingerprintSettings
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cur
+}
+
+// Set validates, persists and re-caches.
+func (s *FingerprintSettingsService) Set(ctx context.Context, in FingerprintSettings) (FingerprintSettings, error) {
+ if err := validateFingerprintSettings(in); err != nil {
+ return FingerprintSettings{}, err
+ }
+ if s == nil {
+ return FingerprintSettings{}, errors.New("fingerprint settings: no settings service")
+ }
+ row, err := dbq.New(s.pool).UpdateFingerprintSettings(ctx, dbq.UpdateFingerprintSettingsParams{
+ Enabled: in.Enabled,
+ ChromaprintLengthSec: in.ChromaprintLengthSec,
+ AcousticMaxBitErrorRate: in.AcousticMaxBitErrorRate,
+ BackfillConcurrency: in.BackfillConcurrency,
+ SweepIntervalHours: in.SweepIntervalHours,
+ })
+ if err != nil {
+ return FingerprintSettings{}, fmt.Errorf("fingerprint settings: save: %w", err)
+ }
+ out := fingerprintSettingsFromRow(row)
+ s.mu.Lock()
+ s.cur = out
+ s.mu.Unlock()
+ return out, nil
+}
+
+func validateFingerprintSettings(in FingerprintSettings) error {
+ switch {
+ case in.ChromaprintLengthSec < minChromaprintLengthSec || in.ChromaprintLengthSec > maxChromaprintLengthSec:
+ return fmt.Errorf("%w: chromaprint_length_sec must be %d-%d",
+ ErrFingerprintSettingOutOfRange, minChromaprintLengthSec, maxChromaprintLengthSec)
+ // Written as a negated range so NaN, which fails every comparison, is refused.
+ case !(in.AcousticMaxBitErrorRate >= minAcousticMaxBitErrorRate && in.AcousticMaxBitErrorRate <= maxAcousticMaxBitErrorRate):
+ return fmt.Errorf("%w: acoustic_max_bit_error_rate must be %.2f-%.2f",
+ ErrFingerprintSettingOutOfRange, minAcousticMaxBitErrorRate, maxAcousticMaxBitErrorRate)
+ case in.BackfillConcurrency < minBackfillConcurrency || in.BackfillConcurrency > maxBackfillConcurrency:
+ return fmt.Errorf("%w: backfill_concurrency must be %d-%d",
+ ErrFingerprintSettingOutOfRange, minBackfillConcurrency, maxBackfillConcurrency)
+ case in.SweepIntervalHours < minSweepIntervalHours || in.SweepIntervalHours > maxSweepIntervalHours:
+ return fmt.Errorf("%w: sweep_interval_hours must be %d-%d",
+ ErrFingerprintSettingOutOfRange, minSweepIntervalHours, maxSweepIntervalHours)
+ }
+ return nil
+}
+
+func fingerprintSettingsFromRow(row dbq.FingerprintSetting) FingerprintSettings {
+ return FingerprintSettings{
+ Enabled: row.Enabled,
+ ChromaprintLengthSec: row.ChromaprintLengthSec,
+ AcousticMaxBitErrorRate: row.AcousticMaxBitErrorRate,
+ BackfillConcurrency: row.BackfillConcurrency,
+ SweepIntervalHours: row.SweepIntervalHours,
+ UpdatedAt: row.UpdatedAt.Time,
+ }
+}
diff --git a/internal/library/fingerprint_settings_test.go b/internal/library/fingerprint_settings_test.go
new file mode 100644
index 00000000..95d5837b
--- /dev/null
+++ b/internal/library/fingerprint_settings_test.go
@@ -0,0 +1,146 @@
+package library
+
+import (
+ "context"
+ "errors"
+ "math"
+ "testing"
+ "time"
+)
+
+func TestValidateFingerprintSettings(t *testing.T) {
+ with := func(edit func(*FingerprintSettings)) FingerprintSettings {
+ s := DefaultFingerprintSettings
+ edit(&s)
+ return s
+ }
+ valid := map[string]FingerprintSettings{
+ "defaults": DefaultFingerprintSettings,
+ "shortest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec }),
+ "longest length": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec }),
+ "strictest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate }),
+ "loosest match": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate }),
+ "fewest at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = minBackfillConcurrency }),
+ "most at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency }),
+ "shortest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = minSweepIntervalHours }),
+ "longest interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours }),
+ "switched off": with(func(s *FingerprintSettings) { s.Enabled = false }),
+ }
+ for name, s := range valid {
+ if err := validateFingerprintSettings(s); err != nil {
+ t.Errorf("%s: rejected: %v", name, err)
+ }
+ }
+ invalid := map[string]FingerprintSettings{
+ "length too short": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = minChromaprintLengthSec - 1 }),
+ "length too long": with(func(s *FingerprintSettings) { s.ChromaprintLengthSec = maxChromaprintLengthSec + 1 }),
+ "match too strict": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = minAcousticMaxBitErrorRate - 0.001 }),
+ "match too loose": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = maxAcousticMaxBitErrorRate + 0.001 }),
+ "match not a number": with(func(s *FingerprintSettings) { s.AcousticMaxBitErrorRate = math.NaN() }),
+ "none at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = 0 }),
+ "too many at once": with(func(s *FingerprintSettings) { s.BackfillConcurrency = maxBackfillConcurrency + 1 }),
+ "no interval": with(func(s *FingerprintSettings) { s.SweepIntervalHours = 0 }),
+ "interval too long": with(func(s *FingerprintSettings) { s.SweepIntervalHours = maxSweepIntervalHours + 1 }),
+ }
+ for name, s := range invalid {
+ if err := validateFingerprintSettings(s); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
+ t.Errorf("%s: err = %v, want ErrFingerprintSettingOutOfRange", name, err)
+ }
+ }
+}
+
+func TestFingerprintSettingsService_NilServesDefaults(t *testing.T) {
+ var s *FingerprintSettingsService
+ if got := s.Get(); got != DefaultFingerprintSettings {
+ t.Fatalf("nil service Get = %+v, want the defaults", got)
+ }
+ // Validation still runs first, so a bad value is named rather than hidden
+ // behind the missing service.
+ bad := DefaultFingerprintSettings
+ bad.BackfillConcurrency = 0
+ if _, err := s.Set(context.Background(), bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
+ t.Fatalf("nil service Set of a bad value: err = %v, want ErrFingerprintSettingOutOfRange", err)
+ }
+}
+
+func TestFingerprintSettingsService_Integration(t *testing.T) {
+ pool := newPool(t)
+ ctx := context.Background()
+ reload := func(step string) FingerprintSettings {
+ t.Helper()
+ fresh, err := NewFingerprintSettingsService(ctx, pool)
+ if err != nil {
+ t.Fatalf("%s: load: %v", step, err)
+ }
+ return fresh.Get()
+ }
+ withoutTime := func(s FingerprintSettings) FingerprintSettings {
+ s.UpdatedAt = time.Time{}
+ return s
+ }
+
+ svc, err := NewFingerprintSettingsService(ctx, pool)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ // ResetDB puts every column back to its migration default, so this pins the
+ // Go defaults to migration 0061's. Were they to drift, a database that could
+ // not be read would fingerprint differently from one that could.
+ loaded := svc.Get()
+ if loaded.UpdatedAt.IsZero() {
+ t.Fatal("loaded settings carry no updated_at")
+ }
+ if withoutTime(loaded) != DefaultFingerprintSettings {
+ t.Fatalf("stored defaults = %+v, want the Go defaults %+v", withoutTime(loaded), DefaultFingerprintSettings)
+ }
+
+ // Every bound the service accepts, the table accepts too. A CHECK tighter
+ // than validate would turn a value the card allows into a 500.
+ lowest := FingerprintSettings{
+ Enabled: false,
+ ChromaprintLengthSec: minChromaprintLengthSec,
+ AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
+ BackfillConcurrency: minBackfillConcurrency,
+ SweepIntervalHours: minSweepIntervalHours,
+ }
+ highest := FingerprintSettings{
+ Enabled: true,
+ ChromaprintLengthSec: maxChromaprintLengthSec,
+ AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
+ BackfillConcurrency: maxBackfillConcurrency,
+ SweepIntervalHours: maxSweepIntervalHours,
+ }
+ for _, step := range []struct {
+ name string
+ want FingerprintSettings
+ }{{"lowest", lowest}, {"highest", highest}} {
+ saved, err := svc.Set(ctx, step.want)
+ if err != nil {
+ t.Fatalf("%s: save: %v", step.name, err)
+ }
+ // A save must move updated_at forward: it is what makes a sweep due.
+ if !saved.UpdatedAt.After(loaded.UpdatedAt) {
+ t.Fatalf("%s: updated_at %v did not move past %v", step.name, saved.UpdatedAt, loaded.UpdatedAt)
+ }
+ if withoutTime(saved) != step.want || withoutTime(svc.Get()) != step.want {
+ t.Fatalf("%s: saved %+v, cached %+v, want %+v", step.name, saved, svc.Get(), step.want)
+ }
+ if got := withoutTime(reload(step.name)); got != step.want {
+ t.Fatalf("%s: table holds %+v, want %+v", step.name, got, step.want)
+ }
+ }
+
+ // An out-of-range save changes nothing, in the cache or the table.
+ before := svc.Get()
+ bad := highest
+ bad.ChromaprintLengthSec = maxChromaprintLengthSec + 1
+ if _, err := svc.Set(ctx, bad); !errors.Is(err, ErrFingerprintSettingOutOfRange) {
+ t.Fatalf("out-of-range save: err = %v, want ErrFingerprintSettingOutOfRange", err)
+ }
+ if svc.Get() != before {
+ t.Fatalf("out-of-range save changed the cache to %+v", svc.Get())
+ }
+ if got := reload("after out-of-range save"); got != before {
+ t.Fatalf("out-of-range save changed the table to %+v", got)
+ }
+}
diff --git a/internal/library/scanner.go b/internal/library/scanner.go
index 7b32507a..f2c00c32 100644
--- a/internal/library/scanner.go
+++ b/internal/library/scanner.go
@@ -79,11 +79,14 @@ type Scanner struct {
// integration test can substitute a deterministic one: CI has no real audio
// to fingerprint, and what the test pins is WHEN the scan fingerprints, not
// what the tools print. Call it through fingerprintFile.
- fingerprint func(ctx context.Context, path string) fingerprintResult
+ fingerprint func(ctx context.Context, path string, opts fingerprintOptions) fingerprintResult
+ // settings is the operator's fingerprinting policy (#3913), shared with the
+ // workers and the admin API. Nil fingerprints with the defaults.
+ settings *FingerprintSettingsService
}
-func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
- return &Scanner{pool: pool, logger: logger, paths: paths, fingerprint: computeFingerprint}
+func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string, settings *FingerprintSettingsService) *Scanner {
+ return &Scanner{pool: pool, logger: logger, paths: paths, fingerprint: computeFingerprint, settings: settings}
}
// Scan walks every configured root and upserts any audio file whose mtime is
@@ -313,10 +316,18 @@ func (s *Scanner) scanFile(
//
// Computed before move adoption so adoption can match on the audio hash
// (#3914); stored after the upsert, once the row id is known.
+ //
+ // With fingerprinting switched off (#3913) the scan decodes nothing, but it
+ // still takes the stream hash: a demux rather than a decode, and the only
+ // thing that recognises a moved file with no MBID.
var fp fingerprintResult
+ fpCfg := s.settings.Get()
fingerprinted := !unchanged
if fingerprinted {
- fp = s.fingerprintFile(ctx, path)
+ fp = s.fingerprintFile(ctx, path, fingerprintOptions{
+ lengthSec: fpCfg.ChromaprintLengthSec,
+ chromaprint: fpCfg.Enabled,
+ })
}
// A path we've never seen might not be a new track — it might be one that
@@ -384,7 +395,13 @@ func (s *Scanner) scanFile(
s.logger.Warn("library scan: LogChange track upsert failed", "track_id", track.ID, "err", err)
}
if fingerprinted {
- storeFingerprint(ctx, q, s.logger, track.ID, path, fp)
+ if fpCfg.Enabled {
+ storeFingerprint(ctx, q, s.logger, track.ID, path, fp, fpCfg.ChromaprintLengthSec)
+ } else if err := q.DeleteTrackFingerprint(ctx, track.ID); err != nil {
+ // Off, nothing is stored — but a row describing the previous bytes
+ // must not outlive them, or the sweep would compare audio that is gone.
+ s.logger.Warn("fingerprint: clearing stale fingerprint failed", "path", path, "err", err)
+ }
}
if knownTrack {
diff --git a/internal/library/scanner_test.go b/internal/library/scanner_test.go
index bcc56a79..b17741a9 100644
--- a/internal/library/scanner_test.go
+++ b/internal/library/scanner_test.go
@@ -81,7 +81,7 @@ func TestScanner_Integration(t *testing.T) {
"TIT2": "Solo", "TPE1": "The Artist Y", "TALB": "Y Album", "TRCK": "1",
})
- scanner := New(pool, logger, []string{root})
+ scanner := New(pool, logger, []string{root}, nil)
stats, err := scanner.Scan(ctx, nil)
if err != nil {
t.Fatalf("first scan: %v", err)
@@ -259,7 +259,7 @@ func TestScanner_AdoptsMovedFile_Integration(t *testing.T) {
})
}
- scanner := New(pool, logger, []string{root})
+ scanner := New(pool, logger, []string{root}, nil)
if _, err := scanner.Scan(ctx, nil); err != nil {
t.Fatalf("first scan: %v", err)
}
diff --git a/internal/server/server.go b/internal/server/server.go
index 37b13da2..c7eafbac 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -100,6 +100,11 @@ type Server struct {
// and serves the admin tuning endpoints from it. Router() constructs
// a fallback when nil (tests).
RecSettings *recsettings.Service
+ // FingerprintSettings is the DB-backed fingerprinting policy (M400 #3913).
+ // Constructed in cmd/minstrel/main.go and shared with the scanner and the
+ // fingerprint workers, so a save from the admin card reaches them without a
+ // restart. Router() constructs a fallback when nil (tests).
+ FingerprintSettings *library.FingerprintSettingsService
// StreamSecret is the HMAC key used by /api/cast/stream-token to
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
// to verify them. Sourced from config.Config.StreamSecret. Tests that
@@ -186,7 +191,17 @@ func (s *Server) Router() http.Handler {
s.Logger.Error("server: recsettings boot failed", "err", err)
}
}
- api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings)
+ fpSettings := s.FingerprintSettings
+ if fpSettings == nil {
+ // Test contexts construct Server without main.go's wiring. Always
+ // usable: a failed load serves the defaults.
+ var err error
+ fpSettings, err = library.NewFingerprintSettingsService(context.Background(), s.Pool)
+ if err != nil {
+ s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err)
+ }
+ }
+ api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
diff --git a/web/src/lib/api/admin.fingerprints.test.ts b/web/src/lib/api/admin.fingerprints.test.ts
index 9442fe45..1ae3669c 100644
--- a/web/src/lib/api/admin.fingerprints.test.ts
+++ b/web/src/lib/api/admin.fingerprints.test.ts
@@ -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).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).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).mockResolvedValueOnce(settings);
+ (api.put as unknown as ReturnType).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);
+ });
});
diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts
index 07e3940e..8f97e103 100644
--- a/web/src/lib/api/admin.ts
+++ b/web/src/lib/api/admin.ts
@@ -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 {
@@ -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 {
+ return api.get('/api/admin/library/fingerprint-settings');
+}
+
+export async function updateFingerprintSettings(
+ s: FingerprintSettings
+): Promise {
+ return api.put('/api/admin/library/fingerprint-settings', s);
+}
+
// Cover-art providers ------------------------------------------------------
export type CoverProviderCapability = 'album_cover' | 'artist_thumb' | 'artist_fanart';
diff --git a/web/src/lib/api/errors.test.ts b/web/src/lib/api/errors.test.ts
index d728c1fc..fc874041 100644
--- a/web/src/lib/api/errors.test.ts
+++ b/web/src/lib/api/errors.test.ts
@@ -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', () => {
diff --git a/web/src/lib/api/errors.ts b/web/src/lib/api/errors.ts
index 1f09f55d..3de3b71b 100644
--- a/web/src/lib/api/errors.ts
+++ b/web/src/lib/api/errors.ts
@@ -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 = new Set(['library_not_writable', 'file_delete_failed']);
+const DETAIL_CODES: ReadonlySet = 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
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index 76c5a645..4648ee5d 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -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;
diff --git a/web/src/lib/components/FingerprintSettingsCard.svelte b/web/src/lib/components/FingerprintSettingsCard.svelte
new file mode 100644
index 00000000..85e766da
--- /dev/null
+++ b/web/src/lib/components/FingerprintSettingsCard.svelte
@@ -0,0 +1,218 @@
+
+
+
diff --git a/web/src/lib/components/FingerprintSettingsCard.test.ts b/web/src/lib/components/FingerprintSettingsCard.test.ts
new file mode 100644
index 00000000..85ea770d
--- /dev/null
+++ b/web/src/lib/components/FingerprintSettingsCard.test.ts
@@ -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 = {},
+ 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();
+ });
+});
diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json
index c18028a3..237d999a 100644
--- a/web/src/lib/styles/error-copy.json
+++ b/web/src/lib/styles/error-copy.json
@@ -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.",
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte
index df5334ae..97feda2a 100644
--- a/web/src/routes/admin/+page.svelte
+++ b/web/src/routes/admin/+page.svelte
@@ -445,6 +445,11 @@
·
{fingerprints.pending.toLocaleString()} pending
{/if}
+ {#if fingerprints.enabled === false}
+
+ ·
+ fingerprinting is off
+ {/if}
{#if fingerprints.rejected > 0}
·
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}
{/if}
@@ -191,8 +197,13 @@
{#if prints && prints.total > 0 && prints.fingerprinted === 0}
Nothing to compare yet.
- 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}
{:else if sweep?.state === 'never'}
The sweep hasn't run yet.
@@ -341,4 +352,8 @@
{/if}
{/if}
+
+
+ query.refetch()} />
diff --git a/web/src/routes/admin/duplicates/duplicates.test.ts b/web/src/routes/admin/duplicates/duplicates.test.ts
index 98d3c9d3..c7c7fa60 100644
--- a/web/src/routes/admin/duplicates/duplicates.test.ts
+++ b/web/src/routes/admin/duplicates/duplicates.test.ts
@@ -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 = {}) {
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({