add: live encode progress tracking and read-only status web UI
Stream ffmpeg -progress during the video encode into an in-memory tracker (internal/status) so the long-running step is no longer a black box: percent, fps, speed, and ETA are derived from the source duration and updated ~1/s. Progress prints to stdout only (no logs.db row) to avoid burying real events. Expose it over HTTP (internal/server, default :8080, set http_addr to "" to disable): GET /status returns the live snapshot, the pending input queue, and recent non-debug events; GET / serves an embedded dashboard that polls /status every second. The server shuts down on the same SIGINT/SIGTERM context as the watcher.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// Package status tracks the live state of the single in-flight encode job.
|
||||
// The watcher processes one file at a time, so one mutex-guarded value is
|
||||
// enough — no per-job table, no concurrency design.
|
||||
package status
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase labels for the current job.
|
||||
const (
|
||||
PhaseIdle = "idle"
|
||||
PhaseProbing = "probing"
|
||||
PhaseAudio = "audio"
|
||||
PhaseEncoding = "encoding"
|
||||
)
|
||||
|
||||
// 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
|
||||
totalSec float64 // source duration; 0 until known
|
||||
outTime float64 // encoded position in seconds
|
||||
fps float64
|
||||
speed float64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
// Snapshot is an immutable view of the tracker for readers.
|
||||
type Snapshot struct {
|
||||
File string `json:"file"`
|
||||
Phase string `json:"phase"`
|
||||
Percent float64 `json:"percent"`
|
||||
FPS float64 `json:"fps"`
|
||||
Speed float64 `json:"speed"`
|
||||
ElapsedSec int `json:"elapsed_sec"`
|
||||
ETASec int `json:"eta_sec"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func New() *Tracker {
|
||||
return &Tracker{phase: PhaseIdle}
|
||||
}
|
||||
|
||||
// Begin marks the start of a new job, resetting all progress fields.
|
||||
func (t *Tracker) Begin(file string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.file = file
|
||||
t.phase = PhaseProbing
|
||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||
t.startedAt = time.Now()
|
||||
}
|
||||
|
||||
func (t *Tracker) SetPhase(p string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.phase = p
|
||||
}
|
||||
|
||||
// SetTotal records the source duration in seconds (from ffprobe).
|
||||
func (t *Tracker) SetTotal(seconds float64) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.totalSec = seconds
|
||||
}
|
||||
|
||||
// Update records one ffmpeg -progress sample.
|
||||
func (t *Tracker) Update(outTimeSec, fps, speed float64) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.outTime, t.fps, t.speed = outTimeSec, fps, speed
|
||||
}
|
||||
|
||||
// Idle clears the tracker when no job is running.
|
||||
func (t *Tracker) Idle() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.file = ""
|
||||
t.phase = PhaseIdle
|
||||
t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0
|
||||
t.startedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (t *Tracker) Snapshot() Snapshot {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
s := Snapshot{
|
||||
File: t.file,
|
||||
Phase: t.phase,
|
||||
FPS: t.fps,
|
||||
Speed: t.speed,
|
||||
StartedAt: t.startedAt,
|
||||
}
|
||||
if !t.startedAt.IsZero() {
|
||||
s.ElapsedSec = int(time.Since(t.startedAt).Seconds())
|
||||
}
|
||||
if t.totalSec > 0 {
|
||||
s.Percent = t.outTime / t.totalSec * 100
|
||||
if s.Percent > 100 {
|
||||
s.Percent = 100
|
||||
}
|
||||
if t.speed > 0 {
|
||||
remaining := t.totalSec - t.outTime
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
s.ETASec = int(remaining / t.speed)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ProgressSample is one completed ffmpeg -progress block.
|
||||
type ProgressSample struct {
|
||||
OutTimeSec float64
|
||||
FPS float64
|
||||
Speed float64
|
||||
Done bool // progress=end
|
||||
}
|
||||
|
||||
// ScanProgress reads ffmpeg `-progress` key=value output from r and calls
|
||||
// onSample once per block (each block is terminated by a "progress=" line).
|
||||
// Returns when r is exhausted.
|
||||
func ScanProgress(r io.Reader, onSample func(ProgressSample)) error {
|
||||
sc := bufio.NewScanner(r)
|
||||
var cur ProgressSample
|
||||
for sc.Scan() {
|
||||
if parseProgressLine(sc.Text(), &cur) {
|
||||
onSample(cur)
|
||||
cur = ProgressSample{}
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// parseProgressLine folds one "key=value" line into s. Returns true when the
|
||||
// line closes a block (key == "progress").
|
||||
//
|
||||
// ponytail: ffmpeg field naming drifts between builds — out_time_us is the
|
||||
// modern microsecond field; out_time_ms is historically ALSO microseconds (a
|
||||
// known mislabel); out_time is the "HH:MM:SS.ffffff" string. Prefer out_time_us,
|
||||
// fall back to the others only if it hasn't set a value this block. Verify
|
||||
// against the ffmpeg in the Docker image if percentages look off.
|
||||
func parseProgressLine(line string, s *ProgressSample) (complete bool) {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
switch k {
|
||||
case "out_time_us":
|
||||
if us, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.OutTimeSec = us / 1e6
|
||||
}
|
||||
case "out_time_ms":
|
||||
if s.OutTimeSec == 0 {
|
||||
if ms, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.OutTimeSec = ms / 1e6 // really microseconds, see note above
|
||||
}
|
||||
}
|
||||
case "out_time":
|
||||
if s.OutTimeSec == 0 {
|
||||
s.OutTimeSec = parseTimecode(v)
|
||||
}
|
||||
case "fps":
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
s.FPS = f
|
||||
}
|
||||
case "speed":
|
||||
if f, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil {
|
||||
s.Speed = f // "N/A" leaves it 0
|
||||
}
|
||||
case "progress":
|
||||
s.Done = v == "end"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTimecode parses "HH:MM:SS.ffffff" into seconds; 0 on bad input.
|
||||
func parseTimecode(tc string) float64 {
|
||||
parts := strings.Split(tc, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
h, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
m, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
sec, err3 := strconv.ParseFloat(parts[2], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0
|
||||
}
|
||||
return h*3600 + m*60 + sec
|
||||
}
|
||||
Reference in New Issue
Block a user