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 @@
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
+
+
+
+
+
+
+
+
+
+
+
+
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")
+ }
+}