// Package server exposes a read-only HTTP status endpoint for the daemon: // the live encode progress, the pending input queue, and recent log events. package server import ( _ "embed" "encoding/json" "net/http" "path/filepath" "av1dae/internal/logger" "av1dae/internal/settings" "av1dae/internal/status" "av1dae/internal/watcher" ) //go:embed index.html var indexHTML []byte //go:embed settings.html var settingsHTML []byte // 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 } 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 // dashboard) to this same mux. 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("/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) } // 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) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(indexHTML) } 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) { recent, err := s.log.RecentLogs(50) if err != nil { http.Error(w, "reading logs", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(statusResponse{ Running: s.controls.Running(), Current: s.tracker.Snapshot(), 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 }