Files
Esa Kataja 04d6c85712 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
2026-06-21 20:38:21 +03:00

111 lines
3.2 KiB
Go

package status
import (
"strings"
"testing"
)
func TestScanProgress(t *testing.T) {
tests := []struct {
name string
input string
wantOutSec, wantFPS, wantSpd float64
wantDone bool
}{
{
name: "out_time_us preferred over ms and string",
input: "frame=120\nfps=24.00\nout_time_us=5000000\nout_time_ms=9999999\nout_time=00:00:09.000000\nspeed=1.02x\nprogress=continue\n",
wantOutSec: 5, wantFPS: 24, wantSpd: 1.02, wantDone: false,
},
{
name: "out_time string fallback when no us field",
input: "fps=12\nout_time=00:01:30.500000\nspeed=0.09x\nprogress=continue\n",
wantOutSec: 90.5, wantFPS: 12, wantSpd: 0.09, wantDone: false,
},
{
name: "end block",
input: "out_time_us=7200000000\nspeed=2x\nprogress=end\n",
wantOutSec: 7200, wantFPS: 0, wantSpd: 2, wantDone: true,
},
{
name: "speed N/A leaves zero",
input: "out_time_us=1000000\nspeed=N/A\nprogress=continue\n",
wantOutSec: 1, wantFPS: 0, wantSpd: 0, wantDone: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var last ProgressSample
n := 0
if err := ScanProgress(strings.NewReader(tt.input), func(s ProgressSample) { last = s; n++ }); err != nil {
t.Fatalf("ScanProgress: %v", err)
}
if n != 1 {
t.Fatalf("got %d samples, want 1", n)
}
if last.OutTimeSec != tt.wantOutSec || last.FPS != tt.wantFPS || last.Speed != tt.wantSpd || last.Done != tt.wantDone {
t.Errorf("got %+v, want out=%v fps=%v spd=%v done=%v", last, tt.wantOutSec, tt.wantFPS, tt.wantSpd, tt.wantDone)
}
})
}
}
func TestScanProgressMultipleBlocks(t *testing.T) {
in := "out_time_us=1000000\nspeed=1x\nprogress=continue\nout_time_us=2000000\nspeed=1x\nprogress=end\n"
var got []ProgressSample
if err := ScanProgress(strings.NewReader(in), func(s ProgressSample) { got = append(got, s) }); err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d blocks, want 2", len(got))
}
if got[0].OutTimeSec != 1 || got[1].OutTimeSec != 2 || got[0].Done || !got[1].Done {
t.Errorf("blocks = %+v", got)
}
}
func TestSnapshotPercentAndETA(t *testing.T) {
tr := New()
tr.Begin("episode.mkv")
tr.SetTotal(100)
tr.Update(25, 10, 0.5) // 25% done, 75s left at 0.5x -> 150s ETA
s := tr.Snapshot()
if s.Percent != 25 {
t.Errorf("Percent = %v, want 25", s.Percent)
}
if s.ETASec != 150 {
t.Errorf("ETASec = %v, want 150", s.ETASec)
}
if s.File != "episode.mkv" || s.Phase != PhaseProbing {
t.Errorf("File/Phase = %q/%q", s.File, s.Phase)
}
}
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 {
t.Errorf("idle snapshot = %+v", s)
}
}