package playback import ( "bytes" "context" "fmt" "io" "log/slog" "net/http" "os" "os/exec" "strconv" "strings" "sync" "github.com/Silo-Server/silo-server/internal/httpstream" ) var ( resolvedFFmpegPath string ffmpegOnce sync.Once doviRPUMu sync.Mutex doviRPUCache map[string]bool ) // ffmpegBinary returns the path to the ffmpeg binary. // Resolved once at first call, then cached for the process lifetime. func ffmpegBinary() string { ffmpegOnce.Do(func() { const jellyfinPath = "/usr/lib/jellyfin-ffmpeg/ffmpeg" if _, err := exec.LookPath(jellyfinPath); err == nil { resolvedFFmpegPath = jellyfinPath return } resolvedFFmpegPath = "ffmpeg" }) return resolvedFFmpegPath } // ResolveFFmpegPath returns the ffmpeg binary the playback pipeline executes // for the given configured path: the configured path when set, otherwise the // process-global discovery (jellyfin-ffmpeg install, then PATH). Capability // probes must resolve through this same function so a feature advertised at // planning time is guaranteed present in the binary that later runs. func ResolveFFmpegPath(configured string) string { if configured = strings.TrimSpace(configured); configured != "" { return configured } return ffmpegBinary() } // supportsDoviRPUFilter reports whether the given FFmpeg binary can strip // Dolby Vision RPU metadata via the dovi_rpu bitstream filter (FFmpeg 7.1+). // The enhancement layer itself is dropped by stream mapping, so stripping the // RPUs yields a clean HDR10 base layer. Probed once per binary path. func supportsDoviRPUFilter(bin string) bool { doviRPUMu.Lock() defer doviRPUMu.Unlock() if available, ok := doviRPUCache[bin]; ok { return available } out, err := exec.Command(bin, "-hide_banner", "-bsfs").Output() available := err == nil && bytes.Contains(out, []byte("dovi_rpu")) if !available { slog.Warn("ffmpeg lacks the dovi_rpu bitstream filter (needs FFmpeg 7.1+); validated Profile 7 HDR10 remux is disabled", "ffmpeg", bin) } if doviRPUCache == nil { doviRPUCache = make(map[string]bool) } doviRPUCache[bin] = available return available } // remuxDVProfile neutralizes a Dolby Vision profile the local ffmpeg cannot // handle. Profile 7 is the only profile that triggers an RPU strip in // buildRemuxArgs; when the strip is unavailable — the dovi_rpu filter is // missing, or this source's RPU cannot be parsed — the remux must still start // (an unknown bitstream filter aborts ffmpeg immediately, and a filter that // rejects every packet hangs the session), so fall back to the pre-strip // behavior instead of failing playback. Only the legacy/auto mode takes this // route; the explicit v3 strip recipe fails loudly instead, because it has // promised the client an HDR10 output it could not then produce. func remuxDVProfile(dvProfile int, canStripRPU bool) int { if dvProfile == 7 && !canStripRPU { return 0 } return dvProfile } // RemuxSession represents a running ffmpeg remux process that copies // codecs to a new container format without re-encoding. type RemuxSession struct { cmd *exec.Cmd cancel context.CancelFunc outputPipe io.ReadCloser } // RemuxDVMode makes Profile 7 handling an explicit byte-level recipe choice. // The empty/legacy mode exists only for pre-v3 callers and old stream tokens. type RemuxDVMode string const ( RemuxDVLegacyAutoV3 RemuxDVMode = "legacy_auto" RemuxDVPreserveV3 RemuxDVMode = "preserve" RemuxDVStripToHDR10V3 RemuxDVMode = "strip_to_hdr10" RemuxDVRejectP7V3 RemuxDVMode = "reject_profile_7" ) // buildRemuxArgs constructs the ffmpeg argument list for a remux operation. // The args perform codec copy (-c copy) into the target container format, // using fragmented output for streaming (frag_keyframe+delay_moov+default_base_moof) and // pipe:1 for stdout output. // When transcodeAudio is true, video is copied but audio is transcoded to // stereo AAC (handles cases like DTS/TrueHD that browsers cannot decode). // dvProfile is the file's Dolby Vision profile (0 = none). Profile 7 remuxes // strip DV RPUs: the enhancement layer is dropped by the video map below, so // the RPUs would dangle — stripping yields a clean HDR10 base layer (the // Apple-parity fallback for devices without a P7 decoder). Profile 8 RPUs // stay: the base layer is self-contained and DV clients can render it. func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagDVSampleEntry, audioOnly bool) []string { return buildRemuxArgsWithAudioV3(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, tagDVSampleEntry, audioOnly, 0, 0) } func buildRemuxArgsWithAudioV3(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagDVSampleEntry, audioOnly bool, targetAudioChannels, targetAudioBitrateKbps int) []string { args := []string{ "-nostdin", "-hide_banner", "-loglevel", "error", // Cap the input probe to speed up startup on large MKV containers // (defaults scan up to 5 seconds / several MB). Mirrors the transcode // pipeline, which has run these caps in production without issue. "-fflags", "+genpts+fastseek", "-analyzeduration", "3000000", "-probesize", "5000000", } // Add seek if requested (before input for fast seek). if seekSeconds > 0 { args = append(args, "-ss", strconv.FormatFloat(seekSeconds, 'f', 3, 64)) if transcodeAudio { // In copy mode (-c:v copy) video must start at a keyframe, but // accurate_seek (the default) trims re-encoded audio to the exact // seek point. This mismatch causes A/V desync equal to the gap // between the keyframe and the seek point. Disabling accurate seek // keeps both streams aligned at the keyframe boundary. args = append(args, "-noaccurate_seek") } } args = append(args, "-i", filePath) // Strip metadata/chapters and skip subtitle/data streams — the remux // output is fed to an HTML