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:
Esa Kataja
2026-06-21 17:43:00 +03:00
parent 4147cb9470
commit aa8a341069
10 changed files with 740 additions and 8 deletions
+29 -3
View File
@@ -6,18 +6,22 @@ import (
"encoding/hex" "encoding/hex"
"flag" "flag"
"fmt" "fmt"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"syscall" "syscall"
"time"
"videnc-vibe/internal/config" "videnc-vibe/internal/config"
"videnc-vibe/internal/encoder" "videnc-vibe/internal/encoder"
"videnc-vibe/internal/logger" "videnc-vibe/internal/logger"
"videnc-vibe/internal/metadata" "videnc-vibe/internal/metadata"
"videnc-vibe/internal/mover" "videnc-vibe/internal/mover"
"videnc-vibe/internal/server"
"videnc-vibe/internal/status"
"videnc-vibe/internal/watcher" "videnc-vibe/internal/watcher"
"videnc-vibe/pkg/types" "videnc-vibe/pkg/types"
) )
@@ -53,7 +57,8 @@ func main() {
} }
defer log.Close() defer log.Close()
enc := encoder.New(log) tracker := status.New()
enc := encoder.New(log, tracker)
if err := enc.CheckDeps(); err != nil { if err := enc.CheckDeps(); err != nil {
log.Error("Dependency check failed", err.Error()) log.Error("Dependency check failed", err.Error())
fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err) fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err)
@@ -65,17 +70,38 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
if addr := *cfg.HTTPAddr; addr != "" {
srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, cfg.Paths.Input).Handler()}
go func() {
log.Info(fmt.Sprintf("Status server listening on %s", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("Status server", err.Error())
}
}()
go func() {
<-ctx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
}()
}
w := watcher.New(cfg.Paths.Input, 15) w := watcher.New(cfg.Paths.Input, 15)
w.Start(ctx, func(ctx context.Context, inputPath string) error { w.Start(ctx, func(ctx context.Context, inputPath string) error {
return processFile(ctx, inputPath, cfg, enc, metaClient, log) return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker)
}) })
log.Info("videnc-vibe started") log.Info("videnc-vibe started")
} }
func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger) error { func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker) error {
log.Info(fmt.Sprintf("Processing: %s", inputPath)) log.Info(fmt.Sprintf("Processing: %s", inputPath))
// Mark this file as the active job (phase: probing) and clear the tracker
// back to idle on every exit path — success, failure, or cancellation.
tracker.Begin(inputPath)
defer tracker.Idle()
filename := filepath.Base(inputPath) filename := filepath.Base(inputPath)
isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename) isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename)
+4
View File
@@ -79,6 +79,10 @@ func Load(configPath string) (*types.Config, error) {
if cfg.LogRetentionDays == 0 { if cfg.LogRetentionDays == 0 {
cfg.LogRetentionDays = 7 cfg.LogRetentionDays = 7
} }
if cfg.HTTPAddr == nil {
def := ":8080"
cfg.HTTPAddr = &def
}
return &cfg, nil return &cfg, nil
} }
+99 -3
View File
@@ -1,6 +1,7 @@
package encoder package encoder
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -10,8 +11,10 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time"
"videnc-vibe/internal/logger" "videnc-vibe/internal/logger"
"videnc-vibe/internal/status"
"videnc-vibe/pkg/types" "videnc-vibe/pkg/types"
) )
@@ -22,6 +25,7 @@ type Encoder struct {
ffprobePath string ffprobePath string
opusencPath string opusencPath string
log *logger.Logger log *logger.Logger
tracker *status.Tracker
} }
type StreamInfo struct { type StreamInfo struct {
@@ -45,12 +49,13 @@ type StreamMetadata struct {
Title string `json:"title"` Title string `json:"title"`
} }
func New(log *logger.Logger) *Encoder { func New(log *logger.Logger, tracker *status.Tracker) *Encoder {
return &Encoder{ return &Encoder{
ffmpegPath: "ffmpeg", ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe", ffprobePath: "ffprobe",
opusencPath: "opusenc", opusencPath: "opusenc",
log: log, log: log,
tracker: tracker,
} }
} }
@@ -71,6 +76,82 @@ func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []s
return out, err return out, err
} }
// runCmdProgress runs ffmpeg with `-progress pipe:1`, streaming progress
// samples to the tracker (instead of buffering all output like runCmd). stdout
// carries only the key=value progress stream; stderr carries real errors and is
// returned for the caller's error message. Used solely for the video encode —
// the one step long enough to be worth watching live.
func (e *Encoder) runCmdProgress(ctx context.Context, label, file, name string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
// Reads stdout to EOF (when ffmpeg exits), so Wait below is safe afterwards.
var lastLog time.Time
_ = status.ScanProgress(stdout, func(s status.ProgressSample) {
if e.tracker == nil {
return
}
e.tracker.Update(s.OutTimeSec, s.FPS, s.Speed)
if time.Since(lastLog) >= 5*time.Second {
lastLog = time.Now()
snap := e.tracker.Snapshot()
e.log.Progress(fmt.Sprintf("encoding %s · %.1f%% · %.1ffps · %.2fx · ETA %s",
filepath.Base(file), snap.Percent, snap.FPS, snap.Speed, fmtETA(snap.ETASec)))
}
})
err = cmd.Wait()
extra, _ := json.Marshal(struct {
Cmd string `json:"cmd"`
Output string `json:"output"`
}{
Cmd: name + " " + strings.Join(args, " "),
Output: stderr.String(),
})
e.log.Debug(label, file, string(extra))
return stderr.Bytes(), err
}
// fmtETA renders a seconds count as a compact "11h03m" / "4m12s" / "9s" string.
func fmtETA(sec int) string {
if sec <= 0 {
return "--"
}
d := time.Duration(sec) * time.Second
switch {
case d >= time.Hour:
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
case d >= time.Minute:
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), sec%60)
default:
return fmt.Sprintf("%ds", sec)
}
}
// GetDuration returns the source container duration in seconds via ffprobe.
func (e *Encoder) GetDuration(ctx context.Context, path string) (float64, error) {
args := []string{"-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path}
out, err := e.runCmd(ctx, "ffprobe duration", path, e.ffprobePath, args)
if err != nil {
return 0, fmt.Errorf("ffprobe duration: %w", err)
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0, fmt.Errorf("parsing duration %q: %w", strings.TrimSpace(string(out)), err)
}
return d, nil
}
func (e *Encoder) CheckDeps() error { func (e *Encoder) CheckDeps() error {
if _, err := exec.LookPath(e.ffmpegPath); err != nil { if _, err := exec.LookPath(e.ffmpegPath); err != nil {
return fmt.Errorf("ffmpeg not found") return fmt.Errorf("ffmpeg not found")
@@ -246,6 +327,9 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina
} }
func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
if e.tracker != nil {
e.tracker.SetPhase(status.PhaseAudio)
}
audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs) audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs)
if err != nil { if err != nil {
return fmt.Errorf("extracting audio: %w", err) return fmt.Errorf("extracting audio: %w", err)
@@ -319,6 +403,18 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string)
func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error {
outFile := filepath.Join(workDir, "output.mkv") outFile := filepath.Join(workDir, "output.mkv")
// Switch the tracker to the encode phase and feed it the source duration so
// progress samples can be turned into a percentage. A failed duration probe
// just means no percent — it must not abort the encode.
if e.tracker != nil {
e.tracker.SetPhase(status.PhaseEncoding)
if dur, derr := e.GetDuration(ctx, input); derr == nil {
e.tracker.SetTotal(dur)
} else {
e.log.Debug("duration probe failed", input, derr.Error())
}
}
svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s") svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s")
zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0) zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0)
@@ -412,8 +508,8 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi
args = append(args, outFile) args = append(args, outFile)
args = append([]string{"-hide_banner", "-v", "error"}, args...) args = append([]string{"-hide_banner", "-v", "error", "-progress", "pipe:1", "-nostats"}, args...)
out, err := e.runCmd(ctx, "ffmpeg encode", input, e.ffmpegPath, args) out, err := e.runCmdProgress(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
if err != nil { if err != nil {
return fmt.Errorf("ffmpeg encode: %s %w", out, err) return fmt.Errorf("ffmpeg encode: %s %w", out, err)
} }
+41
View File
@@ -87,6 +87,13 @@ func (l *Logger) Info(message string) {
l.write(LevelInfo, message, "", "") l.write(LevelInfo, message, "", "")
} }
// Progress prints to stdout only, without a db row. For high-frequency,
// ephemeral telemetry (live encode progress) that would otherwise bury real
// events in logs.db and churn until retention purges it.
func (l *Logger) Progress(message string) {
fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
}
func (l *Logger) Error(message, errStr string) { func (l *Logger) Error(message, errStr string) {
msg := fmt.Sprintf("%s: %s", message, errStr) msg := fmt.Sprintf("%s: %s", message, errStr)
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg) fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg)
@@ -106,6 +113,40 @@ func (l *Logger) Debug(message, file, extra string) {
l.write(LevelDebug, message, file, extra) l.write(LevelDebug, message, file, extra)
} }
// LogEntry is one row returned by RecentLogs, shaped for the status feed.
type LogEntry struct {
TS string `json:"ts"`
Level string `json:"level"`
Message string `json:"message"`
File string `json:"file,omitempty"`
}
// RecentLogs returns the newest-first non-debug entries, capped at n. Debug
// rows (per-command ffprobe/ffmpeg/API dumps) are excluded — the status feed
// wants real events, not invocation noise.
func (l *Logger) RecentLogs(n int) ([]LogEntry, error) {
if l.db == nil {
return nil, nil
}
rows, err := l.db.Query(
`SELECT ts, level, message, COALESCE(file, '') FROM logs WHERE level != ? ORDER BY id DESC LIMIT ?`,
LevelDebug, n)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LogEntry
for rows.Next() {
var e LogEntry
if err := rows.Scan(&e.TS, &e.Level, &e.Message, &e.File); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (l *Logger) Close() { func (l *Logger) Close() {
if l.db == nil { if l.db == nil {
return return
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>videnc·vibe — status</title>
<style>
:root {
--bg:#0d1117; --panel:#151b23; --panel-2:#1a2230; --line:#26303f;
--text:#cdd5df; --muted:#7d8896; --dim:#5a6573;
--amber:#ffb454; --amber-soft:#ffd9a0; --cyan:#56c7e8; --green:#5cc98b; --red:#f0816a;
--mono:"SF Mono",ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:16px; line-height:1.5;
-webkit-font-smoothing:antialiased;
padding:clamp(1.25rem,4vw,2.5rem); max-width:920px; margin:0 auto;
}
a { color:var(--cyan); }
/* header */
header { display:flex; align-items:center; gap:1rem; margin-bottom:1.75rem; }
.wordmark { font-family:var(--mono); font-weight:600; font-size:1.4rem; letter-spacing:-.02em; }
.wordmark .prompt { color:var(--dim); }
.wordmark .vibe { color:var(--amber); }
.live { margin-left:auto; display:flex; align-items:center; gap:.5rem; font-family:var(--mono); font-size:.74rem; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--dim); }
.dot.ok { background:var(--green); box-shadow:0 0 0 0 rgba(92,201,139,.6); animation:pulse 2s infinite; }
.dot.idle { background:var(--amber); }
.dot.err { background:var(--red); }
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,201,139,.5);} 70%{box-shadow:0 0 0 7px rgba(92,201,139,0);} 100%{box-shadow:0 0 0 0 rgba(92,201,139,0);} }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1.4rem 1.5rem; margin-bottom:1.25rem; }
.eyebrow { font-family:var(--mono); font-size:.7rem; letter-spacing:.16em; text-transform:uppercase; color:var(--dim); margin:0 0 .9rem; }
/* current job */
.job-head { display:flex; align-items:baseline; gap:.75rem; flex-wrap:wrap; margin-bottom:.2rem; }
.job-file { font-size:1.15rem; font-weight:600; color:#e7edf4; word-break:break-word; }
.badge { font-family:var(--mono); font-size:.66rem; letter-spacing:.08em; text-transform:uppercase; padding:.2rem .5rem; border-radius:5px; border:1px solid var(--line); color:var(--muted); }
.badge[data-phase="encoding"] { color:var(--amber); border-color:var(--amber); }
.badge[data-phase="probing"], .badge[data-phase="audio"] { color:var(--cyan); border-color:var(--cyan); }
.badge[data-phase="idle"] { color:var(--dim); }
.pct { font-family:var(--mono); font-size:2.6rem; font-weight:600; line-height:1; margin:.6rem 0 .5rem; color:var(--amber); }
.pct.idle { color:var(--dim); font-size:1.4rem; }
.bar { height:10px; border-radius:6px; background:var(--panel-2); overflow:hidden; border:1px solid var(--line); }
.bar > i { display:block; height:100%; width:0; background:linear-gradient(90deg,var(--amber-soft),var(--amber)); border-radius:6px; transition:width .6s ease; }
.bar.indet > i { width:35%; background:linear-gradient(90deg,transparent,var(--cyan),transparent); animation:slide 1.3s linear infinite; transition:none; }
@keyframes slide { 0%{transform:translateX(-120%);} 100%{transform:translateX(340%);} }
@media (prefers-reduced-motion:reduce){ .dot.ok{animation:none;} .bar.indet>i{animation:none; width:100%; opacity:.4;} }
.stats { display:flex; flex-wrap:wrap; gap:1.5rem; margin-top:1.1rem; }
.stat { font-family:var(--mono); }
.stat .v { font-size:1.15rem; color:var(--text); }
.stat .k { font-size:.66rem; letter-spacing:.1em; text-transform:uppercase; color:var(--dim); margin-top:.1rem; }
.idle-msg { color:var(--muted); }
/* grid: queue + events */
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; }
@media (max-width:680px){ .grid { grid-template-columns:1fr; } }
.count { font-family:var(--mono); color:var(--amber); }
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.85rem; }
ul.list li { padding:.4rem 0; border-bottom:1px solid var(--line); color:var(--text); word-break:break-word; }
ul.list li:last-child { border-bottom:0; }
ul.list li.empty { color:var(--dim); border:0; }
.ev { display:flex; gap:.6rem; padding:.4rem 0; border-bottom:1px solid var(--line); font-size:.82rem; }
.ev:last-child { border-bottom:0; }
.ev time { font-family:var(--mono); font-size:.7rem; color:var(--dim); white-space:nowrap; padding-top:.1rem; }
.ev .msg { color:var(--text); word-break:break-word; }
.ev.error .msg { color:var(--red); }
</style>
</head>
<body>
<header>
<span class="wordmark"><span class="prompt">$ </span>videnc<span class="vibe">·vibe</span></span>
<span class="live"><span class="dot" id="dot"></span><span id="livetext">connecting</span></span>
</header>
<section class="card" id="job">
<p class="eyebrow">Current job</p>
<div id="job-body"></div>
</section>
<div class="grid">
<section class="card">
<p class="eyebrow">Queue <span class="count" id="qcount"></span></p>
<ul class="list" id="queue"></ul>
</section>
<section class="card">
<p class="eyebrow">Recent events</p>
<div id="events"></div>
</section>
</div>
<script>
const $ = id => document.getElementById(id);
function fmtDur(sec) {
sec = Math.max(0, Math.floor(sec || 0));
if (sec <= 0) return "--";
const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
if (h) return `${h}h${String(m).padStart(2,"0")}m`;
if (m) return `${m}m${String(s).padStart(2,"0")}s`;
return `${s}s`;
}
function base(path) { return (path || "").split("/").pop(); }
function renderJob(c) {
const body = $("job-body");
if (!c || c.phase === "idle" || !c.file) {
body.innerHTML = `<div class="pct idle">Idle</div><p class="idle-msg">Waiting for files in <code>input/</code>.</p>`;
return;
}
const encoding = c.phase === "encoding" && c.percent > 0;
const pct = encoding ? c.percent.toFixed(1) + "%" : "preparing";
const barClass = encoding ? "bar" : "bar indet";
const barW = encoding ? c.percent : 0;
body.innerHTML = `
<div class="job-head">
<span class="job-file">${base(c.file)}</span>
<span class="badge" data-phase="${c.phase}">${c.phase}</span>
</div>
<div class="pct${encoding ? "" : " idle"}">${pct}</div>
<div class="${barClass}" role="progressbar" aria-valuenow="${encoding ? Math.round(c.percent) : 0}" aria-valuemin="0" aria-valuemax="100"><i style="width:${barW}%"></i></div>
<div class="stats">
<div class="stat"><div class="v">${(c.fps||0).toFixed(1)}</div><div class="k">fps</div></div>
<div class="stat"><div class="v">${(c.speed||0).toFixed(2)}×</div><div class="k">speed</div></div>
<div class="stat"><div class="v">${fmtDur(c.eta_sec)}</div><div class="k">eta</div></div>
<div class="stat"><div class="v">${fmtDur(c.elapsed_sec)}</div><div class="k">elapsed</div></div>
</div>`;
}
function renderQueue(queue, current) {
const cur = base(current && current.file);
const pending = (queue || []).filter(f => f !== cur);
$("qcount").textContent = pending.length ? `(${pending.length})` : "";
$("queue").innerHTML = pending.length
? pending.map(f => `<li>${f}</li>`).join("")
: `<li class="empty">Nothing waiting</li>`;
}
function renderEvents(recent) {
$("events").innerHTML = (recent && recent.length)
? recent.map(e => {
const t = (e.ts || "").replace("T", " ").replace("Z", "").slice(0, 19);
const cls = e.level === "error" ? "ev error" : "ev";
return `<div class="${cls}"><time>${t}</time><span class="msg">${e.message}</span></div>`;
}).join("")
: `<div class="ev"><span class="msg" style="color:var(--dim)">No events yet</span></div>`;
}
function setLive(state) {
const dot = $("dot"), txt = $("livetext");
dot.className = "dot " + state;
txt.textContent = state === "ok" ? "live" : state === "idle" ? "idle" : state === "err" ? "offline" : "connecting";
}
async function poll() {
try {
const r = await fetch("status", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
const d = await r.json();
renderJob(d.current);
renderQueue(d.queue, d.current);
renderEvents(d.recent);
const active = d.current && d.current.file && d.current.phase !== "idle";
setLive(active ? "ok" : "idle");
} catch (e) {
setLive("err");
} finally {
setTimeout(poll, 1000);
}
}
poll();
</script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
// 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"
"videnc-vibe/internal/logger"
"videnc-vibe/internal/status"
"videnc-vibe/internal/watcher"
)
//go:embed index.html
var indexHTML []byte
type Server struct {
tracker *status.Tracker
log *logger.Logger
inputDir string
}
func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server {
return &Server{tracker: tracker, log: log, inputDir: inputDir}
}
// 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("/", s.handleIndex)
return mux
}
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 {
Current status.Snapshot `json:"current"`
Queue []string `json:"queue"`
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)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{
Current: s.tracker.Snapshot(),
Queue: queue,
Recent: recent,
})
}
+201
View File
@@ -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
}
+89
View File
@@ -0,0 +1,89 @@
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 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)
}
}
+14
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"time" "time"
"videnc-vibe/pkg/types" "videnc-vibe/pkg/types"
@@ -28,6 +29,19 @@ var inputExtensions = []string{
"webm", "wmv", "flv", "webm", "wmv", "flv",
} }
// InputFiles returns the source files currently in dir that the watcher would
// consider, sorted. The status server uses this to report the pending queue, so
// it stays in sync with inputExtensions.
func InputFiles(dir string) []string {
var files []string
for _, ext := range inputExtensions {
matches, _ := filepath.Glob(filepath.Join(dir, "*."+ext))
files = append(files, matches...)
}
sort.Strings(files)
return files
}
// fileStat is the (mtime, size) pair used to decide whether a file has settled // fileStat is the (mtime, size) pair used to decide whether a file has settled
// between two consecutive ticks. // between two consecutive ticks.
type fileStat struct { type fileStat struct {
+3
View File
@@ -34,6 +34,9 @@ type Metadata struct {
type Config struct { type Config struct {
OMDBAPIKey string `yaml:"omdb_api_key"` OMDBAPIKey string `yaml:"omdb_api_key"`
LogRetentionDays int `yaml:"log_retention_days"` LogRetentionDays int `yaml:"log_retention_days"`
// HTTPAddr is the status server listen address. Absent (nil) defaults to
// ":8080"; an explicit empty string disables the server.
HTTPAddr *string `yaml:"http_addr"`
Encoding EncodingConfig Encoding EncodingConfig
Paths PathsConfig Paths PathsConfig
} }