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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (0–63)</th><th>Preset (0–13)</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>
|
||||
Reference in New Issue
Block a user