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:
+29
-3
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user