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
+46 -11
View File
@@ -43,16 +43,19 @@ type Stream struct {
// Tracker holds live progress for the active job. Safe for concurrent use:
// the encode goroutine writes, HTTP/log readers call Snapshot.
type Tracker struct {
mu sync.RWMutex
file string
phase string
meta *JobMeta
streams []Stream
totalSec float64 // source duration; 0 until known
outTime float64 // encoded position in seconds
fps float64
speed float64
startedAt time.Time
mu sync.RWMutex
file string
phase string
meta *JobMeta
streams []Stream
totalSec float64 // source duration; 0 until known
outTime float64 // encoded position in seconds
fps float64
speed float64
startedAt time.Time
paused bool
pausedAt time.Time // when the current pause began
pausedTotal time.Duration // accumulated paused time this job
}
// Snapshot is an immutable view of the tracker for readers.
@@ -64,6 +67,7 @@ type Snapshot struct {
Percent float64 `json:"percent"`
FPS float64 `json:"fps"`
Speed float64 `json:"speed"`
Paused bool `json:"paused"`
ElapsedSec int `json:"elapsed_sec"`
ETASec int `json:"eta_sec"`
StartedAt time.Time `json:"started_at"`
@@ -83,6 +87,26 @@ func (t *Tracker) Begin(file string) {
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Now()
t.paused = false
t.pausedAt = time.Time{}
t.pausedTotal = 0
}
// SetPaused records pause/resume transitions so elapsed time excludes the
// paused span. Idempotent on repeated same-state calls.
func (t *Tracker) SetPaused(p bool) {
t.mu.Lock()
defer t.mu.Unlock()
if p == t.paused {
return
}
if p {
t.pausedAt = time.Now()
} else if !t.pausedAt.IsZero() {
t.pausedTotal += time.Since(t.pausedAt)
t.pausedAt = time.Time{}
}
t.paused = p
}
func (t *Tracker) SetPhase(p string) {
@@ -129,6 +153,9 @@ func (t *Tracker) Idle() {
t.streams = nil
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
t.startedAt = time.Time{}
t.paused = false
t.pausedAt = time.Time{}
t.pausedTotal = 0
}
func (t *Tracker) Snapshot() Snapshot {
@@ -141,10 +168,18 @@ func (t *Tracker) Snapshot() Snapshot {
Streams: t.streams,
FPS: t.fps,
Speed: t.speed,
Paused: t.paused,
StartedAt: t.startedAt,
}
if !t.startedAt.IsZero() {
s.ElapsedSec = int(time.Since(t.startedAt).Seconds())
elapsed := time.Since(t.startedAt) - t.pausedTotal
if t.paused && !t.pausedAt.IsZero() {
elapsed -= time.Since(t.pausedAt)
}
if elapsed < 0 {
elapsed = 0
}
s.ElapsedSec = int(elapsed.Seconds())
}
if t.totalSec > 0 {
s.Percent = t.outTime / t.totalSec * 100
+21
View File
@@ -81,6 +81,27 @@ func TestSnapshotPercentAndETA(t *testing.T) {
}
}
func TestSetPausedFlag(t *testing.T) {
tr := New()
tr.Begin("x.mkv")
if tr.Snapshot().Paused {
t.Fatal("should not start paused")
}
tr.SetPaused(true)
if !tr.Snapshot().Paused {
t.Error("expected paused after SetPaused(true)")
}
tr.SetPaused(true) // idempotent — must not double-count
tr.SetPaused(false)
if tr.Snapshot().Paused {
t.Error("expected not paused after SetPaused(false)")
}
tr.Idle()
if tr.Snapshot().Paused {
t.Error("Idle should clear paused")
}
}
func TestSnapshotIdleNoDivByZero(t *testing.T) {
s := New().Snapshot() // no Begin, totalSec 0
if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle {