add: processing controls — start/hold, pause/resume, retry

Three lifecycle controls on the dashboard, sharing one /api surface and the
status feed:

- Start/hold gate (#11): the watcher tracks the queue but holds processing
  until POST /api/start. Held by default; AV1DAE_AUTOSTART=1 restores start-
  on-boot. NOTE: flips the previous auto-start default.
- Pause/resume the active encode (#10): SIGSTOP/SIGCONT on the ffmpeg process
  (libsvtav1 is in-process, so one signal suspends all its threads). Resumes
  exactly where it left off; the tracker excludes paused time from elapsed.
- Retry a failed file (#3): POST /api/retry?file=NAME moves it from failed/
  back to input/, with a base-name guard against path traversal.

/status now reports running + the failed list; snapshots carry a paused flag.
Verified live: held queue, retry move, traversal -> 400, and a real encode
suspending to process state T on pause and S on resume.

Closes #3
Closes #10
Closes #11
This commit is contained in:
Esa Kataja
2026-06-21 20:38:21 +03:00
parent 8fc00c96b9
commit 04d6c85712
8 changed files with 349 additions and 29 deletions
+94 -13
View File
@@ -20,15 +20,27 @@ var indexHTML []byte
//go:embed settings.html
var settingsHTML []byte
type Server struct {
tracker *status.Tracker
log *logger.Logger
store *settings.Store
inputDir string
// Controls is the runtime-control surface the UI drives, wired in main from the
// watcher (start/hold gate), encoder (pause/resume), and mover (retry).
type Controls struct {
Running func() bool
SetRunning func(bool)
Pause func() error
Resume func() error
RetryFailed func(name string) error
}
func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, inputDir string) *Server {
return &Server{tracker: tracker, log: log, store: store, inputDir: inputDir}
type Server struct {
tracker *status.Tracker
log *logger.Logger
store *settings.Store
controls Controls
inputDir string
failedDir string
}
func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, controls Controls, inputDir, failedDir string) *Server {
return &Server{tracker: tracker, log: log, store: store, controls: controls, inputDir: inputDir, failedDir: failedDir}
}
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
@@ -38,10 +50,72 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/settings", s.handleSettingsPage)
mux.HandleFunc("/api/settings", s.handleAPISettings)
mux.HandleFunc("/api/start", s.gateHandler(true))
mux.HandleFunc("/api/hold", s.gateHandler(false))
mux.HandleFunc("/api/pause", s.actionHandler(func() error { return s.controls.Pause() }, "Encode paused"))
mux.HandleFunc("/api/resume", s.actionHandler(func() error { return s.controls.Resume() }, "Encode resumed"))
mux.HandleFunc("/api/retry", s.handleRetry)
mux.HandleFunc("/", s.handleIndex)
return mux
}
// gateHandler flips the start/hold gate. POST only.
func (s *Server) gateHandler(run bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
s.controls.SetRunning(run)
if run {
s.log.Info("Queue started via web UI")
} else {
s.log.Info("Queue held via web UI")
}
w.WriteHeader(http.StatusNoContent)
}
}
// actionHandler wraps a no-arg control action (pause/resume). POST only.
func (s *Server) actionHandler(fn func() error, logMsg string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
if err := fn(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.log.Info(logMsg + " via web UI")
w.WriteHeader(http.StatusNoContent)
}
}
// handleRetry moves a named file from failed/ back to input/. POST ?file=NAME.
func (s *Server) handleRetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
name := r.URL.Query().Get("file")
if name == "" {
http.Error(w, "missing file", http.StatusBadRequest)
return
}
if err := s.controls.RetryFailed(name); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.log.Info("Retry requested via web UI: " + name)
w.WriteHeader(http.StatusNoContent)
}
func methodNotAllowed(w http.ResponseWriter) {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(settingsHTML)
@@ -83,17 +157,14 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
type statusResponse struct {
Running bool `json:"running"`
Current status.Snapshot `json:"current"`
Queue []string `json:"queue"`
Failed []string `json:"failed"`
Recent []logger.LogEntry `json:"recent"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
queue := []string{}
for _, f := range watcher.InputFiles(s.inputDir) {
queue = append(queue, filepath.Base(f))
}
recent, err := s.log.RecentLogs(50)
if err != nil {
http.Error(w, "reading logs", http.StatusInternalServerError)
@@ -102,8 +173,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{
Running: s.controls.Running(),
Current: s.tracker.Snapshot(),
Queue: queue,
Queue: baseNames(watcher.InputFiles(s.inputDir)),
Failed: baseNames(watcher.InputFiles(s.failedDir)),
Recent: recent,
})
}
func baseNames(paths []string) []string {
out := []string{}
for _, p := range paths {
out = append(out, filepath.Base(p))
}
return out
}