From 8fc00c96b97471cae8c48463cbc99e61423da2d5 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Sun, 21 Jun 2026 19:51:35 +0300 Subject: [PATCH] add: DB-backed settings UI for live-editable tunables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/av1dae/main.go | 50 ++++++--- internal/logger/logger.go | 12 +++ internal/metadata/metadata.go | 4 + internal/server/index.html | 3 + internal/server/server.go | 42 +++++++- internal/server/settings.html | 159 +++++++++++++++++++++++++++++ internal/settings/settings.go | 130 +++++++++++++++++++++++ internal/settings/settings_test.go | 46 +++++++++ 8 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 internal/server/settings.html create mode 100644 internal/settings/settings.go create mode 100644 internal/settings/settings_test.go diff --git a/cmd/av1dae/main.go b/cmd/av1dae/main.go index ba84e50..520b8d6 100644 --- a/cmd/av1dae/main.go +++ b/cmd/av1dae/main.go @@ -21,6 +21,7 @@ import ( "av1dae/internal/metadata" "av1dae/internal/mover" "av1dae/internal/server" + "av1dae/internal/settings" "av1dae/internal/status" "av1dae/internal/watcher" "av1dae/pkg/types" @@ -65,13 +66,33 @@ func main() { os.Exit(1) } + // Live-editable settings: config.yaml seeds the store on first run; after + // that the DB (logs.db) is the source of truth for these values. + store, err := settings.New(log.DB(), settings.Settings{ + DVD: settings.Profile{CRF: cfg.Encoding.DVD.CRF, Preset: cfg.Encoding.DVD.Preset}, + Bluray: settings.Profile{CRF: cfg.Encoding.Bluray.CRF, Preset: cfg.Encoding.Bluray.Preset}, + WebDL: settings.Profile{CRF: cfg.Encoding.WebDL.CRF, Preset: cfg.Encoding.WebDL.Preset}, + TVRip: settings.Profile{CRF: cfg.Encoding.TVRip.CRF, Preset: cfg.Encoding.TVRip.Preset}, + LP: cfg.Encoding.LP, + OMDBAPIKey: cfg.OMDBAPIKey, + LogRetentionDays: cfg.LogRetentionDays, + DeleteOriginals: deleteOrigin, + }) + if err != nil { + log.Error("Settings init failed", err.Error()) + fmt.Fprintf(os.Stderr, "Failed to init settings: %v\n", err) + os.Exit(1) + } + // A persisted retention value (DB) overrides the config-seeded one. + log.SetRetention(store.Get().LogRetentionDays) + metaClient := metadata.NewClient(cfg.OMDBAPIKey, log) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() if addr := *cfg.HTTPAddr; addr != "" { - srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, cfg.Paths.Input).Handler()} + srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, store, cfg.Paths.Input).Handler()} go func() { log.Info(fmt.Sprintf("Status server listening on %s", addr)) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -88,15 +109,20 @@ func main() { w := watcher.New(cfg.Paths.Input, 15) w.Start(ctx, func(ctx context.Context, inputPath string) error { - return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker) + return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker, store) }) log.Info("av1dae started") } -func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker) error { +func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker, store *settings.Store) error { log.Info(fmt.Sprintf("Processing: %s", inputPath)) + // Snapshot the live settings once for the whole job, so a save mid-encode + // doesn't change anything until the next file. + cur := store.Get() + metaClient.SetAPIKey(cur.OMDBAPIKey) + // Mark this file as the active job (phase: probing) and clear the tracker // back to idle on every exit path — success, failure, or cancellation. tracker.Begin(inputPath) @@ -136,17 +162,7 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc * mediaType = watcher.DetectMediaType(width, height) } - var profile types.EncodingParams - switch mediaType { - case types.MediaTypeBluRay: - profile = cfg.Encoding.Bluray - case types.MediaTypeWebDL: - profile = cfg.Encoding.WebDL - case types.MediaTypeTVRip: - profile = cfg.Encoding.TVRip - default: - profile = cfg.Encoding.DVD - } + profile := cur.ProfileFor(mediaType) crf := profile.CRF preset := profile.Preset @@ -192,8 +208,8 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc * MediaType: mediaType, CRF: crf, Preset: preset, - LP: cfg.Encoding.LP, - DeleteOrigin: deleteOrigin, + LP: cur.LP, + DeleteOrigin: cur.DeleteOriginals, } if err := enc.Transcode(ctx, inputPath, workDir, job, meta, interlaced, streamLangs); err != nil { @@ -224,7 +240,7 @@ func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc * return err } - if deleteOrigin { + if cur.DeleteOriginals { mover.Delete(inputPath) log.Info(fmt.Sprintf("Deleted original: %s", inputPath)) } else { diff --git a/internal/logger/logger.go b/internal/logger/logger.go index bfe7408..b204942 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -113,6 +113,18 @@ func (l *Logger) Debug(message, file, extra string) { l.write(LevelDebug, message, file, extra) } +// DB exposes the underlying connection so the settings store can share the +// same logs.db file rather than opening a second database. +func (l *Logger) DB() *sql.DB { return l.db } + +// SetRetention updates the purge horizon at runtime (e.g. from the settings UI). +// Takes effect on the next purge. Ignored for non-positive values. +func (l *Logger) SetRetention(days int) { + if days > 0 { + l.retentionDays = days + } +} + // LogEntry is one row returned by RecentLogs, shaped for the status feed. type LogEntry struct { TS string `json:"ts"` diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 76252f9..6ec5d78 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -64,6 +64,10 @@ func NewClient(apiKey string, log *logger.Logger) *Client { } } +// SetAPIKey updates the OMDb key (e.g. after a settings change). Called from the +// single-threaded encode loop before a fetch, so no locking is needed. +func (c *Client) SetAPIKey(key string) { c.omdbAPIKey = key } + // redactURL strips secret query parameters (e.g. apikey) before logging. func redactURL(rawURL string) string { u, err := url.Parse(rawURL) diff --git a/internal/server/index.html b/internal/server/index.html index ec45ef7..f688440 100644 --- a/internal/server/index.html +++ b/internal/server/index.html @@ -27,6 +27,8 @@ .wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; } .wordmark .prompt { color:var(--dim); } .wordmark .vibe { color:var(--amber); } + .navlink { font-family:var(--mono); font-size:.78rem; color:var(--muted); text-decoration:none; } + .navlink:hover { color:var(--amber); } .live { margin-left:auto; display:flex; align-items:center; gap:.5rem; font-family:var(--mono); font-size:.74rem; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; } .dot { width:9px; height:9px; border-radius:50%; background:var(--dim); } .dot.ok { background:var(--green); box-shadow:0 0 0 0 rgba(92,201,139,.6); animation:pulse 2s infinite; } @@ -118,6 +120,7 @@
$ av1dae + settings connecting
diff --git a/internal/server/server.go b/internal/server/server.go index 26e2197..30a9963 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "path/filepath" "av1dae/internal/logger" + "av1dae/internal/settings" "av1dae/internal/status" "av1dae/internal/watcher" ) @@ -16,14 +17,18 @@ import ( //go:embed index.html var indexHTML []byte +//go:embed settings.html +var settingsHTML []byte + type Server struct { tracker *status.Tracker log *logger.Logger + store *settings.Store inputDir string } -func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server { - return &Server{tracker: tracker, log: log, inputDir: inputDir} +func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, inputDir string) *Server { + return &Server{tracker: tracker, log: log, store: store, inputDir: inputDir} } // Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML @@ -31,10 +36,43 @@ func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/settings", s.handleSettingsPage) + mux.HandleFunc("/api/settings", s.handleAPISettings) mux.HandleFunc("/", s.handleIndex) return mux } +func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(settingsHTML) +} + +// handleAPISettings serves the current settings (GET) and saves new ones (PUT). +// Validation lives in settings.Store.Set; a bad payload returns 400. +func (s *Server) handleAPISettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(s.store.Get()) + case http.MethodPut: + var in settings.Settings + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.store.Set(in); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.log.SetRetention(in.LogRetentionDays) + s.log.Info("Settings updated via web UI") + w.WriteHeader(http.StatusNoContent) + default: + w.Header().Set("Allow", "GET, PUT") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/internal/server/settings.html b/internal/server/settings.html new file mode 100644 index 0000000..ba47856 --- /dev/null +++ b/internal/server/settings.html @@ -0,0 +1,159 @@ + + + + + +av1dae — settings + + + + +
+ $ av1dae + / settings + ← dashboard +
+ +
+
+

Encoding profiles

+ + + + + + + + +
SourceCRF (0–63)Preset (0–13)
dvd
bluray
webdl
tvrip
+
+ + +
+
+ +
+

Metadata & housekeeping

+
+ + +
+
+ + +
+
+ +
+
+ +
+ + +
+
+ + + + + diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..5eac9ab --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,130 @@ +// 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 +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..4e1c3e0 --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,46 @@ +package settings + +import ( + "testing" + + "av1dae/pkg/types" +) + +func valid() Settings { + return Settings{ + DVD: Profile{30, 2}, Bluray: Profile{29, 3}, WebDL: Profile{30, 3}, TVRip: Profile{32, 2}, + LP: 0, LogRetentionDays: 7, + } +} + +func TestValidate(t *testing.T) { + if err := valid().Validate(); err != nil { + t.Fatalf("valid settings rejected: %v", err) + } + bad := func(mut func(*Settings)) Settings { s := valid(); mut(&s); return s } + cases := map[string]Settings{ + "crf too high": bad(func(s *Settings) { s.DVD.CRF = 64 }), + "crf negative": bad(func(s *Settings) { s.Bluray.CRF = -1 }), + "preset too high": bad(func(s *Settings) { s.WebDL.Preset = 14 }), + "lp negative": bad(func(s *Settings) { s.LP = -1 }), + "retention zero": bad(func(s *Settings) { s.LogRetentionDays = 0 }), + } + for name, s := range cases { + if err := s.Validate(); err == nil { + t.Errorf("%s: expected validation error, got nil", name) + } + } +} + +func TestProfileFor(t *testing.T) { + s := valid() + if s.ProfileFor(types.MediaTypeBluRay) != s.Bluray { + t.Error("bluray profile mismatch") + } + if s.ProfileFor(types.MediaTypeWebDL) != s.WebDL { + t.Error("webdl profile mismatch") + } + if s.ProfileFor(types.MediaType("anything-else")) != s.DVD { + t.Error("unknown media type should fall back to DVD") + } +}