From 5a92df1e14b8703e86e4984992a37c5b4b60e67c Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 10 Jun 2026 23:06:21 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(stream):=20HLS-copy=20=E2=80=94=20reem?= =?UTF-8?q?plazo=20resiliente=20del=20remux=20progresivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo modo VideoCopy en el engine HLS: ffmpeg -c:v copy (el vídeo jamás se re-encodea — I/O puro, funciona en un NAS sin GPU), audio copy si ya es AAC o AAC 192k si no, muxeado a segmentos fMP4 con ffmpeg escribiendo SU PROPIO playlist (EVENT mientras corre, ENDLIST al acabar, EXTINF exactos en los keyframes del source). Sustituye al remux growing-fMP4 servido por HTTP Range artesanal, cuya fragilidad estructural produjo tres incidentes en un día (init malformado/delay_moov, loop de re-seek por total inventado, iOS rechazando total desconocido). Diferencias deliberadas respecto al modo encode: - playlist de ffmpeg servido desde disco (los cortes van a keyframe del source → duraciones imposibles de pre-renderizar; medido: probar keyframes antes cuesta 8-24s, inviable para TTFF) - sin seek-restart ni auto-restart (la copia va a velocidad de disco y adelanta a cualquier viewer; el -ss de segmentos uniformes corrompería la timeline de cortes variables) - sin caché HLS (regenerar no cuesta encode; cachear solo quema disco) - resume vía -ss (snap a keyframe) + -output_ts_offset - master playlist sin CODECS (un string hardcodeado equivocado hace que iOS rechace la variante; omitirlo es legal y universal) Validación: TTFB seg-0 510ms sobre el MKV real del incidente (HEVC Main10 + EAC3, 6.7GB). Suite de integración con ffmpeg real (tag smoke): h264+aac (copy total), h264+ac3 (re-encode de audio con priming dts — la clase delay_moov), hevc10+eac3 (la forma exacta del incidente, tag hvc1), resume con StartSec, y serving del playlist; asserts de codecs vía ffprobe sobre el playlist servido, suma EXTINF ≈ duración, segmentos completos en disco (+temp_file = rename atómico). El wiring web (plan remux→hls+videoCopy con gate de versión ≥1.0.10) va en el repo web. Plan: docs/plans/hls-copy-remux-replacement.md (web). --- internal/agent/types.go | 6 + internal/cmd/daemon.go | 1 + internal/engine/hls.go | 242 +++++++++++++++++++++-- internal/engine/hls_copy_smoke_test.go | 262 +++++++++++++++++++++++++ 4 files changed, 499 insertions(+), 12 deletions(-) create mode 100644 internal/engine/hls_copy_smoke_test.go diff --git a/internal/agent/types.go b/internal/agent/types.go index 8b79dff..25bad79 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -522,6 +522,12 @@ type StreamSession struct { // the raw file over /stream (HTTP Range, no ffmpeg) instead of // transcoding to HLS. See hueco #3 phase 3a in the roadmap. PlayMethod string `json:"playMethod,omitempty"` + // VideoCopy (playMethod "hls" only): serve via HLS-copy — ffmpeg -c:v copy + // into fMP4 segments, audio to AAC when needed. The robust replacement for + // the progressive-remux path: same near-zero CPU (video never re-encoded, + // works on a GPU-less NAS), but in the segmented transport every player + // handles. Set by webs that know this agent supports it (≥1.0.10). + VideoCopy bool `json:"videoCopy,omitempty"` // DirectURL, when set, is an HTTPS link to the media resolved server-side // from the user's debrid account (hueco #2 / 2a). The source has no local // file: the daemon streams /stream from this URL via ranged GETs diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 9c595ed..75b226a 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -998,6 +998,7 @@ func runDaemonStart() error { BurnSubtitleIndex: sess.BurnSubtitleIndex, StartSec: sess.StartSec, Prewarm: sess.Prewarm, + VideoCopy: sess.VideoCopy, Transcode: tcRuntime, Cache: hlsCache, }, hlsCtx, hlsCancel) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 58deb96..8f90c15 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -191,8 +191,30 @@ type HLSSessionConfig struct { // of the same file at the same quality skip ffmpeg entirely. nil disables // caching (per-session tmpdir, deleted on Close — original behavior). Cache *HLSCache + // VideoCopy switches the session to HLS-copy mode: ffmpeg `-c:v copy` + // (NEVER re-encodes video — I/O-bound, works on a GPU-less NAS), audio + // copied when already AAC or re-encoded to AAC otherwise. This replaces + // the fragile progressive-remux path (growing fMP4 over manual HTTP + // Range) with the robust segmented transport every player handles + // (hls.js + native iOS HLS). Differences from the encode mode, all + // driven by "segments cut at the SOURCE's keyframes, so their durations + // are unknown upfront": + // - the media playlist is ffmpeg's own (EVENT → ENDLIST), served from + // disk — not the pre-rendered uniform-2s VOD manifest; + // - no seek-restart / auto-restart (copy outruns any viewer: the whole + // file is remuxed at I/O speed, minutes at worst on a weak NAS); + // - no HLS cache (re-generating costs no encode — caching would only + // burn disk); + // - StartSec is passed straight to `-ss` (keyframe-snapped by ffmpeg). + // See docs/plans/hls-copy-remux-replacement.md (web repo). + VideoCopy bool } +// copyPlaylistName is the on-disk media playlist ffmpeg owns in VideoCopy +// mode, under /video/. Distinct from the encode mode's in-memory +// manifest so the two can never be confused. +const copyPlaylistName = "copy.m3u8" + // sourceRef returns the ffmpeg/ffprobe input: the remote URL when set, else the // local path. Used everywhere a `-i` argument or a probe target is needed so // the local-file and debrid-URL paths share one code path. @@ -490,6 +512,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er fromCache bool writerLockHeld bool ) + if cfg.VideoCopy && cfg.Cache != nil { + // HLS-copy never caches: re-generating costs no encode (I/O-bound), so + // persisting segments would only burn cache budget that real transcodes + // need. Private per-session tmpdir, deleted on Close. + cfg.Cache = nil + } if cfg.Cache != nil { // Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache // despite the URL changing each resolution; local files key by path. @@ -566,8 +594,16 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er writerLockHeld: writerLockHeld, liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable } - s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount) - s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality) + if cfg.VideoCopy { + // Copy mode: ffmpeg owns the media playlist (segments cut at the + // source's keyframes → durations unknown upfront, the uniform-2s + // pre-render would lie). ServeVideoPlaylist reads it from disk. + s.manifestVideo = "" + s.manifestRoot = renderMasterPlaylistCopy(probe) + } else { + s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount) + s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality) + } // Cache HIT: every segment + init.mp4 is already on disk. Skip ffmpeg // entirely and mark readyMax so handlers don't wait. Background subtitle @@ -596,7 +632,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er // 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 { + if cfg.VideoCopy { + // Copy mode always numbers from 0: segment indices don't map to + // uniform 2s slots, so a StartSec-derived index would be wrong. The + // resume seek itself is handled inside buildHLSCopyArgs via `-ss` + // (keyframe-snapped) + `-output_ts_offset`. + } else if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec { startIdx = segmentIdxForTime(cfg.StartSec) if startIdx > segCount-1 { startIdx = segCount - 1 @@ -616,7 +657,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er // touching the parent ctx. ffCtx, cancel := context.WithCancel(context.Background()) s.cancel = cancel - args := buildHLSFFmpegArgsAt(cfg, probe, tmpDir, startIdx, segmentStartSec(startIdx)) + var args []string + if cfg.VideoCopy { + args = buildHLSCopyArgs(cfg, probe, tmpDir) + } else { + 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 { @@ -644,19 +690,27 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er // triaged from the agent log alone — `encoder=libx264 accel=none` means // the user's ffmpeg has no HW encoders compiled in, which is the most // common root cause (linuxbrew, default brew formula on macOS). - profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset) - presetNote := "" - if profile.Preset != "" { - presetNote = " preset=" + profile.Preset + encoderNote := "" + if cfg.VideoCopy { + encoderNote = "encoder=copy (no video re-encode)" + } else { + profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset) + presetNote := "" + if profile.Preset != "" { + presetNote = " preset=" + profile.Preset + } + encoderNote = fmt.Sprintf("encoder=%s accel=%s%s", profile.Codec, string(cfg.Transcode.HWAccel), presetNote) } startNote := "" - if startIdx > 0 { + if cfg.VideoCopy && cfg.StartSec > 0 { + startNote = fmt.Sprintf(" start=%.0fs", cfg.StartSec) + } else 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", + log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, %s)%s%s", shortHLSID(cfg.SessionID), cfg.logName(), probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"), - profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote, startNote) + encoderNote, cachedNote, startNote) return s, nil } @@ -953,6 +1007,15 @@ func (s *HLSSession) waitFFmpeg() { } log.Printf("[hls %s] ffmpeg exited: %v", shortHLSID(s.cfg.SessionID), err) + // Copy mode: no auto-restart. restartFromSegment's `-ss segmentStartSec(N)` + // math assumes uniform 2s segments, which copy mode doesn't have — a + // restart would corrupt the timeline. A failed copy surfaces through the + // player's probe deadline / fallback chain instead. + if s.cfg.VideoCopy { + log.Printf("[hls %s] copy session failed — not restarting (player falls back)", shortHLSID(s.cfg.SessionID)) + return + } + // Decide whether to attempt an auto-restart. We don't restart when: // - the session was closed externally (kill on quality change etc.) // - we've already retried 3 times within the last 60 s (broken file) @@ -1129,9 +1192,39 @@ func (s *HLSSession) ServeVideoPlaylist(w http.ResponseWriter, r *http.Request) s.Touch() w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.Header().Set("Cache-Control", "no-cache") + if s.cfg.VideoCopy { + s.serveCopyPlaylist(w, r) + return + } _, _ = io.WriteString(w, s.manifestVideo) } +// serveCopyPlaylist serves ffmpeg's own media playlist (VideoCopy mode). The +// file appears within ~1 s of spawn (copy is I/O-bound) but the player's +// first fetch can race it — poll briefly instead of returning a 404 hls.js +// would surface as a manifest error. Each request re-reads the file: the +// playlist GROWS (EVENT) until ffmpeg appends ENDLIST, and players re-poll +// growing playlists by design. +func (s *HLSSession) serveCopyPlaylist(w http.ResponseWriter, r *http.Request) { + path := filepath.Join(s.tmpDir, "video", copyPlaylistName) + deadline := time.Now().Add(10 * time.Second) + for { + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + _, _ = w.Write(data) + return + } + if r.Context().Err() != nil || time.Now().After(deadline) { + http.Error(w, "playlist not ready", http.StatusServiceUnavailable) + return + } + select { + case <-r.Context().Done(): + case <-time.After(100 * time.Millisecond): + } + } +} + // ServeInit writes init.mp4 (the fMP4 init segment) to w. func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) { s.Touch() @@ -1188,7 +1281,10 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in readyMax := s.readyMax s.readyMu.Unlock() - if idx >= readyMax+hlsSeekAhead || idx < segStart { + // Copy mode never seek-restarts: ffmpeg outruns playback (I/O-bound), the + // playlist only lists fully-written segments (temp_file), and segment + // indices don't map to uniform 2s slots anyway. Just wait for the writer. + if !s.cfg.VideoCopy && (idx >= readyMax+hlsSeekAhead || idx < segStart) { if err := s.restartFromSegment(idx); err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -1208,6 +1304,12 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in // `-ss` offset corresponds to segment `targetIdx`. The caller must NOT hold // s.mu when calling — the function takes both s.mu and s.readyMu. func (s *HLSSession) restartFromSegment(targetIdx int) error { + if s.cfg.VideoCopy { + // Defensive: callers already gate on VideoCopy, but the `-ss + // segmentStartSec(N)` math below assumes uniform 2s segments and + // would corrupt a copy session's keyframe-cut timeline. + return errors.New("hls: seek-restart not supported in copy mode") + } s.mu.Lock() if s.closed { s.mu.Unlock() @@ -1783,6 +1885,122 @@ func renderVideoPlaylist(durationSec float64, segCount int) string { // video variant + every text subtitle as an EXT-X-MEDIA group. Audio is muxed // into the video segments for the MVP — separate audio renditions can come // later (they require a second ffmpeg pipeline producing audio-only segments). +// buildHLSCopyArgs builds the ffmpeg invocation for VideoCopy mode: video +// stream copied bit-exact (`-c:v copy`, the segments cut at the source's own +// keyframes), audio copied when already AAC or re-encoded to AAC 192k +// otherwise, muxed to fMP4 HLS with ffmpeg writing its OWN media playlist +// (EVENT while running, ENDLIST on completion) with byte-exact EXTINF +// durations. Validated empirically on the incident source (HEVC Main10 + +// EAC3 MKV): seg-0 TTFB ~510 ms, valid hvc1+mp4a stream. +// +// Deliberate differences from the encode path: +// - no encoder/preset/bitrate/keyframe flags (nothing is encoded); +// - `+temp_file` so segments land atomically (write .tmp → rename) and a +// listed segment is always complete on disk; +// - playlist type EVENT: the timeline grows as ffmpeg outruns playback +// (I/O-bound) and players treat it as live-DVR until ENDLIST. +func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string { + args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"} + + // Resume: input-side seek snaps to the keyframe at/before StartSec (demux + // seek — instant). -output_ts_offset keeps the fragments' tfdt on the + // absolute timeline so the player's clock matches the real position. + if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec { + ss := strconv.FormatFloat(cfg.StartSec, 'f', 3, 64) + args = append(args, "-ss", ss) + } + + if cfg.SourceURL != "" { + args = append(args, + "-reconnect", "1", + "-reconnect_streamed", "1", + "-reconnect_delay_max", "5", + "-rw_timeout", "30000000", + ) + } + args = append(args, "-i", cfg.sourceRef()) + if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec { + args = append(args, "-output_ts_offset", strconv.FormatFloat(cfg.StartSec, 'f', 3, 64)) + } + + // Map video + selected audio (same clamping rules as the encode path). + args = append(args, "-map", "0:v:0") + audioIdx := cfg.AudioIndex + if audioIdx < 0 { + audioIdx = 0 + for i, a := range probe.AudioTracks { + if a.Default { + audioIdx = i + break + } + } + } + if n := len(probe.AudioTracks); n > 0 && audioIdx >= n { + log.Printf("[hls %s] audioIndex %d out of range (%d audio track(s)) — using 0:a:0", + shortHLSID(cfg.SessionID), audioIdx, n) + audioIdx = 0 + } + args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx)) + + // Video: bit-exact copy. HEVC needs the hvc1 tag or Safari/Apple refuses + // the track (mkv extracts default to hev1). + args = append(args, "-c:v", "copy") + if strings.EqualFold(probe.VideoCodec, "hevc") { + args = append(args, "-tag:v", "hvc1") + } + + // Audio: copy when the SELECTED track is already AAC, else AAC 192k. + // (fMP4 HLS carries AAC universally; EAC3/DTS/TrueHD do not.) + audioCodec := probe.AudioCodec + if audioIdx < len(probe.AudioTracks) { + audioCodec = probe.AudioTracks[audioIdx].Codec + } + if strings.EqualFold(audioCodec, "aac") { + args = append(args, "-c:a", "copy") + } else { + args = append(args, "-c:a", "aac", "-b:a", "192k") + } + + args = append(args, + "-f", "hls", + "-hls_time", strconv.Itoa(hlsSegmentDuration), + "-hls_playlist_type", "event", + "-hls_segment_type", "fmp4", + "-hls_list_size", "0", + "-hls_flags", "independent_segments+temp_file", + "-hls_fmp4_init_filename", "init.mp4", + "-hls_segment_filename", filepath.Join(tmpDir, "video", "seg-%d.m4s"), + filepath.Join(tmpDir, "video", copyPlaylistName), + ) + return args +} + +// renderMasterPlaylistCopy builds the master playlist for VideoCopy mode. +// Unlike the encode master it deliberately OMITS the CODECS attribute: the +// stream carries the source's codec verbatim (hvc1/avc1/av01 at whatever +// profile/level the file has) and a wrong hardcoded string makes iOS reject +// the variant outright, while omission is legal and universally tolerated. +// Resolution/bandwidth are the source's real values (best-effort). +func renderMasterPlaylistCopy(probe *StreamProbe) string { + var b strings.Builder + b.WriteString("#EXTM3U\n") + b.WriteString("#EXT-X-VERSION:7\n") + // BANDWIDTH is advisory (single variant, no ABR) — a height-based + // estimate of typical source bitrates is plenty. + bw := 8_000_000 + switch { + case probe.Height >= 2000: + bw = 25_000_000 + case probe.Height >= 1000: + bw = 10_000_000 + case probe.Height >= 700: + bw = 5_000_000 + } + b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d\n", bw, probe.Width, probe.Height)) + b.WriteString("video/index.m3u8\n") + return b.String() +} + func renderMasterPlaylist(probe *StreamProbe, qualityLabel string) string { var b strings.Builder b.WriteString("#EXTM3U\n") diff --git a/internal/engine/hls_copy_smoke_test.go b/internal/engine/hls_copy_smoke_test.go new file mode 100644 index 0000000..4ab5b3b --- /dev/null +++ b/internal/engine/hls_copy_smoke_test.go @@ -0,0 +1,262 @@ +//go:build smoke + +package engine + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// HLS-copy integration suite — real ffmpeg, synthetic sources replicating +// every shape that broke the progressive-remux path in production: +// +// h264+aac mkv → video copy + audio copy +// h264+ac3 mkv → video copy + audio re-encode (the priming-dts class +// that needed delay_moov on the old remux) +// hevc10+eac3 mkv → the exact "Hoppers" incident shape (Main10, hvc1 tag) +// resume (-ss) → StartSec mid-file, timeline offset +// +// Asserts on every run: ffmpeg's playlist reaches ENDLIST, EXTINF sum ≈ +// source duration, every listed segment exists non-empty, ffprobe decodes +// the served playlist with the EXPECTED codecs, and the video stream was +// NOT re-encoded (copy must preserve the source codec). +// +// go test -tags=smoke -run TestHLSCopy -v ./internal/engine/ +func copyTestRuntime(t *testing.T) TranscodeRuntime { + t.Helper() + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skipf("ffmpeg not on PATH: %v", err) + } + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + t.Skipf("ffprobe not on PATH: %v", err) + } + return TranscodeRuntime{FFmpegPath: ffmpeg, FFprobePath: ffprobe} +} + +// genSource synthesises a test file. encV/encA are the SOURCE encoders; skip +// the test when the local ffmpeg lacks them (libx265 is optional in some +// builds). +func genSource(t *testing.T, rt TranscodeRuntime, name string, vArgs, aArgs []string, durSec int) string { + t.Helper() + out := filepath.Join(t.TempDir(), name) + args := []string{ + "-y", "-loglevel", "error", + "-f", "lavfi", "-i", fmt.Sprintf("testsrc2=duration=%d:size=640x360:rate=30", durSec), + "-f", "lavfi", "-i", fmt.Sprintf("sine=frequency=440:duration=%d", durSec), + } + args = append(args, vArgs...) + args = append(args, aArgs...) + // Short GOP so the copy cuts several segments even on a short source. + args = append(args, "-g", "60", "-keyint_min", "60", out) + if outB, err := exec.Command(rt.FFmpegPath, args...).CombinedOutput(); err != nil { + if strings.Contains(string(outB), "Unknown encoder") { + t.Skipf("source encoder unavailable: %s", string(outB)) + } + t.Fatalf("generate %s: %v\n%s", name, err, outB) + } + return out +} + +// runCopySession starts a VideoCopy session and waits for ffmpeg's playlist +// to reach ENDLIST. Returns the session and the final playlist text. +func runCopySession(t *testing.T, rt TranscodeRuntime, source string, startSec float64) (*HLSSession, string) { + t.Helper() + s, err := StartHLSSession(context.Background(), HLSSessionConfig{ + SessionID: "copytest" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10), + SourcePath: source, + FileName: filepath.Base(source), + AudioIndex: -1, + StartSec: startSec, + VideoCopy: true, + Transcode: rt, + }) + if err != nil { + t.Fatalf("StartHLSSession(copy): %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + playlistPath := filepath.Join(s.tmpDir, "video", copyPlaylistName) + deadline := time.Now().Add(30 * time.Second) + for { + data, err := os.ReadFile(playlistPath) + if err == nil && strings.Contains(string(data), "#EXT-X-ENDLIST") { + return s, string(data) + } + if time.Now().After(deadline) { + t.Fatalf("playlist never reached ENDLIST; last read err=%v contents:\n%s", err, string(data)) + } + time.Sleep(100 * time.Millisecond) + } +} + +// assertCopyOutput validates playlist structure, segment files, and (via +// ffprobe over the playlist) that the served stream carries the expected +// codecs — wantVideo MUST equal the source codec, proving no re-encode. +func assertCopyOutput(t *testing.T, rt TranscodeRuntime, s *HLSSession, playlist, wantVideo, wantAudio string, wantDur float64) { + t.Helper() + if !strings.Contains(playlist, "#EXT-X-PLAYLIST-TYPE:EVENT") { + t.Errorf("playlist missing EVENT type:\n%s", playlist) + } + if !strings.Contains(playlist, `#EXT-X-MAP:URI="init.mp4"`) { + t.Errorf("playlist missing EXT-X-MAP init.mp4") + } + + var sum float64 + segs := 0 + for _, line := range strings.Split(playlist, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#EXTINF:") { + v := strings.TrimSuffix(strings.TrimPrefix(line, "#EXTINF:"), ",") + d, err := strconv.ParseFloat(v, 64) + if err != nil { + t.Fatalf("bad EXTINF %q: %v", line, err) + } + sum += d + } else if strings.HasSuffix(line, ".m4s") { + segs++ + fi, err := os.Stat(filepath.Join(s.tmpDir, "video", line)) + if err != nil || fi.Size() == 0 { + t.Errorf("listed segment %s missing/empty: %v", line, err) + } + } + } + if segs == 0 { + t.Fatalf("no segments listed:\n%s", playlist) + } + if sum < wantDur-1.5 || sum > wantDur+1.5 { + t.Errorf("EXTINF sum = %.2fs, want ≈%.2fs (±1.5)", sum, wantDur) + } + + // ffprobe over the playlist = a real demuxer consuming init + segments. + out, err := exec.Command(rt.FFprobePath, "-v", "error", + "-show_entries", "stream=codec_type,codec_name", + "-of", "csv=p=0", + filepath.Join(s.tmpDir, "video", copyPlaylistName)).CombinedOutput() + if err != nil { + t.Fatalf("ffprobe playlist: %v\n%s", err, out) + } + probeStr := string(out) + if !strings.Contains(probeStr, wantVideo+",video") && !strings.Contains(probeStr, "video,"+wantVideo) && + !strings.Contains(probeStr, wantVideo) { + t.Errorf("video codec: probe=%q want %q (copy must NOT re-encode)", probeStr, wantVideo) + } + if !strings.Contains(probeStr, wantAudio) { + t.Errorf("audio codec: probe=%q want %q", probeStr, wantAudio) + } +} + +func TestHLSCopy_H264AacCopyBoth(t *testing.T) { + rt := copyTestRuntime(t) + src := genSource(t, rt, "h264aac.mkv", + []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, + []string{"-c:a", "aac", "-b:a", "128k"}, 8) + s, pl := runCopySession(t, rt, src, 0) + assertCopyOutput(t, rt, s, pl, "h264", "aac", 8) + // Audio already AAC → the args must COPY it, not re-encode. + args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir) + if !containsSeq(args, "-c:a", "copy") { + t.Errorf("expected -c:a copy for AAC source, args: %v", args) + } +} + +func TestHLSCopy_H264Ac3TranscodesAudio(t *testing.T) { + rt := copyTestRuntime(t) + src := genSource(t, rt, "h264ac3.mkv", + []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, + []string{"-c:a", "ac3", "-b:a", "192k"}, 8) + s, pl := runCopySession(t, rt, src, 0) + // The re-encoded AAC track starts with a priming dts — the exact shape + // that produced a malformed init on the old progressive remux. The HLS + // muxer must land a probe-clean stream regardless. + assertCopyOutput(t, rt, s, pl, "h264", "aac", 8) + args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir) + if !containsSeq(args, "-c:a", "aac") { + t.Errorf("expected -c:a aac for AC3 source, args: %v", args) + } +} + +func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) { + rt := copyTestRuntime(t) + src := genSource(t, rt, "hevc10eac3.mkv", + []string{"-c:v", "libx265", "-preset", "ultrafast", "-pix_fmt", "yuv420p10le", "-x265-params", "log-level=error"}, + []string{"-c:a", "eac3", "-b:a", "192k"}, 8) + s, pl := runCopySession(t, rt, src, 0) + assertCopyOutput(t, rt, s, pl, "hevc", "aac", 8) + // HEVC must carry the hvc1 tag or Safari refuses the track. + args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir) + if !containsSeq(args, "-tag:v", "hvc1") { + t.Errorf("expected -tag:v hvc1 for HEVC source, args: %v", args) + } +} + +func TestHLSCopy_ResumeStartSec(t *testing.T) { + rt := copyTestRuntime(t) + src := genSource(t, rt, "resume.mkv", + []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, + []string{"-c:a", "aac", "-b:a", "128k"}, 12) + _, pl := runCopySession(t, rt, src, 6) + // Resume covers roughly the back half (keyframe-snapped, so allow the + // full GOP of slack: 60 frames @30fps = 2s). + var sum float64 + for _, line := range strings.Split(pl, "\n") { + if strings.HasPrefix(line, "#EXTINF:") { + v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), "#EXTINF:"), ",") + d, _ := strconv.ParseFloat(v, 64) + sum += d + } + } + if sum < 4 || sum > 9 { + t.Errorf("resume EXTINF sum = %.2fs, want ≈6s (12s source, -ss 6, ±GOP)", sum) + } +} + +func TestHLSCopy_ServeVideoPlaylistFromDisk(t *testing.T) { + rt := copyTestRuntime(t) + src := genSource(t, rt, "serve.mkv", + []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, + []string{"-c:a", "aac", "-b:a", "128k"}, 6) + s, _ := runCopySession(t, rt, src, 0) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/hls/x/video/index.m3u8", nil) + s.ServeVideoPlaylist(rec, req) + if rec.Code != 200 { + t.Fatalf("ServeVideoPlaylist = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "#EXT-X-ENDLIST") || !strings.Contains(body, "seg-0.m4s") { + t.Errorf("served playlist incomplete:\n%s", body) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" { + t.Errorf("Content-Type = %q", ct) + } + + // Master: no CODECS attr (a wrong hardcoded string makes iOS reject the + // variant; omission is legal), real resolution present. + master := s.MasterPlaylist() + if strings.Contains(master, "CODECS") { + t.Errorf("copy master must omit CODECS:\n%s", master) + } + if !strings.Contains(master, "RESOLUTION=640x360") { + t.Errorf("copy master missing real resolution:\n%s", master) + } +} + +func containsSeq(args []string, a, b string) bool { + for i := 0; i < len(args)-1; i++ { + if args[i] == a && args[i+1] == b { + return true + } + } + return false +} From 9eb3e44153837fb2691fa0375378ee12729b912d Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 10 Jun 2026 23:31:58 +0200 Subject: [PATCH 2/5] fix(stream): el modo copy ignora StartSec (offset EVENT rompe iOS nativo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un playlist EVENT cuyas entradas empiezan en 0 mientras los fragmentos llevan tfdt desplazado (-ss + -output_ts_offset) es exactamente la forma que el parser HLS nativo de iOS no traga: resume a 368s → error del player y bucle de re-bootstrap de sesión en iPhone (observado 2026-06-10). Copy produce siempre desde 0 con PTS absolutos reales: adelanta a la reproducción a velocidad de I/O, así que el punto de resume aparece en la timeline creciente en segundos y el seek de startPosition del player aterriza con normalidad. Test de resume actualizado: el playlist debe cubrir la timeline completa. --- internal/engine/hls.go | 21 ++++++++++----------- internal/engine/hls_copy_smoke_test.go | 9 +++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 8f90c15..cf9c506 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -205,7 +205,8 @@ type HLSSessionConfig struct { // file is remuxed at I/O speed, minutes at worst on a weak NAS); // - no HLS cache (re-generating costs no encode — caching would only // burn disk); - // - StartSec is passed straight to `-ss` (keyframe-snapped by ffmpeg). + // - StartSec is ignored: copy produces from 0 (outruns playback at I/O + // speed); an offset EVENT playlist breaks iOS's native HLS parser. // See docs/plans/hls-copy-remux-replacement.md (web repo). VideoCopy bool } @@ -1902,13 +1903,14 @@ func renderVideoPlaylist(durationSec float64, segCount int) string { func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string { args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"} - // Resume: input-side seek snaps to the keyframe at/before StartSec (demux - // seek — instant). -output_ts_offset keeps the fragments' tfdt on the - // absolute timeline so the player's clock matches the real position. - if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec { - ss := strconv.FormatFloat(cfg.StartSec, 'f', 3, 64) - args = append(args, "-ss", ss) - } + // StartSec is INTENTIONALLY ignored in copy mode: an EVENT playlist whose + // entries start at position 0 while the fragments carry an offset tfdt + // (-ss + -output_ts_offset) is exactly the shape iOS's native HLS parser + // chokes on (observed 2026-06-10: resume at 368s → player error + session + // re-bootstrap loop on iPhone). Copy always produces from 0 with true + // absolute PTS — it outruns playback at I/O speed, so the resume point + // appears in the growing timeline within seconds and the player's own + // startPosition seek lands normally. if cfg.SourceURL != "" { args = append(args, @@ -1919,9 +1921,6 @@ func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) [ ) } args = append(args, "-i", cfg.sourceRef()) - if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec { - args = append(args, "-output_ts_offset", strconv.FormatFloat(cfg.StartSec, 'f', 3, 64)) - } // Map video + selected audio (same clamping rules as the encode path). args = append(args, "-map", "0:v:0") diff --git a/internal/engine/hls_copy_smoke_test.go b/internal/engine/hls_copy_smoke_test.go index 4ab5b3b..203dcb3 100644 --- a/internal/engine/hls_copy_smoke_test.go +++ b/internal/engine/hls_copy_smoke_test.go @@ -205,8 +205,9 @@ func TestHLSCopy_ResumeStartSec(t *testing.T) { []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, []string{"-c:a", "aac", "-b:a", "128k"}, 12) _, pl := runCopySession(t, rt, src, 6) - // Resume covers roughly the back half (keyframe-snapped, so allow the - // full GOP of slack: 60 frames @30fps = 2s). + // StartSec must be IGNORED in copy mode: the playlist covers the FULL + // timeline from 0 (an offset EVENT playlist breaks iOS's native parser; + // the player seeks to the resume point itself). Sum ≈ full 12s. var sum float64 for _, line := range strings.Split(pl, "\n") { if strings.HasPrefix(line, "#EXTINF:") { @@ -215,8 +216,8 @@ func TestHLSCopy_ResumeStartSec(t *testing.T) { sum += d } } - if sum < 4 || sum > 9 { - t.Errorf("resume EXTINF sum = %.2fs, want ≈6s (12s source, -ss 6, ±GOP)", sum) + if sum < 10.5 || sum > 13.5 { + t.Errorf("copy EXTINF sum = %.2fs, want ≈12s (StartSec ignored, full timeline)", sum) } } From 6c756a256908c8a72bef4b2bf3f82eb5830375f7 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 10 Jun 2026 23:51:14 +0200 Subject: [PATCH 3/5] fix(stream): EXT-X-START=0 en el playlist copy mientras crece MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta que llega ENDLIST la sesión copy es un EVENT creciente y algunos players nativos (iOS) tratan un playlist sin terminar como LIVE: se enganchan al borde en vez de a la posición 0. EXT-X-START:TIME-OFFSET=0 (RFC 8216 §4.3.5.2) fija el arranque explícitamente; inofensivo cuando el playlist ya es final. Coincide con el patrón observado: episodios cortos (ENDLIST en segundos) reproducían en iPhone, películas (EVENT durante minutos) no. --- internal/engine/hls.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index cf9c506..fc5a297 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1212,7 +1212,18 @@ func (s *HLSSession) serveCopyPlaylist(w http.ResponseWriter, r *http.Request) { for { data, err := os.ReadFile(path) if err == nil && len(data) > 0 { - _, _ = w.Write(data) + // Until ENDLIST lands a copy session is a growing EVENT playlist, + // and some native players (iOS) treat any not-yet-ended playlist + // like LIVE and join at the live edge instead of position 0. + // EXT-X-START pins the start to 0 explicitly (RFC 8216 §4.3.5.2); + // harmless once the playlist is final. + out := data + if !strings.Contains(string(data), "#EXT-X-START") { + out = []byte(strings.Replace(string(data), + "#EXT-X-VERSION:7\n", + "#EXT-X-VERSION:7\n#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n", 1)) + } + _, _ = w.Write(out) return } if r.Context().Err() != nil || time.Now().After(deadline) { From f89396ceede728a0d591ddc465dc4285f7c9513e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 11 Jun 2026 00:02:53 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(stream):=20downmix=20est=C3=A9reo=20en?= =?UTF-8?q?=20el=20audio=20re-encodeado=20del=20modo=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sin -ac 2 una fuente 5.1 (AC3/EAC3) producía AAC de 6 canales del encoder nativo de ffmpeg, que WebKit/Apple HLS rechaza al sniffar el primer segmento: en el access log de Safari se ve master → index → init → seg-0 dos veces y silencio. Era el discriminador exacto del patrón de campo: episodios con AAC estéreo (copy de audio) reproducían en iPhone; todas las películas 5.1 fallaban. Verificado con Safari/macOS via WebDriver-less access log: con -ac 2 la progresión de segmentos avanza con normalidad. Espeja los flags del path de encode (aac 192k 48kHz estéreo). Test smoke ampliado: el re-encode debe llevar -ac 2. --- internal/engine/hls.go | 7 ++++++- internal/engine/hls_copy_smoke_test.go | 6 ++++++ internal/engine/stream_server.go | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index fc5a297..4cf6576 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1968,7 +1968,12 @@ func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) [ if strings.EqualFold(audioCodec, "aac") { args = append(args, "-c:a", "copy") } else { - args = append(args, "-c:a", "aac", "-b:a", "192k") + // Mirror the encode path exactly: AAC stereo 48k. WITHOUT -ac 2 a 5.1 + // source produces 6-channel ffmpeg-native AAC, which WebKit/Apple HLS + // rejects at the first media segment (observed via Safari access log: + // master → index → init → seg-0 fetched twice, then silence — every + // 5.1 movie failed on iPhone while stereo-AAC episodes played). + args = append(args, "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2") } args = append(args, diff --git a/internal/engine/hls_copy_smoke_test.go b/internal/engine/hls_copy_smoke_test.go index 203dcb3..8bc867c 100644 --- a/internal/engine/hls_copy_smoke_test.go +++ b/internal/engine/hls_copy_smoke_test.go @@ -183,6 +183,12 @@ func TestHLSCopy_H264Ac3TranscodesAudio(t *testing.T) { if !containsSeq(args, "-c:a", "aac") { t.Errorf("expected -c:a aac for AC3 source, args: %v", args) } + // MUST downmix to stereo: 6-channel ffmpeg-native AAC is rejected by + // WebKit/Apple HLS at the first media segment (every 5.1 movie failed on + // iPhone while stereo-AAC sources played — confirmed via Safari access log). + if !containsSeq(args, "-ac", "2") { + t.Errorf("expected -ac 2 (stereo downmix) for re-encoded audio, args: %v", args) + } } func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) { diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 43486a9..3478d54 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -690,6 +690,14 @@ func (ss *StreamServer) HLSURLsJSON(sessionID string) string { func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) { ss.lastActivity.Store(time.Now().UnixNano()) + // Debug access log (UNARR_HLS_DEBUG=1): which client fetches which HLS + // resource. Off by default — a player polls the playlist every few + // seconds and segments stream constantly, far too chatty for normal logs. + if os.Getenv("UNARR_HLS_DEBUG") == "1" { + host, _, _ := net.SplitHostPort(r.RemoteAddr) + log.Printf("[hls-debug] %s %s from %s UA=%q", r.Method, r.URL.Path, host, r.Header.Get("User-Agent")) + } + if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") { return } From a4a6e2f2d69003e744d081fc1b277bcd0a4196bc Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 11 Jun 2026 00:05:50 +0200 Subject: [PATCH 5/5] fix(stream): no copiar AAC multicanal en modo copy (WebKit lo rechaza igual) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El downmix estéreo del re-encode (f89396c) dejaba un agujero simétrico: una fuente cuyo audio YA es AAC 5.1 se copiaba tal cual, y WebKit rechaza el AAC multicanal en el primer segmento exactamente igual que el re-encodeado. Copy de audio ahora solo cuando la pista es AAC con ≤2 canales; cualquier otra cosa (no-AAC, AAC 5.1+, o canales desconocidos en el probe — fail-safe) re-encodea a AAC estéreo 48k. La pista multicanal original queda intacta para reproductor externo. Test smoke nuevo: fuente AAC 5.1 → re-encode. --- internal/engine/hls.go | 18 ++++++++++-------- internal/engine/hls_copy_smoke_test.go | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 4cf6576..3add225 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1959,20 +1959,22 @@ func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) [ args = append(args, "-tag:v", "hvc1") } - // Audio: copy when the SELECTED track is already AAC, else AAC 192k. - // (fMP4 HLS carries AAC universally; EAC3/DTS/TrueHD do not.) + // Audio: copy ONLY when the selected track is AAC with ≤2 channels — + // WebKit/Apple HLS rejects multichannel AAC at the first media segment + // (observed via the Safari access log: master → index → init → seg-0 + // fetched twice, then silence — every 5.1 movie failed on iPhone while + // stereo-AAC episodes played). Anything else (non-AAC, or AAC 5.1+) is + // re-encoded mirroring the encode path exactly: AAC stereo 48k. The + // original multichannel track stays intact for external players. audioCodec := probe.AudioCodec + audioChannels := 0 if audioIdx < len(probe.AudioTracks) { audioCodec = probe.AudioTracks[audioIdx].Codec + audioChannels = probe.AudioTracks[audioIdx].Channels } - if strings.EqualFold(audioCodec, "aac") { + if strings.EqualFold(audioCodec, "aac") && audioChannels > 0 && audioChannels <= 2 { args = append(args, "-c:a", "copy") } else { - // Mirror the encode path exactly: AAC stereo 48k. WITHOUT -ac 2 a 5.1 - // source produces 6-channel ffmpeg-native AAC, which WebKit/Apple HLS - // rejects at the first media segment (observed via Safari access log: - // master → index → init → seg-0 fetched twice, then silence — every - // 5.1 movie failed on iPhone while stereo-AAC episodes played). args = append(args, "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2") } diff --git a/internal/engine/hls_copy_smoke_test.go b/internal/engine/hls_copy_smoke_test.go index 8bc867c..df15eb4 100644 --- a/internal/engine/hls_copy_smoke_test.go +++ b/internal/engine/hls_copy_smoke_test.go @@ -205,6 +205,25 @@ func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) { } } +func TestHLSCopy_Aac51MustReencode(t *testing.T) { + // AAC is NOT copy-safe when multichannel: WebKit rejects 6-channel AAC at + // the first media segment exactly like re-encoded 5.1. Source AAC 5.1 → + // must re-encode to stereo, never copy. + rt := copyTestRuntime(t) + src := genSource(t, rt, "aac51.mkv", + []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"}, + []string{"-c:a", "aac", "-ac", "6", "-b:a", "256k"}, 8) + s, pl := runCopySession(t, rt, src, 0) + assertCopyOutput(t, rt, s, pl, "h264", "aac", 8) + args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir) + if containsSeq(args, "-c:a", "copy") { + t.Errorf("AAC 5.1 must NOT be copied (WebKit rejects multichannel AAC), args: %v", args) + } + if !containsSeq(args, "-ac", "2") { + t.Errorf("AAC 5.1 must re-encode to stereo, args: %v", args) + } +} + func TestHLSCopy_ResumeStartSec(t *testing.T) { rt := copyTestRuntime(t) src := genSource(t, rt, "resume.mkv",