// Package settings holds the live-editable configuration: values that can be // changed from the web UI and persisted, without a config-file edit + restart. // config.yaml seeds the store on first run; afterwards the DB is the source of // truth for these values (paths.* and http_addr stay config-only). package settings import ( "database/sql" "encoding/json" "fmt" "sync" "av1dae/pkg/types" ) // Profile is a per-source-type SVT-AV1 quality pair. type Profile struct { CRF int `json:"crf"` Preset int `json:"preset"` } // Settings is the full set of live-editable values. type Settings struct { DVD Profile `json:"dvd"` Bluray Profile `json:"bluray"` WebDL Profile `json:"webdl"` TVRip Profile `json:"tvrip"` LP int `json:"lp"` OMDBAPIKey string `json:"omdb_api_key"` LogRetentionDays int `json:"log_retention_days"` DeleteOriginals bool `json:"delete_originals"` } // ProfileFor returns the encoding profile for a media type. func (s Settings) ProfileFor(mt types.MediaType) Profile { switch mt { case types.MediaTypeBluRay: return s.Bluray case types.MediaTypeWebDL: return s.WebDL case types.MediaTypeTVRip: return s.TVRip default: return s.DVD } } // Validate guards the trust boundary: a bad value from the PUT handler could // silently break every subsequent encode. Ranges follow SVT-AV1's limits. func (s Settings) Validate() error { for name, p := range map[string]Profile{"dvd": s.DVD, "bluray": s.Bluray, "webdl": s.WebDL, "tvrip": s.TVRip} { if p.CRF < 0 || p.CRF > 63 { return fmt.Errorf("%s crf %d out of range 0-63", name, p.CRF) } if p.Preset < 0 || p.Preset > 13 { return fmt.Errorf("%s preset %d out of range 0-13", name, p.Preset) } } if s.LP < 0 { return fmt.Errorf("lp %d must be >= 0", s.LP) } if s.LogRetentionDays < 1 { return fmt.Errorf("log_retention_days %d must be >= 1", s.LogRetentionDays) } return nil } // Store is the persisted, concurrency-safe settings holder. The encode loop // reads via Get; the HTTP handler writes via Set. type Store struct { mu sync.RWMutex db *sql.DB cur Settings } // New creates the settings table if needed, loads the persisted row, or seeds // it from `seed` (the config-derived values) on first run. func New(db *sql.DB, seed Settings) (*Store, error) { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)`); err != nil { return nil, fmt.Errorf("settings schema: %w", err) } s := &Store{db: db, cur: seed} var data string switch err := db.QueryRow(`SELECT data FROM settings WHERE id = 1`).Scan(&data); err { case sql.ErrNoRows: if err := s.persist(seed); err != nil { // first run — seed from config return nil, err } case nil: var loaded Settings if err := json.Unmarshal([]byte(data), &loaded); err != nil { return nil, fmt.Errorf("decoding settings: %w", err) } s.cur = loaded default: return nil, fmt.Errorf("loading settings: %w", err) } return s, nil } // Get returns a copy of the current settings. func (s *Store) Get() Settings { s.mu.RLock() defer s.mu.RUnlock() return s.cur } // Set validates, persists, and swaps in the new settings. func (s *Store) Set(n Settings) error { if err := n.Validate(); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if err := s.persist(n); err != nil { return err } s.cur = n return nil } func (s *Store) persist(n Settings) error { b, err := json.Marshal(n) if err != nil { return err } _, err = s.db.Exec(`INSERT INTO settings (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`, string(b)) return err }