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
This commit is contained in:
Esa Kataja
2026-06-21 19:51:35 +03:00
parent bef58f480b
commit 8fc00c96b9
8 changed files with 427 additions and 19 deletions
+12
View File
@@ -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"`
+4
View File
@@ -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)
+3
View File
@@ -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 @@
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<a href="/settings" class="navlink">settings</a>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
+40 -2
View File
@@ -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)
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>av1dae — settings</title>
<style>
:root {
--bg:#0d1117; --panel:#151b23; --panel-2:#1a2230; --line:#26303f;
--text:#cdd5df; --muted:#7d8896; --dim:#5a6573;
--amber:#ffb454; --amber-soft:#ffd9a0; --cyan:#56c7e8; --green:#5cc98b; --red:#f0816a;
--mono:"SF Mono",ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
* { box-sizing:border-box; }
body {
margin:0 auto; max-width:720px; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased; padding:clamp(1.25rem,4vw,2.5rem);
}
a { color:var(--cyan); text-decoration:none; }
a:hover { text-decoration:underline; }
header { display:flex; align-items:baseline; gap:1rem; margin-bottom:1.75rem; }
.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); }
.crumb { color:var(--muted); font-family:var(--mono); font-size:.85rem; }
.back { margin-left:auto; font-family:var(--mono); font-size:.8rem; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.3rem 1.5rem; margin-bottom:1.25rem; }
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--amber); margin:0 0 1rem; }
.hint { color:var(--muted); font-size:.85rem; margin:.1rem 0 0; }
.row { display:flex; align-items:center; gap:1rem; padding:.5rem 0; flex-wrap:wrap; }
.row label { flex:1; min-width:12rem; }
.row label .sub { display:block; color:var(--dim); font-size:.78rem; }
input[type=number], input[type=text] {
background:var(--panel-2); border:1px solid var(--line); color:var(--text);
border-radius:6px; padding:.4rem .55rem; font-family:var(--mono); font-size:.9rem; width:6rem;
}
input[type=text] { width:100%; max-width:22rem; }
input:focus { outline:2px solid var(--amber); outline-offset:1px; }
/* profiles grid */
table.prof { width:100%; border-collapse:collapse; }
table.prof th, table.prof td { text-align:left; padding:.4rem .5rem; }
table.prof th { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:500; }
table.prof td.name { font-family:var(--mono); color:var(--amber-soft); }
.toggle { display:flex; align-items:center; gap:.6rem; }
.toggle input { width:1.1rem; height:1.1rem; accent-color:var(--amber); }
.actions { display:flex; align-items:center; gap:1rem; margin-top:.5rem; }
button {
font-family:var(--mono); font-size:.85rem; background:var(--amber); color:#1a1200;
border:0; border-radius:7px; padding:.55rem 1.1rem; cursor:pointer; font-weight:600;
}
button:hover { background:var(--amber-soft); }
button:disabled { opacity:.5; cursor:default; }
#msg { font-family:var(--mono); font-size:.82rem; }
#msg.ok { color:var(--green); }
#msg.err { color:var(--red); }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>av<span class="vibe">1</span>dae</span>
<span class="crumb">/ settings</span>
<a class="back" href="/">← dashboard</a>
</header>
<form id="form">
<section class="card">
<p class="eyebrow">Encoding profiles</p>
<table class="prof">
<thead><tr><th>Source</th><th>CRF (063)</th><th>Preset (013)</th></tr></thead>
<tbody>
<tr><td class="name">dvd</td><td><input type="number" id="dvd_crf" min="0" max="63"></td><td><input type="number" id="dvd_preset" min="0" max="13"></td></tr>
<tr><td class="name">bluray</td><td><input type="number" id="bluray_crf" min="0" max="63"></td><td><input type="number" id="bluray_preset" min="0" max="13"></td></tr>
<tr><td class="name">webdl</td><td><input type="number" id="webdl_crf" min="0" max="63"></td><td><input type="number" id="webdl_preset" min="0" max="13"></td></tr>
<tr><td class="name">tvrip</td><td><input type="number" id="tvrip_crf" min="0" max="63"></td><td><input type="number" id="tvrip_preset" min="0" max="13"></td></tr>
</tbody>
</table>
<div class="row" style="margin-top:.6rem">
<label>Thread cap (lp)<span class="sub">SVT-AV1 logical processors · 0 = use all cores</span></label>
<input type="number" id="lp" min="0">
</div>
</section>
<section class="card">
<p class="eyebrow">Metadata & housekeeping</p>
<div class="row">
<label>OMDb API key<span class="sub">Required for movie metadata; TVmaze needs none</span></label>
<input type="text" id="omdb_api_key" autocomplete="off" spellcheck="false">
</div>
<div class="row">
<label>Log retention (days)<span class="sub">Rows older than this are purged from logs.db</span></label>
<input type="number" id="log_retention_days" min="1">
</div>
<div class="row">
<label class="toggle"><input type="checkbox" id="delete_originals"> Delete originals after a successful encode<span class="sub">Off = move them to originals/</span></label>
</div>
</section>
<div class="actions">
<button type="submit" id="save">Save settings</button>
<span id="msg"></span>
</div>
</form>
<script>
const $ = id => document.getElementById(id);
const profiles = ["dvd", "bluray", "webdl", "tvrip"];
function fill(s) {
for (const p of profiles) { $(p + "_crf").value = s[p].crf; $(p + "_preset").value = s[p].preset; }
$("lp").value = s.lp;
$("omdb_api_key").value = s.omdb_api_key || "";
$("log_retention_days").value = s.log_retention_days;
$("delete_originals").checked = !!s.delete_originals;
}
function collect() {
const s = { lp: +$("lp").value, omdb_api_key: $("omdb_api_key").value,
log_retention_days: +$("log_retention_days").value, delete_originals: $("delete_originals").checked };
for (const p of profiles) s[p] = { crf: +$(p + "_crf").value, preset: +$(p + "_preset").value };
return s;
}
function msg(text, cls) { const m = $("msg"); m.textContent = text; m.className = cls || ""; }
async function load() {
try {
const r = await fetch("/api/settings", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
fill(await r.json());
} catch (e) { msg("Failed to load settings", "err"); }
}
$("form").addEventListener("submit", async e => {
e.preventDefault();
$("save").disabled = true; msg("Saving…");
try {
const r = await fetch("/api/settings", {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect())
});
if (r.ok) msg("Saved — applies to the next encode.", "ok");
else msg("Rejected: " + (await r.text()).trim(), "err");
} catch (e) { msg("Network error", "err"); }
finally { $("save").disabled = false; }
});
load();
</script>
</body>
</html>
+130
View File
@@ -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
}
+46
View File
@@ -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")
}
}