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:
+33
-17
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user