Merge branch 'main' into feat/agent-tls-direct
# Conflicts: # internal/cmd/daemon.go
This commit is contained in:
commit
b0637f266b
42 changed files with 2862 additions and 340 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue