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, } }