test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
164 lines
6.5 KiB
Go
164 lines
6.5 KiB
Go
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,
|
|
}
|
|
}
|