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
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
103
internal/engine/hls_progress_test.go
Normal file
103
internal/engine/hls_progress_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
127
internal/engine/hls_ratecontrol_test.go
Normal file
127
internal/engine/hls_ratecontrol_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
80
internal/engine/hls_registry_prewarm_test.go
Normal file
80
internal/engine/hls_registry_prewarm_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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] }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue