feat(stream): real-time transcoding for non-browser-decodable codecs

Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.

Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
  * diskFileSource: passthrough when codecs are already browser-friendly.
  * transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
    A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
    produces output. Estimate of final size (bitrate × duration) is
    announced over the wire so the browser's scrubber has something to
    anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
  carries Transcoding=true + an estimated total size; Seekable=true (we
  read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
  cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
  (NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
  (auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
  max_height (optional downscale), max_concurrent (safety cap).

Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
This commit is contained in:
Deivid Soto 2026-05-07 09:26:05 +02:00
parent 4314c06c5c
commit 66ac79664b
6 changed files with 583 additions and 51 deletions

View file

@ -451,6 +451,7 @@ func runDaemonStart() error {
webrtcRegistry.remove(sess.SessionID)
sessCancel()
}()
tcRuntime := buildTranscodeRuntime(ctx, cfg)
runCfg := engine.WebRTCStreamConfig{
SessionID: sess.SessionID,
FilePath: filePath,
@ -459,6 +460,7 @@ func runDaemonStart() error {
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
Signal: agentClient,
Logger: stdLogger{},
Transcode: tcRuntime,
}
log.Printf("[wrtc %s] starting session: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
if err := engine.RunWebRTCStream(sessCtx, runCfg); err != nil {

View file

@ -4,6 +4,10 @@ import (
"context"
"log"
"sync"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// webrtcRegistry tracks per-session cancel funcs for active custom WebRTC
@ -60,3 +64,43 @@ type stdLogger struct{}
func (stdLogger) Infof(format string, args ...any) { log.Printf(format, args...) }
func (stdLogger) Warnf(format string, args ...any) { log.Printf("WARN: "+format, args...) }
func (stdLogger) Errorf(format string, args ...any) { log.Printf("ERROR: "+format, args...) }
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// for the WebRTC streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so engine.RunWebRTCStream falls back to
// passthrough — the user gets a clearer codec error from the browser than a
// daemon-side abort.
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
if !cfg.Download.Transcode.Enabled {
return engine.TranscodeRuntime{Disabled: true}
}
ffmpegPath, errF := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
ffprobePath, errP := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath)
if errF != nil || errP != nil {
return engine.TranscodeRuntime{Disabled: true}
}
hw := engine.HWAccelNone
switch cfg.Download.Transcode.HWAccel {
case "auto":
hw = engine.DetectHWAccel(ctx, ffmpegPath)
case "nvenc":
hw = engine.HWAccelNVENC
case "qsv":
hw = engine.HWAccelQSV
case "vaapi":
hw = engine.HWAccelVAAPI
case "videotoolbox":
hw = engine.HWAccelVideoToolbox
case "none", "":
hw = engine.HWAccelNone
}
return engine.TranscodeRuntime{
FFmpegPath: ffmpegPath,
FFprobePath: ffprobePath,
HWAccel: hw,
Preset: cfg.Download.Transcode.Preset,
VideoBitrate: cfg.Download.Transcode.VideoBitrate,
AudioBitrate: cfg.Download.Transcode.AudioBitrate,
MaxHeight: cfg.Download.Transcode.MaxHeight,
}
}