add: queue total ETA from current encode speed
The queue card now shows an estimated time to clear: current job remaining + sum(pending source durations) / current speed. Durations come from a path+mtime-keyed cache that probes each file once in the background (ffprobe format=duration, header-only), so the 1s poll never re-probes; entries are pruned to the live queue. No speed (idle/held) -> no estimate; files still being probed mark it partial (shown as '~Xh+'). Closes #9
This commit is contained in:
+122
-12
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user