package library import ( "context" "errors" "fmt" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // AcoustID lookup settings (M401 #3922). Rule 25: a database row, changed // without a restart, shared by the worker and the admin API. // AcoustIDSettings mirrors the acoustid_settings row. type AcoustIDSettings struct { Enabled bool // APIKey is the operator's registered application key. Never sent back // to the browser; the API reports only whether one is set. APIKey string // MinScore is the AcoustID score a recording needs to be considered. MinScore float32 // UpdatedAt is set by the database. UpdatedAt time.Time } // Ready reports whether the worker may call AcoustID at all. func (s AcoustIDSettings) Ready() bool { return s.Enabled && s.APIKey != "" } // DefaultAcoustIDSettings mirrors migration 0069's defaults: off, no key. var DefaultAcoustIDSettings = AcoustIDSettings{MinScore: 0.85} // The score threshold's bounds, as migration 0069's CHECK has them. Below 0.5 // AcoustID's own docs call a match doubtful; the floor keeps a typo in the // field from filling the library with guesses. const ( minAcoustIDScore = 0.5 maxAcoustIDScore = 1 ) // ErrAcoustIDSettingOutOfRange is returned by Set for a value the CHECK would // reject, so the API answers 400 naming the field. var ErrAcoustIDSettingOutOfRange = errors.New("acoustid setting out of range") // AcoustIDSettingsUpdate is the admin save. A nil APIKey keeps the stored key; // an empty one clears it. type AcoustIDSettingsUpdate struct { Enabled bool MinScore float32 APIKey *string } // AcoustIDSettingsService caches the settings and owns their persistence. type AcoustIDSettingsService struct { pool *pgxpool.Pool mu sync.RWMutex cur AcoustIDSettings // changed is signalled after every save, so the worker can start a pass // at once instead of waiting out its tick. changed chan struct{} } // NewAcoustIDSettingsService loads once and caches. It always returns a usable // service, holding the defaults (off) when the load fails; the error says so. func NewAcoustIDSettingsService(ctx context.Context, pool *pgxpool.Pool) (*AcoustIDSettingsService, error) { s := &AcoustIDSettingsService{pool: pool, cur: DefaultAcoustIDSettings, changed: make(chan struct{}, 1)} row, err := dbq.New(pool).GetAcoustIDSettings(ctx) if err != nil { return s, fmt.Errorf("acoustid settings: load: %w", err) } s.cur = acoustIDSettingsFromRow(row) return s, nil } // Get returns the cached settings. A nil service answers with the defaults. func (s *AcoustIDSettingsService) Get() AcoustIDSettings { if s == nil { return DefaultAcoustIDSettings } s.mu.RLock() defer s.mu.RUnlock() return s.cur } // Set validates, persists and re-caches. func (s *AcoustIDSettingsService) Set(ctx context.Context, in AcoustIDSettingsUpdate) (AcoustIDSettings, error) { if in.MinScore < minAcoustIDScore || in.MinScore > maxAcoustIDScore { return AcoustIDSettings{}, fmt.Errorf("%w: min_score must be %.2f-%.2f", ErrAcoustIDSettingOutOfRange, float32(minAcoustIDScore), float32(maxAcoustIDScore)) } if s == nil { return AcoustIDSettings{}, errors.New("acoustid settings: no settings service") } params := dbq.UpdateAcoustIDSettingsParams{Enabled: in.Enabled, MinScore: in.MinScore} if in.APIKey != nil { params.SetApiKey = true params.ApiKey = *in.APIKey } row, err := dbq.New(s.pool).UpdateAcoustIDSettings(ctx, params) if err != nil { return AcoustIDSettings{}, fmt.Errorf("acoustid settings: save: %w", err) } out := acoustIDSettingsFromRow(row) s.mu.Lock() s.cur = out s.mu.Unlock() select { case s.changed <- struct{}{}: default: } return out, nil } // Changed is signalled after a save. A nil service never signals. func (s *AcoustIDSettingsService) Changed() <-chan struct{} { if s == nil { return nil } return s.changed } func acoustIDSettingsFromRow(row dbq.AcoustidSetting) AcoustIDSettings { out := AcoustIDSettings{Enabled: row.Enabled, MinScore: row.MinScore, UpdatedAt: row.UpdatedAt.Time} if row.ApiKey != nil { out.APIKey = *row.ApiKey } return out }