Merge branch 'main' into feat/agent-tls-direct

# Conflicts:
#	internal/cmd/daemon.go
This commit is contained in:
Deivid Soto 2026-06-10 19:44:44 +02:00
commit b0637f266b
42 changed files with 2862 additions and 340 deletions

View file

@ -10,6 +10,7 @@ import (
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@ -743,7 +744,9 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
case resource == "probe.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
_ = json.NewEncoder(w).Encode(session.ProbeInfo())
info := session.ProbeInfo()
ss.attachSubtitleVTTURLs(info, session.cfg.sourceRef())
_ = json.NewEncoder(w).Encode(info)
case resource == "video/index.m3u8":
session.ServeVideoPlaylist(w, r)
case resource == "video/init.mp4":
@ -1234,8 +1237,11 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request)
http.Error(w, "missing path", http.StatusBadRequest)
return
}
// index >= 0 → EMBEDDED stream index (-map 0:s:N) of the media at `p`.
// index < 0 → EXTERNAL sidecar: `p` IS the subtitle file; the whole file is
// the track. Both bind the token to (path, index) so a tampered p/i fails.
index, err := strconv.Atoi(q.Get("i"))
if err != nil || index < 0 {
if err != nil {
http.Error(w, "bad index", http.StatusBadRequest)
return
}
@ -1245,21 +1251,30 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request)
http.Error(w, "not found", http.StatusNotFound)
return
}
rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail)
if fi, statErr := os.Stat(rawPath); statErr != nil || !fi.Mode().IsRegular() {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Cache hit: serve a fresh sidecar (written by the scan-time prewarm or a
// prior request) instantly, skipping ffmpeg. This is also what makes huge
// remuxes work — the prewarm extracts without the on-demand HTTP timeout
// below, so by play time the hit avoids the 60s ceiling that was returning
// 500s on 50GB+ files. Checked BEFORE the ffmpeg guard so a pre-warmed track
// is still serveable even if ffmpeg was removed after the cache was filled.
if vtt, ok := mediainfo.ReadCachedSubtitle(rawPath, index); ok {
ss.writeVTT(w, vtt)
return
external := index < 0
// A debrid/HLS-from-URL source has no local file — ffmpeg reads the URL
// directly. Skip the path heal + regular-file stat + on-disk cache for those;
// only local files get the sidecar cache.
isURL := strings.Contains(rawPath, "://")
langHint := q.Get("l") // ISO 639-1 charset hint for external sidecar decoding
if !isURL {
rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail)
if fi, statErr := os.Stat(rawPath); statErr != nil || !fi.Mode().IsRegular() {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Cache hit: serve a fresh sidecar (written by the scan-time prewarm or a
// prior request) instantly, skipping ffmpeg. This is also what makes huge
// remuxes work — the prewarm extracts without the on-demand HTTP timeout
// below, so by play time the hit avoids the 60s ceiling that was returning
// 500s on 50GB+ files. Checked BEFORE the ffmpeg guard so a pre-warmed track
// is still serveable even if ffmpeg was removed after the cache was filled.
if vtt, ok := mediainfo.ReadCachedSubtitle(rawPath, index); ok {
ss.writeVTT(w, vtt)
return
}
}
// Beyond here we must extract on demand, which needs ffmpeg.
@ -1275,15 +1290,23 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request)
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
out, err := mediainfo.ExtractSubtitleVTT(ctx, ss.ffmpegPath, rawPath, index)
var out []byte
if external {
// Standalone sidecar file: transcode charset → UTF-8 (langHint guides the
// code-page guess) then ffmpeg → WebVTT.
out, err = mediainfo.ExtractExternalSubtitleVTT(ctx, ss.ffmpegPath, rawPath, langHint)
} else {
out, err = mediainfo.ExtractSubtitleVTT(ctx, ss.ffmpegPath, rawPath, index)
}
if err != nil {
log.Printf("[sub] extract failed (i=%d path=%q): %v", index, rawPath, err)
log.Printf("[sub] extract failed (i=%d path=%q external=%v url=%v): %v", index, rawPath, external, isURL, err)
http.Error(w, "subtitle extract failed", http.StatusInternalServerError)
return
}
// Write-through so the next request is a cache hit. Best-effort: a read-only
// media mount just logs and serves the in-memory bytes.
if ss.cacheSubtitles {
// media mount just logs and serves the in-memory bytes. URL sources have no
// stable on-disk anchor for the sidecar cache → skip.
if ss.cacheSubtitles && !isURL {
if werr := mediainfo.WriteCachedSubtitle(rawPath, index, out); werr != nil {
log.Printf("[sub] cache write skipped (i=%d path=%q): %v", index, rawPath, werr)
}
@ -1291,6 +1314,60 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request)
ss.writeVTT(w, out)
}
// attachSubtitleVTTURLs enriches a ProbeInfo map's "subtitles" entries with a
// ready-to-use, tokened `vttUrl` for every TEXT track, so the web player can
// attach <track>s for ANY play method (torrent/debrid HLS included) without the
// server needing the source path — it's the single subtitle wiring path that
// makes embedded subs work on streams that were never library-scanned.
//
// - embedded (external=false): /sub?p=<srcRef>&i=<index>&t=<tok>
// - external (external=true) : /sub?p=<sidecar path>&i=-1&t=<tok>&l=<lang>
//
// The token uses the SAME streamScopeSub(path,index) the web mints with, so a
// library-scanned track and a probe-derived one address identically. The raw
// "path" key is removed after the URL is built (it's encoded in the URL already).
// URLs are root-relative; the player resolves them against the funnel origin it
// fetched probe.json from. Bitmap tracks get no vttUrl (burn-in only).
func (ss *StreamServer) attachSubtitleVTTURLs(info map[string]any, srcRef string) {
subsAny, ok := info["subtitles"].([]map[string]any)
if !ok {
return
}
now := time.Now()
for _, sb := range subsAny {
isText, _ := sb["text"].(bool)
if !isText {
delete(sb, "path")
continue
}
external, _ := sb["external"].(bool)
var p string
var idx int
if external {
p, _ = sb["path"].(string)
idx = -1
} else {
p = srcRef
if iv, ok := sb["index"].(int); ok {
idx = iv
}
}
if p == "" {
delete(sb, "path")
continue
}
tok := mintStreamToken(ss.streamSecret, streamScopeSub(p, idx), now)
u := "/sub?p=" + url.QueryEscape(p) + "&i=" + strconv.Itoa(idx) + "&t=" + tok
if external {
if lang, _ := sb["lang"].(string); lang != "" && lang != "und" {
u += "&l=" + url.QueryEscape(lang)
}
}
sb["vttUrl"] = u
delete(sb, "path")
}
}
// writeVTT writes the standard WebVTT response headers + body for both the
// cache-hit and freshly-extracted paths of subtitleHandler.
func (ss *StreamServer) writeVTT(w http.ResponseWriter, vtt []byte) {
@ -1400,25 +1477,38 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", src.FileName()))
// Total to advertise: exact when ffmpeg has exited, else the estimate.
total := src.EstimatedSize()
if src.Final() {
total = src.Size()
// The instance length is KNOWN only once ffmpeg has exited. While the remux
// is still growing, the final size is genuinely unknown — the source MKV
// size is NOT it (the audio re-encode to AAC + fMP4 fragmentation change the
// byte count). Advertising that wrong total made the native <video> map its
// timeline onto a bogus length, request byte offsets that didn't line up,
// re-seek, and reopen the connection hundreds of times a second (the remux
// playback loop). Per RFC 7233 §4.2 we now send "/*" (unknown total) while
// growing, so the player streams sequentially instead of re-seeking against
// a fake size. `end` uses the estimate only as an upper-bound hint.
final := src.Final()
total := src.Size()
if !final {
total = src.EstimatedSize()
}
if total <= 0 {
total = src.Size()
}
start, explicitEnd := parseByteRange(r.Header.Get("Range"))
if total > 0 && start >= total {
// Range beyond what we expect to produce — let the browser recover.
// A 416 is only sound against a KNOWN total. While growing we can't say a
// start is unsatisfiable (more bytes are still coming), so only guard when
// final.
if final && total > 0 && start >= total {
// Range beyond the real end — let the browser recover.
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total))
http.Error(w, "range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
return
}
if r.Method == http.MethodHead {
if total > 0 {
// Only promise a length we actually know (final). While growing, omit it.
if final && total > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
}
w.WriteHeader(http.StatusOK)
@ -1429,13 +1519,23 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src
if explicitEnd >= 0 && explicitEnd < end {
end = explicitEnd
}
if total > 0 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
if end < start {
end = start
}
// Exact Content-Length only when the source is final (true size known) so
// we never promise bytes a still-running remux might not produce.
if src.Final() && explicitEnd < 0 {
w.Header().Set("Content-Length", strconv.FormatInt(src.Size()-start, 10))
if final {
if total > 0 {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
}
// Exact Content-Length only when final (true size known) so we never
// promise bytes a still-running remux might not produce.
if explicitEnd < 0 {
w.Header().Set("Content-Length", strconv.FormatInt(src.Size()-start, 10))
}
} else {
// Growing: honest "unknown total" so the player doesn't re-seek against
// a wrong size. No Content-Length (chunked) — bytes flow as ffmpeg makes
// them and the read loop below blocks at the live edge.
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/*", start, end))
}
w.WriteHeader(http.StatusPartialContent)