Files
av1dae/internal/settings/settings.go
T
Esa Kataja 8fc00c96b9 add: DB-backed settings UI for live-editable tunables
Add a settings page (/settings) and API (/api/settings GET/PUT) to edit
encoding profiles (crf/preset per media type), lp, OMDb key, log retention,
and delete-originals from the browser, persisted to a settings table in
logs.db. config.yaml seeds the store on first run; afterwards the DB is the
source of truth (paths.* and http_addr stay config-only). Each job snapshots
the current settings, so changes apply to the next encode with no restart.

PUT validates ranges (crf 0-63, preset 0-13, lp >=0, retention >=1) before
persisting. No auth, by design for now (isolated network) — see #2.

Closes #1
2026-06-21 19:51:35 +03:00

131 lines
3.6 KiB
Go

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