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
+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>