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.
106 lines
3.3 KiB
Go
106 lines
3.3 KiB
Go
package cmd
|
|
|
|
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
|
|
// streams (engine.RunWebRTCStream goroutines). Each session lives only as
|
|
// long as its DataChannel; the registry exists so duplicate sync responses
|
|
// don't double-spawn the same session and so daemon shutdown can drain.
|
|
var webrtcRegistry = &webrtcSessionRegistry{
|
|
cancels: make(map[string]context.CancelFunc),
|
|
}
|
|
|
|
type webrtcSessionRegistry struct {
|
|
mu sync.Mutex
|
|
cancels map[string]context.CancelFunc
|
|
}
|
|
|
|
func (r *webrtcSessionRegistry) has(sessionID string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
_, ok := r.cancels[sessionID]
|
|
return ok
|
|
}
|
|
|
|
func (r *webrtcSessionRegistry) add(sessionID string, cancel context.CancelFunc) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.cancels[sessionID] = cancel
|
|
}
|
|
|
|
func (r *webrtcSessionRegistry) remove(sessionID string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
delete(r.cancels, sessionID)
|
|
}
|
|
|
|
// cancelAllWebRTCSessions cancels every running session. Called on daemon
|
|
// shutdown so pion peers and SSE consumers exit cleanly.
|
|
func cancelAllWebRTCSessions() {
|
|
webrtcRegistry.mu.Lock()
|
|
cancels := make([]context.CancelFunc, 0, len(webrtcRegistry.cancels))
|
|
for _, c := range webrtcRegistry.cancels {
|
|
cancels = append(cancels, c)
|
|
}
|
|
webrtcRegistry.cancels = make(map[string]context.CancelFunc)
|
|
webrtcRegistry.mu.Unlock()
|
|
for _, c := range cancels {
|
|
c()
|
|
}
|
|
}
|
|
|
|
// stdLogger is a tiny adapter so engine.RunWebRTCStream can log through the
|
|
// standard library logger without pulling in a logging dependency.
|
|
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,
|
|
}
|
|
}
|