diff --git a/cmd/videnc/main.go b/cmd/videnc/main.go
index 6861660..6b8e71a 100644
--- a/cmd/videnc/main.go
+++ b/cmd/videnc/main.go
@@ -6,18 +6,22 @@ import (
"encoding/hex"
"flag"
"fmt"
+ "net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
+ "time"
"videnc-vibe/internal/config"
"videnc-vibe/internal/encoder"
"videnc-vibe/internal/logger"
"videnc-vibe/internal/metadata"
"videnc-vibe/internal/mover"
+ "videnc-vibe/internal/server"
+ "videnc-vibe/internal/status"
"videnc-vibe/internal/watcher"
"videnc-vibe/pkg/types"
)
@@ -53,7 +57,8 @@ func main() {
}
defer log.Close()
- enc := encoder.New(log)
+ tracker := status.New()
+ enc := encoder.New(log, tracker)
if err := enc.CheckDeps(); err != nil {
log.Error("Dependency check failed", err.Error())
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)
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.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")
}
-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))
+ // 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)
isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename)
diff --git a/internal/config/config.go b/internal/config/config.go
index 4c67297..d1573f1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -79,6 +79,10 @@ func Load(configPath string) (*types.Config, error) {
if cfg.LogRetentionDays == 0 {
cfg.LogRetentionDays = 7
}
+ if cfg.HTTPAddr == nil {
+ def := ":8080"
+ cfg.HTTPAddr = &def
+ }
return &cfg, nil
}
diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go
index 27e6bf0..7383afb 100644
--- a/internal/encoder/encoder.go
+++ b/internal/encoder/encoder.go
@@ -1,6 +1,7 @@
package encoder
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -10,8 +11,10 @@ import (
"sort"
"strconv"
"strings"
+ "time"
"videnc-vibe/internal/logger"
+ "videnc-vibe/internal/status"
"videnc-vibe/pkg/types"
)
@@ -22,6 +25,7 @@ type Encoder struct {
ffprobePath string
opusencPath string
log *logger.Logger
+ tracker *status.Tracker
}
type StreamInfo struct {
@@ -45,12 +49,13 @@ type StreamMetadata struct {
Title string `json:"title"`
}
-func New(log *logger.Logger) *Encoder {
+func New(log *logger.Logger, tracker *status.Tracker) *Encoder {
return &Encoder{
ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe",
opusencPath: "opusenc",
log: log,
+ tracker: tracker,
}
}
@@ -71,6 +76,82 @@ func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []s
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 {
if _, err := exec.LookPath(e.ffmpegPath); err != nil {
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 {
+ if e.tracker != nil {
+ e.tracker.SetPhase(status.PhaseAudio)
+ }
audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs)
if err != nil {
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 {
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")
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([]string{"-hide_banner", "-v", "error"}, args...)
- out, err := e.runCmd(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
+ args = append([]string{"-hide_banner", "-v", "error", "-progress", "pipe:1", "-nostats"}, args...)
+ out, err := e.runCmdProgress(ctx, "ffmpeg encode", input, e.ffmpegPath, args)
if err != nil {
return fmt.Errorf("ffmpeg encode: %s %w", out, err)
}
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 16eb701..bfe7408 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -87,6 +87,13 @@ func (l *Logger) Info(message string) {
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) {
msg := fmt.Sprintf("%s: %s", message, errStr)
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)
}
+// 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() {
if l.db == nil {
return
diff --git a/internal/server/index.html b/internal/server/index.html
new file mode 100644
index 0000000..5d62250
--- /dev/null
+++ b/internal/server/index.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+videnc·vibe — status
+
+
+
+
+
+ $ videnc·vibe
+ connecting
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..03d0368
--- /dev/null
+++ b/internal/server/server.go
@@ -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,
+ })
+}
diff --git a/internal/status/status.go b/internal/status/status.go
new file mode 100644
index 0000000..9083f48
--- /dev/null
+++ b/internal/status/status.go
@@ -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
+}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
new file mode 100644
index 0000000..fbcc4cd
--- /dev/null
+++ b/internal/status/status_test.go
@@ -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)
+ }
+}
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
index 8f5b2ea..f4cc2cc 100644
--- a/internal/watcher/watcher.go
+++ b/internal/watcher/watcher.go
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "sort"
"time"
"videnc-vibe/pkg/types"
@@ -28,6 +29,19 @@ var inputExtensions = []string{
"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
// between two consecutive ticks.
type fileStat struct {
diff --git a/pkg/types/types.go b/pkg/types/types.go
index 9180869..9ba7886 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -34,8 +34,11 @@ type Metadata struct {
type Config struct {
OMDBAPIKey string `yaml:"omdb_api_key"`
LogRetentionDays int `yaml:"log_retention_days"`
- Encoding EncodingConfig
- Paths PathsConfig
+ // 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
+ Paths PathsConfig
}
type EncodingConfig struct {