feat(stream)!: retire WebRTC, HLS-only, bump 0.9.4
Drops the custom WebRTC DataChannel pipeline + pion deps + WSS signaling client + wire framing. Every in-browser playback now uses HLS over HTTP from the daemon (Tailscale/LAN/UPnP). Browser P2P never re-enabled. Wire renames (incompatible with web < 2026-05-26): agent.WebRTCSession => agent.StreamSession, SyncResponse.WebRTCSessions (JSON: webrtcSessions) => StreamSessions (JSON: streamSessions). MIN_AGENT_VERSION is bumped to 0.9.4 on the web side so older agents see an upgrade card. Also fixes the libx264 'VBV bitrate > level limit' abort by clamping the encoder bitrate to the effective output height instead of the requested label (carried over from the prior 0.9.3 unreleased work). The seed_file vertical (mode=seed_file handler + engine.SeedFile) was retired with the in-browser P2P player. [downloads.webrtc] config block deleted; existing TOML files with the section still parse fine.
This commit is contained in:
parent
9176e877eb
commit
ca7de23a56
33 changed files with 207 additions and 2854 deletions
|
|
@ -255,9 +255,6 @@ func runDaemonStart() error {
|
|||
MaxUploadRate: maxUl,
|
||||
ListenPort: cfg.Download.ListenPort,
|
||||
SeedEnabled: false,
|
||||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||
VPNTunnel: vpnTunnel,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -330,13 +327,7 @@ func runDaemonStart() error {
|
|||
// Wire: sync receives new tasks → submit to manager or handle stream
|
||||
d.OnTasksClaimed = func(tasks []agent.Task) {
|
||||
for _, t := range tasks {
|
||||
if t.Mode == "seed_file" {
|
||||
// Browser asked us to wrap an arbitrary on-disk file as
|
||||
// a single-file torrent + seed it via WebRTC. Runs in
|
||||
// its own goroutine so a slow / failing seed can't
|
||||
// stall the rest of the claim batch.
|
||||
go handleSeedFileTask(t, torrentDl, agentClient)
|
||||
} else if t.Mode == "stream" {
|
||||
if t.Mode == "stream" {
|
||||
if isStreamingTask(t.ID) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -497,23 +488,23 @@ func runDaemonStart() error {
|
|||
}()
|
||||
}
|
||||
|
||||
// Wire: sync receives custom WebRTC streaming session requests.
|
||||
// Each session is a one-shot browser↔daemon DataChannel. Validate the
|
||||
// FilePath against allowed dirs to prevent path traversal abuse from a
|
||||
// compromised server, then spawn the pion peer in its own goroutine.
|
||||
d.OnWebRTCSession = func(sess agent.WebRTCSession) {
|
||||
if webrtcRegistry.has(sess.SessionID) {
|
||||
// Wire: sync receives HLS streaming session requests. Each session spawns
|
||||
// one ffmpeg process and registers its HLS playlist with the StreamServer.
|
||||
// Validate FilePath against allowed dirs to prevent path traversal abuse
|
||||
// from a compromised server.
|
||||
d.OnStreamSession = func(sess agent.StreamSession) {
|
||||
if playerSessionRegistry.has(sess.SessionID) {
|
||||
return // already running
|
||||
}
|
||||
filePath := sess.FilePath
|
||||
if filePath == "" {
|
||||
log.Printf("webrtc session %s rejected: empty file path", agent.ShortID(sess.SessionID))
|
||||
log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID))
|
||||
return
|
||||
}
|
||||
filePath = filepath.Clean(filePath)
|
||||
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
|
||||
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
|
||||
log.Printf("webrtc session %s rejected: path outside allowed dirs: %s",
|
||||
log.Printf("[hls %s] rejected: path outside allowed dirs: %s",
|
||||
agent.ShortID(sess.SessionID), filePath)
|
||||
return
|
||||
}
|
||||
|
|
@ -521,75 +512,36 @@ func runDaemonStart() error {
|
|||
if info, err := os.Stat(filePath); err == nil && info.IsDir() {
|
||||
found := engine.FindVideoFile(filePath)
|
||||
if found == "" {
|
||||
log.Printf("webrtc session %s rejected: no video file in dir %s",
|
||||
log.Printf("[hls %s] rejected: no video file in dir %s",
|
||||
agent.ShortID(sess.SessionID), filePath)
|
||||
return
|
||||
}
|
||||
filePath = found
|
||||
}
|
||||
|
||||
// Branch on transport: HLS sessions only need ffmpeg + StreamServer,
|
||||
// not a WebRTC peer, so they must bypass the WebRTC.Enabled gate.
|
||||
// Default ("" or "webrtc") runs the DataChannel pipeline and requires it.
|
||||
if strings.EqualFold(sess.Transport, "hls") {
|
||||
tcRuntime := buildTranscodeRuntime(ctx, cfg)
|
||||
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
|
||||
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
|
||||
return
|
||||
}
|
||||
hlsCtx, hlsCancel := context.WithCancel(ctx)
|
||||
webrtcRegistry.add(sess.SessionID, hlsCancel)
|
||||
hlsCfg := engine.HLSSessionConfig{
|
||||
SessionID: sess.SessionID,
|
||||
SourcePath: filePath,
|
||||
FileName: sess.FileName,
|
||||
Quality: sess.Quality,
|
||||
AudioIndex: sess.AudioIndex,
|
||||
Transcode: tcRuntime,
|
||||
}
|
||||
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
||||
if err != nil {
|
||||
webrtcRegistry.remove(sess.SessionID)
|
||||
hlsCancel()
|
||||
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
|
||||
return
|
||||
}
|
||||
streamSrv.HLS().Register(hsess)
|
||||
tcRuntime := buildTranscodeRuntime(ctx, cfg)
|
||||
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
|
||||
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
|
||||
return
|
||||
}
|
||||
|
||||
// Non-HLS transport requires WebRTC peer support.
|
||||
if !cfg.Download.WebRTC.Enabled {
|
||||
log.Printf("webrtc session %s rejected: webrtc disabled in config", agent.ShortID(sess.SessionID))
|
||||
hlsCtx, hlsCancel := context.WithCancel(ctx)
|
||||
playerSessionRegistry.add(sess.SessionID, hlsCancel)
|
||||
hlsCfg := engine.HLSSessionConfig{
|
||||
SessionID: sess.SessionID,
|
||||
SourcePath: filePath,
|
||||
FileName: sess.FileName,
|
||||
Quality: sess.Quality,
|
||||
AudioIndex: sess.AudioIndex,
|
||||
Transcode: tcRuntime,
|
||||
}
|
||||
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
||||
if err != nil {
|
||||
playerSessionRegistry.remove(sess.SessionID)
|
||||
hlsCancel()
|
||||
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
|
||||
return
|
||||
}
|
||||
|
||||
sessCtx, sessCancel := context.WithCancel(ctx) //nolint:gosec // G118 cancel stored in registry
|
||||
webrtcRegistry.add(sess.SessionID, sessCancel)
|
||||
go func() {
|
||||
defer func() {
|
||||
webrtcRegistry.remove(sess.SessionID)
|
||||
sessCancel()
|
||||
}()
|
||||
tcRuntime := buildTranscodeRuntime(ctx, cfg)
|
||||
runCfg := engine.WebRTCStreamConfig{
|
||||
SessionID: sess.SessionID,
|
||||
FilePath: filePath,
|
||||
FileName: sess.FileName,
|
||||
FileSize: sess.FileSize,
|
||||
Quality: sess.Quality,
|
||||
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 {
|
||||
if sessCtx.Err() == nil {
|
||||
log.Printf("[wrtc %s] ended: %v", agent.ShortID(sess.SessionID), err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
streamSrv.HLS().Register(hsess)
|
||||
}
|
||||
|
||||
// Periodic DHT node persistence (every 5 min)
|
||||
|
|
@ -658,7 +610,7 @@ func runDaemonStart() error {
|
|||
case sig := <-sigCh:
|
||||
fmt.Printf("\n Received %s, shutting down...\n", sig)
|
||||
cancelStreamContexts()
|
||||
cancelAllWebRTCSessions()
|
||||
cancelAllPlayerSessions()
|
||||
streamSrv.Shutdown(context.Background())
|
||||
cancel()
|
||||
|
||||
|
|
@ -673,7 +625,7 @@ func runDaemonStart() error {
|
|||
|
||||
case err := <-errCh:
|
||||
cancelStreamContexts()
|
||||
cancelAllWebRTCSessions()
|
||||
cancelAllPlayerSessions()
|
||||
streamSrv.Shutdown(context.Background())
|
||||
cancel()
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -114,9 +114,6 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
|
|||
StallTimeout: 10 * time.Minute,
|
||||
MaxTimeout: 0, // unlimited
|
||||
SeedEnabled: false,
|
||||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create downloader: %w", err)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
|
|
@ -10,66 +9,57 @@ import (
|
|||
"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{
|
||||
// playerSessionRegistry tracks per-session cancel funcs for active in-browser
|
||||
// HLS streaming sessions. Each session lives only as long as its ffmpeg
|
||||
// process; the registry exists so duplicate sync responses don't double-spawn
|
||||
// the same session and so daemon shutdown can drain.
|
||||
var playerSessionRegistry = &playerSessionRegistryT{
|
||||
cancels: make(map[string]context.CancelFunc),
|
||||
}
|
||||
|
||||
type webrtcSessionRegistry struct {
|
||||
type playerSessionRegistryT struct {
|
||||
mu sync.Mutex
|
||||
cancels map[string]context.CancelFunc
|
||||
}
|
||||
|
||||
func (r *webrtcSessionRegistry) has(sessionID string) bool {
|
||||
func (r *playerSessionRegistryT) 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) {
|
||||
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cancels[sessionID] = cancel
|
||||
}
|
||||
|
||||
func (r *webrtcSessionRegistry) remove(sessionID string) {
|
||||
func (r *playerSessionRegistryT) 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 {
|
||||
// cancelAllPlayerSessions cancels every running session. Called on daemon
|
||||
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
|
||||
func cancelAllPlayerSessions() {
|
||||
playerSessionRegistry.mu.Lock()
|
||||
cancels := make([]context.CancelFunc, 0, len(playerSessionRegistry.cancels))
|
||||
for _, c := range playerSessionRegistry.cancels {
|
||||
cancels = append(cancels, c)
|
||||
}
|
||||
webrtcRegistry.cancels = make(map[string]context.CancelFunc)
|
||||
webrtcRegistry.mu.Unlock()
|
||||
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
|
||||
playerSessionRegistry.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.
|
||||
// for the HLS streaming pipeline. Failure to resolve a binary returns a
|
||||
// runtime with empty paths so the caller can short-circuit instead of
|
||||
// launching a transcoder that will immediately fail.
|
||||
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
|
||||
if !cfg.Download.Transcode.Enabled {
|
||||
return engine.TranscodeRuntime{Disabled: true}
|
||||
|
|
@ -15,7 +15,7 @@ import (
|
|||
)
|
||||
|
||||
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
|
||||
// would actually use for HLS/WebRTC transcoding. The motivation: a beefy host
|
||||
// would actually use for HLS transcoding. The motivation: a beefy host
|
||||
// (e.g. RTX 3090) can still fall back to software encoding when the installed
|
||||
// ffmpeg binary was built without nvenc/qsv/vaapi support — Homebrew ffmpeg
|
||||
// is a common offender. Without this command, users see slow / failing 4K
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
"github.com/torrentclaw/unarr/internal/engine"
|
||||
)
|
||||
|
||||
// handleSeedFileTask wraps an arbitrary on-disk file as a single-file
|
||||
// torrent and adds it to the existing torrent client so the WebRTC
|
||||
// peer can serve pieces to a browser. Reports the generated info_hash
|
||||
// back to the server so the web player can target /stream/<hash>.
|
||||
//
|
||||
// Runs in its own goroutine; never blocks the claim batch.
|
||||
func handleSeedFileTask(t agent.Task, dl *engine.TorrentDownloader, client *agent.Client) {
|
||||
short := agent.ShortID(t.ID)
|
||||
|
||||
if t.FilePath == "" {
|
||||
log.Printf("[%s] seed_file: missing filePath, marking failed", short)
|
||||
reportSeedFileFailed(client, t.ID, "Missing filePath")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[%s] seed_file: building torrent from %s", short, t.FilePath)
|
||||
hash, err := engine.SeedFileOnDownloader(dl, t.FilePath)
|
||||
if err != nil {
|
||||
log.Printf("[%s] seed_file: %v", short, err)
|
||||
reportSeedFileFailed(client, t.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
infoHash := hash.HexString()
|
||||
log.Printf("[%s] seed_file: seeding ih=%s", short, infoHash)
|
||||
|
||||
// Push the info_hash + downloading status (file is on disk; from the
|
||||
// client's perspective it's already complete). The web side polls
|
||||
// /api/internal/stream/seed-file/<taskId> waiting for this update.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_, reportErr := client.ReportStatus(ctx, agent.StatusUpdate{
|
||||
TaskID: t.ID,
|
||||
Status: "downloading", // semantic: actively serving
|
||||
InfoHash: infoHash,
|
||||
FilePath: t.FilePath,
|
||||
})
|
||||
if reportErr != nil {
|
||||
log.Printf("[%s] seed_file: failed to push info_hash: %v", short, reportErr)
|
||||
}
|
||||
}
|
||||
|
||||
func reportSeedFileFailed(client *agent.Client, taskID, msg string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err := client.ReportStatus(ctx, agent.StatusUpdate{
|
||||
TaskID: taskID,
|
||||
Status: "failed",
|
||||
ErrorMessage: msg,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[%s] seed_file: report-failed itself failed: %v", agent.ShortID(taskID), err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package cmd
|
||||
|
||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||
var Version = "0.9.3"
|
||||
var Version = "0.9.4"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue