diff --git a/cmd/av1dae/main.go b/cmd/av1dae/main.go
index 33bb9f3..dd06dc8 100644
--- a/cmd/av1dae/main.go
+++ b/cmd/av1dae/main.go
@@ -113,7 +113,12 @@ func main() {
}
if addr := *cfg.HTTPAddr; addr != "" {
- srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, store, controls, cfg.Paths.Input, cfg.Paths.Failed).Handler()}
+ probeDuration := func(p string) (float64, error) {
+ pctx, pcancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer pcancel()
+ return enc.GetDuration(pctx, p)
+ }
+ srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, store, controls, probeDuration, cfg.Paths.Input, cfg.Paths.Failed).Handler()}
go func() {
log.Info(fmt.Sprintf("Status server listening on %s", addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
diff --git a/internal/server/index.html b/internal/server/index.html
index 302ff6c..a05a997 100644
--- a/internal/server/index.html
+++ b/internal/server/index.html
@@ -97,6 +97,7 @@
.qsum { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; margin-bottom:.8rem; }
.qsum .qn { font-family:var(--mono); font-size:1.4rem; color:var(--amber); line-height:1; }
.qsum .ql { color:var(--muted); font-size:.85rem; }
+ .qsum .qeta { font-family:var(--mono); font-size:.8rem; color:var(--amber-soft); margin-left:auto; }
ul.list { list-style:none; margin:0; padding:0; font-family:var(--mono); font-size:.82rem; }
ul.list li { padding:.35rem 0; border-bottom:1px solid var(--line); color:var(--muted); word-break:break-word; }
ul.list li:last-child { border-bottom:0; }
@@ -325,7 +326,8 @@
for (const s of arr) { while (!s.startsWith(p)) p = p.slice(0, -1); if (!p) break; }
return p;
}
- function renderQueue(queue, current) {
+ function renderQueue(d) {
+ const queue = d.queue, current = d.current;
const cur = base(current && current.file);
const pending = (queue || []).filter(f => f !== cur);
const el = $("queue");
@@ -334,7 +336,10 @@
return;
}
const pre = pending.length > 1 ? commonPrefix(pending) : "";
- const head = `
${pending.length}${pending.length === 1 ? "file" : "files"} waiting
`;
+ const eta = d.queue_eta_sec > 0
+ ? `~${fmtDurLong(d.queue_eta_sec)}${d.queue_eta_partial ? "+" : ""} to clear`
+ : "";
+ const head = `${pending.length}${pending.length === 1 ? "file" : "files"} waiting${eta}
`;
const rows = pending.slice(0, 3).map(f => {
const tail = pre ? f.slice(pre.length) : f;
return `${pre ? `${esc(pre)}` : ""}${esc(tail)}`;
@@ -429,7 +434,7 @@
renderControls(d);
renderJob(d.current);
renderFailed(d.failed);
- renderQueue(d.queue, d.current);
+ renderQueue(d);
renderEvents(d.recent);
const active = d.current && d.current.file && d.current.phase !== "idle";
setLive(d.current && d.current.paused ? "idle" : active ? "ok" : "idle");
diff --git a/internal/server/server.go b/internal/server/server.go
index 58db645..ebb4a7a 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -6,7 +6,10 @@ import (
_ "embed"
"encoding/json"
"net/http"
+ "os"
"path/filepath"
+ "sync"
+ "time"
"av1dae/internal/logger"
"av1dae/internal/settings"
@@ -35,12 +38,91 @@ type Server struct {
log *logger.Logger
store *settings.Store
controls Controls
+ durCache *durationCache
inputDir string
failedDir string
}
-func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, controls Controls, inputDir, failedDir string) *Server {
- return &Server{tracker: tracker, log: log, store: store, controls: controls, inputDir: inputDir, failedDir: failedDir}
+func New(tracker *status.Tracker, log *logger.Logger, store *settings.Store, controls Controls, probeDuration func(string) (float64, error), inputDir, failedDir string) *Server {
+ return &Server{
+ tracker: tracker,
+ log: log,
+ store: store,
+ controls: controls,
+ durCache: newDurationCache(probeDuration),
+ inputDir: inputDir,
+ failedDir: failedDir,
+ }
+}
+
+type durEntry struct {
+ mtime time.Time
+ sec float64
+}
+
+// durationCache memoizes source durations (keyed by path+mtime) so the queue
+// ETA doesn't re-probe every file on every 1s poll. A miss kicks a background
+// ffprobe and resolves on a later poll; the file shows as "estimating" until.
+type durationCache struct {
+ probe func(string) (float64, error)
+ mu sync.Mutex
+ m map[string]durEntry
+ inflight map[string]bool
+}
+
+func newDurationCache(probe func(string) (float64, error)) *durationCache {
+ return &durationCache{probe: probe, m: map[string]durEntry{}, inflight: map[string]bool{}}
+}
+
+func (c *durationCache) Get(path string, mtime time.Time) (float64, bool) {
+ c.mu.Lock()
+ if e, ok := c.m[path]; ok && e.mtime.Equal(mtime) {
+ c.mu.Unlock()
+ return e.sec, true
+ }
+ if c.inflight[path] {
+ c.mu.Unlock()
+ return 0, false
+ }
+ c.inflight[path] = true
+ c.mu.Unlock()
+
+ go func() {
+ sec, err := c.probe(path)
+ c.mu.Lock()
+ delete(c.inflight, path)
+ if err == nil {
+ c.m[path] = durEntry{mtime, sec}
+ }
+ c.mu.Unlock()
+ }()
+ return 0, false
+}
+
+// Retain drops cached entries for paths no longer present, bounding the map on
+// a long-running daemon.
+func (c *durationCache) Retain(paths []string) {
+ keep := make(map[string]bool, len(paths))
+ for _, p := range paths {
+ keep[p] = true
+ }
+ c.mu.Lock()
+ for p := range c.m {
+ if !keep[p] {
+ delete(c.m, p)
+ }
+ }
+ c.mu.Unlock()
+}
+
+// queueETASeconds estimates wall-clock time to clear the queue: the current
+// job's remaining time plus each pending file's duration / current speed.
+// 0 when there's no speed to extrapolate from (idle/held).
+func queueETASeconds(currentETASec int, sumQueuedSec, speed float64) int {
+ if speed <= 0 {
+ return 0
+ }
+ return currentETASec + int(sumQueuedSec/speed)
}
// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML
@@ -157,11 +239,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
type statusResponse struct {
- Running bool `json:"running"`
- Current status.Snapshot `json:"current"`
- Queue []string `json:"queue"`
- Failed []string `json:"failed"`
- Recent []logger.LogEntry `json:"recent"`
+ Running bool `json:"running"`
+ Current status.Snapshot `json:"current"`
+ Queue []string `json:"queue"`
+ Failed []string `json:"failed"`
+ QueueETASec int `json:"queue_eta_sec"`
+ QueueETAPartial bool `json:"queue_eta_partial"`
+ Recent []logger.LogEntry `json:"recent"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -171,13 +255,39 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
return
}
+ snap := s.tracker.Snapshot()
+ files := watcher.InputFiles(s.inputDir)
+
+ // Sum durations of pending files (excluding the current job, whose remaining
+ // time is already in snap.ETASec). Unprobed files mark the estimate partial.
+ var sumQueued float64
+ partial := false
+ for _, f := range files {
+ if f == snap.File {
+ continue
+ }
+ info, statErr := os.Stat(f)
+ if statErr != nil {
+ partial = true
+ continue
+ }
+ if sec, ok := s.durCache.Get(f, info.ModTime()); ok {
+ sumQueued += sec
+ } else {
+ partial = true
+ }
+ }
+ s.durCache.Retain(files)
+
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(statusResponse{
- Running: s.controls.Running(),
- Current: s.tracker.Snapshot(),
- Queue: baseNames(watcher.InputFiles(s.inputDir)),
- Failed: baseNames(watcher.InputFiles(s.failedDir)),
- Recent: recent,
+ Running: s.controls.Running(),
+ Current: snap,
+ Queue: baseNames(files),
+ Failed: baseNames(watcher.InputFiles(s.failedDir)),
+ QueueETASec: queueETASeconds(snap.ETASec, sumQueued, snap.Speed),
+ QueueETAPartial: partial,
+ Recent: recent,
})
}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
new file mode 100644
index 0000000..646f4d3
--- /dev/null
+++ b/internal/server/server_test.go
@@ -0,0 +1,62 @@
+package server
+
+import (
+ "testing"
+ "time"
+)
+
+func TestQueueETASeconds(t *testing.T) {
+ // No speed → can't extrapolate.
+ if got := queueETASeconds(100, 3600, 0); got != 0 {
+ t.Errorf("speed 0: got %d, want 0", got)
+ }
+ // current remaining 600s + 1800s of queued source at 0.5x (=3600s) = 4200s.
+ if got := queueETASeconds(600, 1800, 0.5); got != 4200 {
+ t.Errorf("got %d, want 4200", got)
+ }
+ // At 2x, 1800s of source encodes in 900s; + 600 remaining = 1500.
+ if got := queueETASeconds(600, 1800, 2); got != 1500 {
+ t.Errorf("got %d, want 1500", got)
+ }
+ // Empty queue → just the current job's remaining.
+ if got := queueETASeconds(600, 0, 1); got != 600 {
+ t.Errorf("got %d, want 600", got)
+ }
+}
+
+// waitCached polls Get (the probe resolves on a background goroutine).
+func waitCached(c *durationCache, p string, mt time.Time) (float64, bool) {
+ for i := 0; i < 200; i++ {
+ if sec, ok := c.Get(p, mt); ok {
+ return sec, true
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+ return 0, false
+}
+
+func TestDurationCache(t *testing.T) {
+ c := newDurationCache(func(p string) (float64, error) { return 42, nil })
+
+ mt := time.Unix(1000, 0)
+ if _, ok := c.Get("/a.mkv", mt); ok {
+ t.Fatal("first Get should miss")
+ }
+ if sec, ok := waitCached(c, "/a.mkv", mt); !ok || sec != 42 {
+ t.Fatalf("after probe: got %v ok=%v, want 42 true", sec, ok)
+ }
+
+ // A changed mtime invalidates the entry.
+ if _, ok := c.Get("/a.mkv", time.Unix(2000, 0)); ok {
+ t.Error("changed mtime should miss")
+ }
+ if _, ok := waitCached(c, "/a.mkv", time.Unix(2000, 0)); !ok {
+ t.Fatal("re-probe under new mtime should cache")
+ }
+
+ // Retain drops entries for paths not in the keep set.
+ c.Retain([]string{"/b.mkv"})
+ if _, ok := c.Get("/a.mkv", time.Unix(2000, 0)); ok {
+ t.Error("Retain should have dropped /a.mkv")
+ }
+}