feat(trickplay): scan-time montage sprite for the web scrubber
Pre-generate ONE trickplay sprite (montage JPEG of frames sampled every library.trickplay.interval, default 10s) + a JSON manifest per file during the scan/auto-scan prewarm, cached in .unarr next to the media. The web scrubber shows tiles from it instead of extracting frames live — removing the ffmpeg contention with the active stream that broke seekbar previews (the original 'no thumbnail' report was the auto-scan prewarm decoding the same file the HLS transcode was reading, not a seek-index fault). - config: [library.trickplay] enabled/interval/width (default on, 10s, 240px), editable + a toggle; IntervalSeconds() with a 10s fallback. - mediainfo: GenerateTrickplay (one ffmpeg fps=1/interval,scale,tile pass; idle I/O priority; ceil() frame count so no black trailing tile; a 16.7M-px cap coarsens the interval for long media so a single sprite stays decodable on iOS/Safari) + sprite/manifest sidecar cache helpers. - engine: /trickplay endpoint (manifest JSON, ?kind=sprite JPEG); the agent owns the tile width so the web requests by path only; thumb:<sha256> token reused. - prewarm: a trickplay job per item, gated; scan.go + daemon.go wire the config. Tests: parseDims; synthetic 3x2 / exact-multiple / 1x1; real-file e2e smoke (S02E08 → 143 tiles, 662KB sprite). Non-breaking: the existing 5-frame panel prewarm + on-demand /thumbnail stay until the web migrates to the sprite.
This commit is contained in:
parent
7877e1de42
commit
8e37293b7d
7 changed files with 553 additions and 21 deletions
|
|
@ -113,6 +113,13 @@ type StreamServer struct {
|
|||
cacheSubtitles bool
|
||||
cacheThumbnails bool
|
||||
|
||||
// trickplayWidth is the tile width (px) the scan-time prewarm used to build
|
||||
// the trickplay sprite (library.trickplay.width). The /trickplay handler keys
|
||||
// the sidecar lookup on it so the agent owns the width — the web need not know
|
||||
// it. 0 = trickplay disabled (the handler 404s and the web falls back to
|
||||
// on-demand /thumbnail). Set once before Listen() via SetTrickplayWidth.
|
||||
trickplayWidth int
|
||||
|
||||
lastActivity atomic.Int64
|
||||
maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
|
||||
totalFileSize atomic.Int64
|
||||
|
|
@ -227,6 +234,12 @@ func (ss *StreamServer) SetCacheThumbnails(on bool) {
|
|||
ss.cacheThumbnails = on
|
||||
}
|
||||
|
||||
// SetTrickplayWidth records the tile width used to build the trickplay sprite
|
||||
// (library.trickplay.width). 0 leaves trickplay disabled. Call before Listen().
|
||||
func (ss *StreamServer) SetTrickplayWidth(width int) {
|
||||
ss.trickplayWidth = width
|
||||
}
|
||||
|
||||
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
|
||||
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
|
||||
// ports) is always merged in. Call before Listen().
|
||||
|
|
@ -285,6 +298,7 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
|
|||
mux.HandleFunc("/playlist.m3u", ss.playlistHandler)
|
||||
mux.HandleFunc("/hls/", ss.hlsHandler)
|
||||
mux.HandleFunc("/thumbnail", ss.thumbnailHandler)
|
||||
mux.HandleFunc("/trickplay", ss.trickplayHandler)
|
||||
mux.HandleFunc("/sub", ss.subtitleHandler)
|
||||
|
||||
// SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart)
|
||||
|
|
@ -1090,6 +1104,67 @@ func (ss *StreamServer) writeJPEG(w http.ResponseWriter, jpeg []byte) {
|
|||
}
|
||||
}
|
||||
|
||||
// trickplayHandler serves the pre-built trickplay montage sprite (kind=sprite →
|
||||
// JPEG) or its manifest (default → JSON) for a file. The sprite is generated by
|
||||
// the scan-time prewarm (library.trickplay) so playback does NO live extraction
|
||||
// (no contention with the active stream — the cause of broken seekbar previews).
|
||||
// The agent owns the tile width (its config), so the web requests by path only
|
||||
// and reads geometry from the manifest. Auth mirrors /thumbnail (a
|
||||
// thumb:<sha256(path)> token). 404 when no sprite exists yet → the web falls
|
||||
// back to on-demand /thumbnail.
|
||||
func (ss *StreamServer) trickplayHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ss.lastActivity.Store(time.Now().UnixNano())
|
||||
if ss.writeCORSHeaders(w, r, "") {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
rawPath := q.Get("p")
|
||||
if rawPath == "" {
|
||||
http.Error(w, "missing path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !ss.checkStreamToken(streamScopeThumb(rawPath), q.Get("t")) {
|
||||
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
log.Printf("[trickplay] rejected from %s — bad/absent token", clientIP)
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ss.trickplayWidth <= 0 {
|
||||
http.Error(w, "trickplay disabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
manifest, ok := mediainfo.ReadCachedTrickplay(rawPath, ss.trickplayWidth)
|
||||
if !ok {
|
||||
http.Error(w, "trickplay not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if q.Get("kind") == "sprite" {
|
||||
f, err := os.Open(mediainfo.TrickplaySpritePath(rawPath, ss.trickplayWidth))
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
mod := time.Time{}
|
||||
if fi, serr := f.Stat(); serr == nil {
|
||||
mod = fi.ModTime()
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeContent(w, r, "trickplay.jpg", mod, f)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
if err := json.NewEncoder(w).Encode(manifest); err != nil {
|
||||
log.Printf("[trickplay] manifest encode failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// subtitleHandler extracts ONE embedded TEXT subtitle stream from a file and
|
||||
// serves it as WebVTT, on demand. It's the single subtitle source the web
|
||||
// player uses for BOTH direct-play and HLS (attached as an external <track>),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue