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

@ -27,6 +27,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@ -65,6 +66,17 @@ func segmentStartSec(idx int) float64 {
return float64(idx * hlsSegmentDuration)
}
// segmentIdxForTime returns the index of the segment containing second `sec`
// of the timeline — the inverse of segmentStartSec. Used to translate a
// session's StartSec (resume position) into the segment the FIRST ffmpeg
// should start writing from.
func segmentIdxForTime(sec float64) int {
if sec <= 0 {
return 0
}
return int(sec / float64(hlsSegmentDuration))
}
// segmentCountForDuration returns how many segments cover a source of the
// given duration. Always returns at least 1.
func segmentCountForDuration(dur float64) int {
@ -159,7 +171,21 @@ type HLSSessionConfig struct {
// with the clean one. Forces the video re-encode the HLS path already does
// to also composite the subtitle overlay.
BurnSubtitleIndex *int
Transcode TranscodeRuntime
// StartSec is the playback position (seconds) the viewer will start at —
// the saved resume point, or the current position on a quality/audio
// switch. When > 0 the FIRST ffmpeg spawns already seeked there
// (`-ss` + `-output_ts_offset` + `-start_number`, the same flags as a
// seek-restart), instead of encoding from segment 0 only to be
// killed by an immediate seek-restart when the player asks for the resume
// segment (double spawn, slow resume). 0 = start at the beginning.
// Ignored on a cache HIT (every segment is already on disk).
StartSec float64
// Prewarm marks a background cache-fill session. The daemon defers its
// encode until no live encode runs and registers it via RegisterKeep
// (never evicting the viewer). It also lets a REAL session close stale
// prewarms up front so the cache writer-lock is free for the viewer.
Prewarm bool
Transcode TranscodeRuntime
// Cache is an optional persistent segment cache keyed by (source, quality,
// audio). When set, completed encodes are kept across sessions so re-plays
// of the same file at the same quality skip ffmpeg entirely. nil disables
@ -254,6 +280,21 @@ type HLSSession struct {
cacheKey string
fromCache bool
writerLockHeld bool
// Live transcode telemetry (F3). ffmpeg's -stats progress line is parsed
// in hlsStderrCapture.Write into an EWMA of speed= (×realtime) + fps=, plus
// an input-bound hint set when the SOURCE read errors (slow/broken pull vs a
// too-slow encode). GetTranscodeStats() snapshots this so the ready-watcher
// can report a real measurement to the web side — letting the player name a
// too-slow transcode honestly in ~4s instead of inferring it from stall
// shape over 15-30s. Guarded by statsMu (the stderr goroutine writes; the
// watcher goroutine reads).
statsMu sync.Mutex
speedEWMA float64
fpsEWMA float64
speedSamples int
warmupSeen int // cold-start frames discarded before the EWMA is trusted
inputBound bool
}
// hlsSeekAhead is how many segments past the writer's current position the
@ -305,6 +346,63 @@ func (r *HLSSessionRegistry) Register(s *HLSSession) {
}
}
// CloseWhere closes + removes every registered session matching pred. Used
// by the REAL-session path to reap stale prewarm encodes BEFORE its own
// StartHLSSession runs — that frees the per-key cache writer-lock, so the
// viewer's encode lands in the persistent cache instead of falling back to
// an uncached per-session tmpdir (and a SEALED prewarm survives as a cache
// HIT: closing a from-cache reader never invalidates the entry).
func (r *HLSSessionRegistry) CloseWhere(pred func(*HLSSession) bool) int {
r.mu.Lock()
victims := make([]*HLSSession, 0, len(r.sessions))
for id, s := range r.sessions {
if pred(s) {
victims = append(victims, s)
delete(r.sessions, id)
}
}
r.mu.Unlock()
for _, s := range victims {
_ = s.Close()
}
return len(victims)
}
// IsPrewarm reports whether this session was started as a background
// cache-fill (HLSSessionConfig.Prewarm). cfg is immutable after construction.
func (s *HLSSession) IsPrewarm() bool { return s.cfg.Prewarm }
// RegisterKeep adds a session WITHOUT displacing the others — the prewarm
// path: a background cache-fill encode must not evict the viewer's live
// session (Register's eviction killed the stream being watched when the
// next-episode prewarm got claimed mid-playback). It still replaces (and
// closes) a previous session with the SAME ID. A later Register() of a real
// viewer session evicts prewarms like any other session — a completed
// (sealed) prewarm survives in the segment cache either way.
func (r *HLSSessionRegistry) RegisterKeep(s *HLSSession) {
r.mu.Lock()
prev := r.sessions[s.cfg.SessionID]
r.sessions[s.cfg.SessionID] = s
r.mu.Unlock()
if prev != nil && prev != s {
_ = prev.Close()
}
}
// HasLiveEncode reports whether any registered session still has a RUNNING
// ffmpeg (encode not finished). Used to defer prewarm encodes so they never
// compete with the viewer's live transcode for the encoder.
func (r *HLSSessionRegistry) HasLiveEncode() bool {
r.mu.RLock()
defer r.mu.RUnlock()
for _, s := range r.sessions {
if !s.EncodeExited() {
return true
}
}
return false
}
// Remove drops a session from the registry without closing it.
func (r *HLSSessionRegistry) Remove(id string) {
r.mu.Lock()
@ -487,11 +585,38 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
return s, nil
}
// Resume-aware first spawn: when the session carries a StartSec (resume
// point / position on a quality switch), launch ffmpeg already seeked at
// the segment containing it. The web player opens playback at the same
// position (hls.js startPosition), so segment 0 would never be requested —
// encoding from 0 just to seek-restart milliseconds later wasted a full
// ffmpeg spawn and doubled the resume latency. Earlier segments simply
// don't exist on disk; ServeSegment's `idx < segStart` branch restarts the
// encoder if the user later scrubs back before the resume point. A partial
// encode never seals the cache (allSegmentsPresent checks 0..N), matching
// today's post-seek behaviour.
startIdx := 0
if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
startIdx = segmentIdxForTime(cfg.StartSec)
if startIdx > segCount-1 {
startIdx = segCount - 1
}
} else if cfg.StartSec >= probe.DurationSec && cfg.StartSec > 0 {
// Stale resume beyond this source's duration (the file was replaced by
// a shorter cut, or progress was saved against another release). Start
// from the beginning instead of encoding only the final segment, which
// would "end" the video seconds after it starts.
log.Printf("[hls %s] startSec %.0f ≥ duration %.0f — starting from 0",
shortHLSID(cfg.SessionID), cfg.StartSec, probe.DurationSec)
}
s.ffmpegSegStart = startIdx
s.readyMax = startIdx
// Spawn ffmpeg under a dedicated context so Close() can kill it without
// touching the parent ctx.
ffCtx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
args := buildHLSFFmpegArgs(cfg, probe, tmpDir)
args := buildHLSFFmpegArgsAt(cfg, probe, tmpDir, startIdx, segmentStartSec(startIdx))
cmd := exec.CommandContext(ffCtx, cfg.Transcode.FFmpegPath, args...)
cmd.Stderr = &hlsStderrCapture{owner: s}
if err := cmd.Start(); err != nil {
@ -524,10 +649,14 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if profile.Preset != "" {
presetNote = " preset=" + profile.Preset
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s",
startNote := ""
if startIdx > 0 {
startNote = fmt.Sprintf(" start=seg-%d@%.0fs", startIdx, segmentStartSec(startIdx))
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s%s",
shortHLSID(cfg.SessionID), cfg.logName(),
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote)
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote, startNote)
return s, nil
}
@ -558,13 +687,18 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
subs := make([]map[string]any, 0, len(s.probe.SubtitleTracks))
for _, sb := range s.probe.SubtitleTracks {
// `external`/`path` let the stream server attach a tokened /sub vttUrl
// (path-addressed for sidecars, index-addressed for embedded). `path` is
// stripped after the URL is built so the raw path isn't doubled in JSON.
subs = append(subs, map[string]any{
"index": sb.Index,
"lang": sb.Lang,
"codec": sb.Codec,
"title": sb.Title,
"forced": sb.Forced,
"text": sb.IsTextSubtitle(),
"index": sb.Index,
"lang": sb.Lang,
"codec": sb.Codec,
"title": sb.Title,
"forced": sb.Forced,
"text": sb.IsTextSubtitle(),
"external": sb.External,
"path": sb.Path,
})
}
return map[string]any{
@ -580,21 +714,106 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
}
// ReadyCount returns how many segments are currently fully on disk.
// Caller can `>= 1` it to check whether seg-0 has landed (and so the
// player can be told to attach). For cache-HIT sessions this is always
// `segmentCount` from the moment StartHLSSession returns.
// ReadyCount returns the session's readyMax watermark: segment idx is on disk
// iff idx < ReadyCount() AND idx >= WriterStartIdx(). For a from-zero encode
// this is simply "how many segments are on disk"; for a resume session
// (StartSec > 0) readyMax is pre-seeded to the start index, so the FIRST real
// segment has landed only once ReadyCount() > WriterStartIdx() — use that
// comparison, not `>= 1`, to flip the player's "Preparando…" UI. For
// cache-HIT sessions this is always `segmentCount` from the moment
// StartHLSSession returns.
func (s *HLSSession) ReadyCount() int {
s.readyMu.Lock()
defer s.readyMu.Unlock()
return s.readyMax
}
// EncodeExited reports whether this session's ffmpeg has finished (clean or
// crashed) or never ran (cache HIT). False while an encode is producing
// segments. Used by HasLiveEncode to defer prewarm work.
func (s *HLSSession) EncodeExited() bool {
s.readyMu.Lock()
defer s.readyMu.Unlock()
return s.exited
}
// WriterStartIdx returns the segment index the CURRENT ffmpeg writer started
// at: 0 for a from-the-beginning encode, the resume segment for a StartSec
// session, the seek target after a seek-restart. See ReadyCount for the
// "first segment landed" comparison.
func (s *HLSSession) WriterStartIdx() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.ffmpegSegStart
}
// FromCache reports whether this session was served from the HLS cache
// (no ffmpeg subprocess spawned). Used by ready-watcher logic to short-
// circuit polling — a cache HIT is ready the moment we return.
func (s *HLSSession) FromCache() bool { return s.fromCache }
// TranscodeStats is a point-in-time snapshot of live ffmpeg progress for one
// HLS session (F3). SpeedX < 1.0 means the encode runs slower than realtime —
// the player can't sustain playback without buffering. Samples==0 means no
// -stats line has been parsed yet (the watcher keeps waiting before reporting).
type TranscodeStats struct {
SpeedX float64 // EWMA of ffmpeg speed= (×realtime; 1.0 = exactly realtime)
Fps float64 // EWMA of ffmpeg fps=
Samples int // progress lines parsed so far (0 = no telemetry yet)
InputBound bool // source read hit I/O errors (slow/broken pull, not encode)
FromCache bool // replayed from cache → no live encode, stats meaningless
}
// GetTranscodeStats returns a snapshot of the parsed ffmpeg progress EWMAs.
func (s *HLSSession) GetTranscodeStats() TranscodeStats {
s.statsMu.Lock()
defer s.statsMu.Unlock()
return TranscodeStats{
SpeedX: s.speedEWMA,
Fps: s.fpsEWMA,
Samples: s.speedSamples,
InputBound: s.inputBound,
FromCache: s.fromCache,
}
}
// hlsStatsWarmupSkip is how many leading -stats frames to discard before
// trusting the EWMA. ffmpeg's first readings reflect the pipeline filling
// (often speed=0.0x) and would otherwise drag a healthy encoder into a false
// "struggling" verdict that pauses a stream which plays fine once warmed up.
const hlsStatsWarmupSkip = 2
// recordProgress folds one parsed ffmpeg -stats sample into the session EWMAs.
// alpha=0.3 smooths the noisy per-line numbers while still tracking a sustained
// slowdown within a few samples (~2s of encoding).
func (s *HLSSession) recordProgress(speedX, fps float64) {
s.statsMu.Lock()
defer s.statsMu.Unlock()
// Drop the cold-start frames so a steady-state slowdown — not the encoder
// spin-up — is what the watcher reports.
if s.warmupSeen < hlsStatsWarmupSkip {
s.warmupSeen++
return
}
const alpha = 0.3
if s.speedSamples == 0 {
s.speedEWMA = speedX
s.fpsEWMA = fps
} else {
s.speedEWMA = alpha*speedX + (1-alpha)*s.speedEWMA
s.fpsEWMA = alpha*fps + (1-alpha)*s.fpsEWMA
}
s.speedSamples++
}
// markInputBound flags that ffmpeg reported a source-read error — the wall is
// the input pull (slow debrid link / dropped torrent peer), not the encoder.
func (s *HLSSession) markInputBound() {
s.statsMu.Lock()
s.inputBound = true
s.statsMu.Unlock()
}
// IsClosed reports whether Close() has been invoked. Exposed (vs the
// internal isClosed) so external watchers — the ready-webhook
// goroutine in cmd/daemon.go — can short-circuit polling on a session
@ -1076,11 +1295,6 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
// ---- ffmpeg argument builders ----
// buildHLSFFmpegArgs returns the argv for the initial HLS encode (start at 0).
func buildHLSFFmpegArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string {
return buildHLSFFmpegArgsAt(cfg, probe, tmpDir, 0, 0)
}
// EncoderProfile names the codec + preset + decoder hint combination the HLS
// pipeline picks for the given hardware backend + transcode config. Exposed
// so callers can log the chosen encoder before ffmpeg launches and so both
@ -1140,7 +1354,10 @@ func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
// `-output_ts_offset` keeps the segment PTS aligned with manifest timeline.
func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string, startIdx int, startSec float64) []string {
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
// -stats forces ffmpeg to emit the frame=/fps=/speed= progress line to
// stderr even at -loglevel warning; hlsStderrCapture parses it for live
// transcode telemetry (F3) without logging it.
args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"}
// Demuxer-side HW-decode hint. Sourced from the profile so a future
// codec/hint mismatch is impossible — the encoder + decode hint are
@ -1266,12 +1483,21 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// scene-cut). No B-frame reorder → monotonic DTS → uniform segments, no
// "Packet duration is out of range" flood. Safe with -force_key_frames
// (unlike -tune ll, which broke per-segment cuts — see note above).
args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-bf", "0", "-no-scenecut", "1")
// -forced-idr 1 is LOAD-BEARING: NVENC emits -force_key_frames frames
// as plain (non-IDR) I-frames on current ffmpeg/driver combos, the HLS
// muxer only cuts on IDR, and every segment silently stretches to the
// default GOP (250 frames ≈ 10.4 s @24fps) while the server-rendered
// playlist still promises hlsSegmentDuration. The PTS↔playlist mismatch
// breaks seeks and desyncs subtitles (measured 2026-06-10: 3 segments
// per 30 s instead of 15; with -forced-idr exactly 15).
args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-bf", "0", "-no-scenecut", "1", "-forced-idr", "1")
case "h264_qsv":
// veryfast is the fastest realistic QSV preset; medium was too
// conservative for first-start. look_ahead=0 keeps the encoder
// truly low-latency (no rate-control look-ahead window).
args = append(args, "-preset", profile.Preset, "-look_ahead", "0")
// -forced_idr: same non-IDR forced-keyframe failure mode as NVENC (see
// above) — QSV's AVOption spells it with an underscore.
args = append(args, "-preset", profile.Preset, "-look_ahead", "0", "-forced_idr", "1")
case "h264_videotoolbox":
// VideoToolbox has no "preset" knob; `-realtime` flips into the
// low-latency path used by FaceTime. We let the `-b:v / -maxrate
@ -1332,7 +1558,31 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if bitrate == "" {
bitrate = "5M"
}
args = append(args, "-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bitrate)
// Rate control: capped constant-quality where the encoder supports it well
// (libx264 CRF, NVENC CQ), plain CBR-ish elsewhere. Constant quality is the
// on-the-fly analogue of per-title encoding: easy scenes (dialogue, anime
// flats) emit FAR fewer bits than the fixed target — which is what keeps a
// funnel/LTE link from stalling — while complex scenes can still use up to
// `-maxrate` (the same ceiling as before, so worst-case quality and the
// level-derived VBV pair are unchanged). `-bufsize 2×maxrate` gives the VBV
// a standard one-segment window to absorb spikes; the old 1× window forced
// the encoder to flatline at the cap. CPB stays far below every H.264
// level's limit (level 3.1 allows 14 Mbps CPB vs our 3M at 480p).
switch codec {
case "libx264":
// Capped CRF: no -b:v (CRF drives quality), -maxrate/-bufsize cap it.
args = append(args, "-crf", "23", "-maxrate", bitrate, "-bufsize", doubleBitrate(bitrate))
case "h264_nvenc":
// NVENC constant-quality VBR: -cq targets quality, -b:v 0 disables the
// default 2M average-bitrate target that would otherwise fight it.
args = append(args, "-cq", "23", "-b:v", "0", "-maxrate", bitrate, "-bufsize", doubleBitrate(bitrate))
default:
// QSV / VideoToolbox / VAAPI: keep the proven fixed-bitrate triple —
// their constant-quality knobs (ICQ, -q:v) have vendor-specific gotchas
// (VideoToolbox ignores -q:v when -b:v is set; QSV ICQ conflicts with
// look_ahead=0) and we can't regression-test them here.
args = append(args, "-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bitrate)
}
// Force keyframe alignment with segment boundaries.
args = append(args, "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSegmentDuration))
@ -1581,6 +1831,46 @@ type hlsStderrCapture struct {
const maxStderrBuf = 64 * 1024
// ffmpeg -stats progress lines look like:
//
// frame= 123 fps= 30 q=28.0 size= 456kB time=00:00:08.00 speed=1.05x
//
// emitted with a trailing \r (overwrite-in-place), once per ~0.5s. We parse
// speed=/fps= out of them for live transcode telemetry (F3) and DON'T log them
// (one per 0.5s would drown the daemon log) — only \n-terminated warning/error
// lines reach log.Printf below.
var (
reFFmpegSpeed = regexp.MustCompile(`speed=\s*([0-9.]+)x`)
reFFmpegFps = regexp.MustCompile(`fps=\s*([0-9.]+)`)
)
func parseFFmpegProgress(line string) (speedX, fps float64, ok bool) {
m := reFFmpegSpeed.FindStringSubmatch(line)
if m == nil {
return 0, 0, false
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, 0, false
}
if fm := reFFmpegFps.FindStringSubmatch(line); fm != nil {
fps, _ = strconv.ParseFloat(fm[1], 64)
}
return v, fps, true
}
// isInputBoundLine spots ffmpeg stderr that means the SOURCE read failed (slow
// debrid link, dropped torrent peer, network timeout) rather than the encoder
// being too slow — so the player names the bottleneck as the link, not the GPU.
func isInputBoundLine(line string) bool {
l := strings.ToLower(line)
return strings.Contains(l, "i/o error") ||
strings.Contains(l, "connection reset") ||
strings.Contains(l, "rw_timeout") ||
strings.Contains(l, "error in the pull function") ||
strings.Contains(l, "connection timed out")
}
func (c *hlsStderrCapture) Write(p []byte) (int, error) {
// If the incoming chunk alone exceeds the cap (very long unterminated
// line), drop the buffered prefix AND truncate p so a single multi-MB
@ -1589,20 +1879,33 @@ func (c *hlsStderrCapture) Write(p []byte) (int, error) {
c.buf.Reset()
p = p[len(p)-maxStderrBuf:]
} else if c.buf.Len()+len(p) > maxStderrBuf {
// Drop the unterminated partial line; we'll resync on the next \n.
// Drop the unterminated partial line; we'll resync on the next \r/\n.
c.buf.Reset()
}
c.buf.Write(p)
// Frame on \r OR \n: ffmpeg's progress line is \r-terminated, warnings are
// \n-terminated. Parsing progress per-frame keeps the EWMA fresh; logging
// only the \n lines keeps the log readable.
for {
line, rest, ok := strings.Cut(c.buf.String(), "\n")
if !ok {
s := c.buf.String()
idx := strings.IndexAny(s, "\r\n")
if idx < 0 {
break
}
line := strings.TrimSpace(s[:idx])
c.buf.Reset()
c.buf.WriteString(rest)
if line = strings.TrimSpace(line); line != "" {
log.Printf("[hls %s] ffmpeg: %s", shortHLSID(c.owner.cfg.SessionID), line)
c.buf.WriteString(s[idx+1:])
if line == "" {
continue
}
if speedX, fps, ok := parseFFmpegProgress(line); ok {
c.owner.recordProgress(speedX, fps)
continue // progress line — telemetry only, never logged
}
if isInputBoundLine(line) {
c.owner.markInputBound()
}
log.Printf("[hls %s] ffmpeg: %s", shortHLSID(c.owner.cfg.SessionID), line)
}
return len(p), nil
}

View file

@ -0,0 +1,103 @@
package engine
import (
"math"
"testing"
)
func TestParseFFmpegProgress(t *testing.T) {
cases := []struct {
name string
line string
wantSpeed float64
wantFps float64
wantOK bool
}{
{"realtime", "frame= 123 fps= 30 q=28.0 size= 456kB time=00:00:08.00 bitrate=467.0kbits/s speed=1.05x", 1.05, 30, true},
{"slow", "frame= 12 fps=2.4 q=-1.0 size= 40kB time=00:00:00.40 speed=0.18x", 0.18, 2.4, true},
{"tight_spacing", "speed=2x", 2, 0, true},
{"no_speed", "[libplacebo @ 0x55] Spent 2657ms on a slow shader", 0, 0, false},
{"warning_line", "[hevc @ 0x7f] Could not find ref with POC 12", 0, 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sp, fps, ok := parseFFmpegProgress(c.line)
if ok != c.wantOK {
t.Fatalf("ok=%v want %v", ok, c.wantOK)
}
if !ok {
return
}
if math.Abs(sp-c.wantSpeed) > 1e-9 {
t.Errorf("speed=%v want %v", sp, c.wantSpeed)
}
if math.Abs(fps-c.wantFps) > 1e-9 {
t.Errorf("fps=%v want %v", fps, c.wantFps)
}
})
}
}
func TestIsInputBoundLine(t *testing.T) {
bound := []string{
"[http @ 0x55] HTTP error: Connection reset by peer",
"rw_timeout reached, aborting",
"Error in the pull function.",
"tcp://: I/O error",
}
for _, l := range bound {
if !isInputBoundLine(l) {
t.Errorf("expected input-bound: %q", l)
}
}
notBound := []string{
"frame= 1 fps=30 speed=1.0x",
"[libplacebo] slow shader",
}
for _, l := range notBound {
if isInputBoundLine(l) {
t.Errorf("expected NOT input-bound: %q", l)
}
}
}
// hlsStderrCapture must frame on \r (progress) as well as \n (warnings),
// fold progress into the EWMA, and surface a sustained slow encode as < 1.0x.
func TestHlsStderrCaptureProgressEWMA(t *testing.T) {
s := &HLSSession{}
s.cfg.SessionID = "test-session-00000000"
c := &hlsStderrCapture{owner: s}
// Cold-start frames ffmpeg emits while the pipeline fills — must be skipped
// (hlsStatsWarmupSkip) so they don't drag the EWMA into a false struggle.
warmup := "frame=0 fps=0 speed=0.01x\r" +
"frame=0 fps=0 speed=0.04x\r"
// A burst of \r-terminated steady-state progress lines, like real ffmpeg.
chunk := "frame=1 fps=2 speed=0.20x\r" +
"frame=2 fps=2 speed=0.21x\r" +
"frame=3 fps=2 speed=0.19x\r" +
"frame=4 fps=2 speed=0.20x\r" +
"frame=5 fps=2 speed=0.20x\r"
if _, err := c.Write([]byte(warmup + chunk)); err != nil {
t.Fatal(err)
}
st := s.GetTranscodeStats()
// 7 progress lines written, first hlsStatsWarmupSkip(2) discarded → 5 counted.
if st.Samples != 5 {
t.Fatalf("samples=%d want 5 (7 lines - 2 warmup)", st.Samples)
}
if st.SpeedX > 0.5 || st.SpeedX < 0.1 {
t.Errorf("speedX EWMA=%v, want ~0.2 (sustained slow encode)", st.SpeedX)
}
if st.InputBound {
t.Error("not input-bound for a pure slow encode")
}
// A \n-terminated I/O error line flips input-bound.
if _, err := c.Write([]byte("tcp://: I/O error\n")); err != nil {
t.Fatal(err)
}
if !s.GetTranscodeStats().InputBound {
t.Error("expected input-bound after I/O error line")
}
}

View file

@ -0,0 +1,127 @@
package engine
import (
"strings"
"testing"
)
func TestDoubleBitrate(t *testing.T) {
cases := map[string]string{
"6000k": "12000k",
"25000k": "50000k",
"1500k": "3000k",
"5M": "10M",
"1.5M": "3M",
"2.5m": "5m",
"800000": "1600000",
"": "",
"garbage": "garbage", // unparseable → unchanged (1× bufsize fallback)
"-5M": "-5M", // non-positive → unchanged
}
for in, want := range cases {
if got := doubleBitrate(in); got != want {
t.Errorf("doubleBitrate(%q) = %q, want %q", in, got, want)
}
}
}
// segmentIdxForTime must be the exact inverse of segmentStartSec so the
// resume-aware first spawn (HLSSessionConfig.StartSec) lands on the same
// segment the player's hls.js startPosition will request.
func TestSegmentIdxForTime(t *testing.T) {
cases := map[float64]int{
0: 0,
-3: 0,
0.5: 0,
1.99: 0,
2: 1,
3.9: 1,
60: 30,
3599.9: 1799,
}
for sec, want := range cases {
if got := segmentIdxForTime(sec); got != want {
t.Errorf("segmentIdxForTime(%v) = %d, want %d", sec, got, want)
}
}
// Round-trip: the start time of the segment we resolve must never be
// AFTER the requested position (the player would miss its first frames).
for _, sec := range []float64{0, 1, 2, 7.3, 119.9, 4321} {
idx := segmentIdxForTime(sec)
if start := segmentStartSec(idx); start > sec {
t.Errorf("segmentStartSec(segmentIdxForTime(%v)) = %v > %v", sec, start, sec)
}
}
}
// Capped constant-quality rate control: libx264 gets -crf (no -b:v), NVENC
// gets -cq with -b:v 0, both keep -maxrate at the level-coherent cap and a
// 2× -bufsize. VAAPI (and the other vendor encoders) keep the proven
// fixed-bitrate triple untouched.
func TestBuildHLSFFmpegArgsRateControl(t *testing.T) {
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
base := HLSSessionConfig{
SessionID: "test",
SourcePath: "/media/Movie.mkv",
Quality: "1080p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
},
}
t.Run("libx264 capped CRF", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNone
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-crf 23", "-maxrate 6000k", "-bufsize 12000k"} {
if !strings.Contains(got, want) {
t.Errorf("libx264 argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-b:v 6000k") {
t.Errorf("libx264 argv must not carry -b:v alongside -crf\n%s", got)
}
})
t.Run("nvenc constant-quality VBR", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNVENC
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
// -forced-idr 1 is load-bearing: without it NVENC emits the forced
// keyframes as non-IDR and every HLS segment stretches to the full
// GOP, desyncing the playlist timeline (subs/seeks).
for _, want := range []string{"-rc vbr", "-cq 23", "-b:v 0", "-maxrate 6000k", "-bufsize 12000k", "-forced-idr 1"} {
if !strings.Contains(got, want) {
t.Errorf("nvenc argv missing %q\n%s", want, got)
}
}
})
t.Run("qsv keeps bitrate + forced_idr", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelQSV
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
// -forced_idr 1 (QSV's spelling): same non-IDR forced-keyframe failure
// mode as NVENC — without it segments stretch to the full GOP.
for _, want := range []string{"-look_ahead 0", "-forced_idr 1", "-b:v 6000k"} {
if !strings.Contains(got, want) {
t.Errorf("qsv argv missing %q\n%s", want, got)
}
}
})
t.Run("vaapi keeps fixed-bitrate triple", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelVAAPI
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-b:v 6000k", "-maxrate 6000k", "-bufsize 6000k"} {
if !strings.Contains(got, want) {
t.Errorf("vaapi argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-crf") || strings.Contains(got, "-cq") {
t.Errorf("vaapi argv must not carry constant-quality flags\n%s", got)
}
})
}

View file

@ -0,0 +1,80 @@
package engine
import "testing"
// bare session: no ffmpeg, no tmpdir — exercises pure registry semantics.
func bareSession(id string, prewarm bool, exited bool) *HLSSession {
s := &HLSSession{cfg: HLSSessionConfig{SessionID: id, Prewarm: prewarm}}
s.exited = exited
return s
}
// A prewarm registered via RegisterKeep must NOT evict the viewer's live
// session (the old Register-for-everything path killed the stream being
// watched when the next-episode prewarm got claimed mid-playback).
func TestRegisterKeepDoesNotEvict(t *testing.T) {
r := NewHLSSessionRegistry()
live := bareSession("live", false, false)
r.Register(live)
pre := bareSession("pre", true, false)
r.RegisterKeep(pre)
if r.Get("live") == nil {
t.Fatal("RegisterKeep evicted the live session")
}
if r.Get("pre") == nil {
t.Fatal("RegisterKeep did not register the prewarm")
}
if live.isClosed() {
t.Fatal("RegisterKeep closed the live session")
}
// A REAL session via Register still evicts everything (single viewer).
real2 := bareSession("real2", false, false)
r.Register(real2)
if r.Get("live") != nil || r.Get("pre") != nil {
t.Fatal("Register must evict every other session")
}
if !live.isClosed() || !pre.isClosed() {
t.Fatal("Register must close the evicted sessions")
}
}
func TestCloseWherePrewarmsOnly(t *testing.T) {
r := NewHLSSessionRegistry()
live := bareSession("live", false, false)
pre1 := bareSession("pre1", true, false)
pre2 := bareSession("pre2", true, true)
r.Register(live)
r.RegisterKeep(pre1)
r.RegisterKeep(pre2)
n := r.CloseWhere(func(s *HLSSession) bool { return s.IsPrewarm() })
if n != 2 {
t.Fatalf("CloseWhere closed %d sessions, want 2", n)
}
if r.Get("live") == nil || live.isClosed() {
t.Fatal("CloseWhere must not touch the live session")
}
if r.Get("pre1") != nil || r.Get("pre2") != nil {
t.Fatal("CloseWhere must remove the prewarms from the registry")
}
}
func TestHasLiveEncode(t *testing.T) {
r := NewHLSSessionRegistry()
if r.HasLiveEncode() {
t.Fatal("empty registry must report no live encode")
}
done := bareSession("done", false, true) // encode finished / cache HIT
r.Register(done)
if r.HasLiveEncode() {
t.Fatal("an exited encode must not count as live")
}
running := bareSession("running", true, false)
r.RegisterKeep(running)
if !r.HasLiveEncode() {
t.Fatal("a running encode must count as live")
}
}

View file

@ -16,7 +16,7 @@ type fakePersister struct {
tasks map[string]bool
}
func newFakePersister() *fakePersister { return &fakePersister{tasks: map[string]bool{}} }
func newFakePersister() *fakePersister { return &fakePersister{tasks: map[string]bool{}} }
func (f *fakePersister) Add(t agent.Task) { f.mu.Lock(); f.tasks[t.ID] = true; f.mu.Unlock() }
func (f *fakePersister) Remove(id string) { f.mu.Lock(); delete(f.tasks, id); f.mu.Unlock() }
func (f *fakePersister) has(id string) bool { f.mu.Lock(); defer f.mu.Unlock(); return f.tasks[id] }

View file

@ -50,11 +50,15 @@ type ProbeAudioTrack struct {
// Codec discriminates text (srt/ass/webvtt → extract to WebVTT) vs bitmap
// (pgs/dvbsub → require burn-in).
type ProbeSubtitleTrack struct {
Index int // 0-based subtitle stream index (ffmpeg -map 0:s:Index)
Index int // 0-based EMBEDDED subtitle stream index (ffmpeg -map 0:s:Index). Unused when External.
Lang string // ISO 639-1
Codec string // lowercased — "subrip", "ass", "webvtt", "hdmv_pgs_subtitle", ...
Title string
Forced bool
// External marks a sidecar file (served via /sub?p=<Path>&i=-1) rather than
// an embedded stream. Path is its absolute filesystem path (External only).
External bool
Path string
}
// IsTextSubtitle reports whether a subtitle codec can be extracted to WebVTT
@ -134,14 +138,27 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
}
if len(mi.Subtitles) > 0 {
probe.SubtitleTracks = make([]ProbeSubtitleTrack, 0, len(mi.Subtitles))
for i, s := range mi.Subtitles {
probe.SubtitleTracks = append(probe.SubtitleTracks, ProbeSubtitleTrack{
Index: i,
Lang: s.Lang,
Codec: strings.ToLower(s.Codec),
Title: s.Title,
Forced: s.Forced,
})
// Embedded streams come first (ffprobe order); external sidecars are
// appended after. Count embedded separately so each embedded track's
// Index is its true `0:s:N` value regardless of how many externals trail
// it; externals get Index=-1 and address by Path instead.
embeddedIdx := 0
for _, s := range mi.Subtitles {
t := ProbeSubtitleTrack{
Lang: s.Lang,
Codec: strings.ToLower(s.Codec),
Title: s.Title,
Forced: s.Forced,
External: s.External,
Path: s.Path,
}
if s.External {
t.Index = -1
} else {
t.Index = embeddedIdx
embeddedIdx++
}
probe.SubtitleTracks = append(probe.SubtitleTracks, t)
}
}
storeProbeCache(filePath, probe)

View file

@ -10,10 +10,10 @@ func TestDynamicReadahead(t *testing.T) {
}{
{"unknown bitrate → default", 0, defaultReadahead},
{"negative → default", -1, defaultReadahead},
{"low bitrate clamps to min", 1_000_000, minReadahead}, // 1 Mbps → ~3.75 MiB < 8 MiB
{"mid bitrate scales", 5_000_000, 5_000_000 / 8 * readaheadSeconds}, // 5 Mbps → ~18.75 MiB
{"low bitrate clamps to min", 1_000_000, minReadahead}, // 1 Mbps → ~3.75 MiB < 8 MiB
{"mid bitrate scales", 5_000_000, 5_000_000 / 8 * readaheadSeconds}, // 5 Mbps → ~18.75 MiB
{"high bitrate within range", 25_000_000, 25_000_000 / 8 * readaheadSeconds}, // 4K ~25 Mbps → ~93.75 MiB
{"very high clamps to max", 80_000_000, maxReadahead}, // 80 Mbps → 300 MiB > cap
{"very high clamps to max", 80_000_000, maxReadahead}, // 80 Mbps → 300 MiB > cap
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View file

@ -135,9 +135,12 @@ func TestServeGrowing_BoundedRange(t *testing.T) {
}
}
func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
// Not final: only 8 bytes produced, but estimate says 100. The advertised
// total is the estimate (scrubber timeline); body is what exists so far.
func TestServeGrowing_UnknownTotalWhileNotFinal(t *testing.T) {
// Not final: only 8 bytes produced, estimate says 100. The instance length
// is genuinely unknown while the remux grows, so we advertise "/*" (RFC 7233
// §4.2) instead of a total the native player would map its timeline onto and
// re-seek against (the playback loop). The estimate is only an upper-bound
// hint for `end`; body is what exists so far.
src := &fakeGrowing{data: []byte("01234567"), final: false, est: 100}
ss := &StreamServer{}
@ -149,8 +152,8 @@ func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/*" {
t.Errorf("Content-Range = %q, want bytes 0-99/* (unknown total)", got)
}
// Not final → no exact Content-Length (chunked) so we never promise bytes
// a still-running remux might not produce.
@ -163,6 +166,8 @@ func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
}
func TestServeGrowing_HeadProbe(t *testing.T) {
// HEAD while growing: total is unknown, so no Content-Length is promised
// (advertising the estimate is the bug this fix removes).
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
ss := &StreamServer{}
@ -174,14 +179,32 @@ func TestServeGrowing_HeadProbe(t *testing.T) {
if res.StatusCode != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
}
if got := res.Header.Get("Content-Length"); got != "4242" {
t.Errorf("HEAD Content-Length = %q, want 4242", got)
if got := res.Header.Get("Content-Length"); got != "" {
t.Errorf("HEAD Content-Length = %q, want empty (unknown total while growing)", got)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len())
}
}
func TestServeGrowing_HeadProbeFinal(t *testing.T) {
// HEAD once final: the true total IS known, so advertise it.
src := &fakeGrowing{data: make([]byte, 4242), final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodHead, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
}
if got := res.Header.Get("Content-Length"); got != "4242" {
t.Errorf("HEAD Content-Length = %q, want 4242 (final size known)", got)
}
}
func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
src := &fakeGrowing{data: []byte("0123456789"), final: true}
ss := &StreamServer{}

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)

View file

@ -23,6 +23,23 @@ import (
"golang.org/x/time/rate"
)
// portfwdFilterHandler wraps anacrolix/log handlers and drops the noisy
// UPnP/NAT-PMP port-mapping warnings (e.g. "error: AddPortMapping: 500 Internal
// Server Error") that home routers emit when they reject the mapping. Everything
// else passes through unchanged.
type portfwdFilterHandler struct {
inner []alog.Handler
}
func (h portfwdFilterHandler) Handle(r alog.Record) {
if strings.Contains(r.Text(), "AddPortMapping") {
return
}
for _, inner := range h.inner {
inner.Handle(r)
}
}
var defaultTrackers = []string{
// Tier 1: ngosang/trackerslist "best" + newtrackon "stable"
"udp://tracker.opentrackr.org:1337/announce",
@ -126,6 +143,16 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
// Drop the noisy UPnP/NAT-PMP port-mapping warnings. The library attempts to
// map the listen port on the router for inbound peers (best-effort, only
// helps on routers that support it). Many home routers reject AddPortMapping
// with "500 Internal Server Error" and the lib retries on every lease cycle,
// spamming the log. The rejection is harmless (download works over DHT +
// outbound peers), so suppress just that line while keeping the attempts for
// routers that do support it.
tcfg.Logger.SetHandlers(portfwdFilterHandler{
inner: append([]alog.Handler(nil), alog.Default.Handlers...),
})
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true

View file

@ -1,5 +1,10 @@
package engine
import (
"math"
"strconv"
)
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
@ -48,6 +53,35 @@ func resolveQualityCap(label string) qualityCap {
}
}
// doubleBitrate returns an ffmpeg bitrate string with twice the value of the
// input ("6000k" → "12000k", "1.5M" → "3M", "5M" → "10M"). Used to size
// `-bufsize` at the standard 2× of `-maxrate` for capped-CRF/CQ rate control.
// An unparseable string falls back to the input unchanged (1× bufsize — the
// pre-CRF behaviour, safe just suboptimal). The doubled CPB stays far below
// every H.264 level's limit for the (level, maxrate) pairs this package emits
// (worst case: 1080p level 4.1 → 12000k bufsize vs 62500k allowed).
func doubleBitrate(b string) string {
if b == "" {
return b
}
num := b
suffix := ""
switch b[len(b)-1] {
case 'k', 'K', 'm', 'M':
num = b[:len(b)-1]
suffix = string(b[len(b)-1])
}
v, err := strconv.ParseFloat(num, 64)
if err != nil || v <= 0 {
return b
}
d := v * 2
if d == math.Trunc(d) {
return strconv.FormatFloat(d, 'f', 0, 64) + suffix
}
return strconv.FormatFloat(d, 'f', -1, 64) + suffix
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots