feat(stream)!: retire WebRTC, HLS-only, bump 0.9.4
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s

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:
Deivid Soto 2026-05-26 18:04:35 +02:00
parent 9176e877eb
commit ca7de23a56
33 changed files with 207 additions and 2854 deletions

View file

@ -3,9 +3,7 @@
// Browser ↔ daemon over plain HTTP (LAN / Tailscale / UPnP). The daemon runs
// ffmpeg in `-f hls` mode, writing fragmented MP4 segments to a per-session
// tmpdir. Master + media playlists are pre-rendered from the probed source
// duration so the player knows the full timeline before any segment exists,
// which fixes the seek/duration/pause/multi-track problems we hit with the
// raw fMP4-over-WebRTC pipeline.
// duration so the player knows the full timeline before any segment exists.
//
// One HLSSession == one browser playback. Sessions are registered in a
// process-wide map keyed by session ID; the StreamServer routes

View file

@ -9,7 +9,7 @@ import (
)
// StreamProbe summarises the codec / container shape of a file as it relates
// to the WebRTC streaming pipeline. It tells the transcoder whether bytes can
// to the HLS streaming pipeline. It tells the transcoder whether bytes can
// be streamed as-is, just remuxed to fragmented MP4, or fully transcoded.
type StreamProbe struct {
// VideoCodec lowercased — e.g. "h264", "hevc", "av1", "vp9", "mpeg4".

View file

@ -1,138 +0,0 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// SeedFile builds a single-file torrent from an arbitrary on-disk file
// and adds it to an existing torrent client so the WebRTC peer wire
// (already configured on the client) can serve the file to a browser
// that knows the resulting info-hash.
//
// Returns the generated info-hash. The torrent is left attached to the
// client — caller is responsible for keeping it alive while a browser
// is watching. Drop it via Client.RemoveTorrent / Torrent.Drop when
// idle to free resources.
//
// Behaviour notes:
// - The file must already exist; no download is attempted.
// - Piece length follows the libtorrent ladder (16 KiB → 4 MiB).
// - The torrent is "complete" from the agent's POV — it has every
// piece — so the upload-only flow kicks in immediately.
// - WebRTC peer behaviour comes from the client config the caller
// constructed; SeedFile does not toggle DisableWebtorrent itself.
// If the operator's [downloads.webrtc].enabled = false, the file
// is still added but no browser will discover it via WSS tracker.
func SeedFile(client *torrent.Client, filePath string, trackerURLs []string) (metainfo.Hash, error) {
if client == nil {
return metainfo.Hash{}, errors.New("seed_file: torrent client is nil")
}
if filePath == "" {
return metainfo.Hash{}, errors.New("seed_file: filePath is empty")
}
abs, err := filepath.Abs(filePath)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: resolve path: %w", err)
}
st, err := os.Stat(abs)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: stat: %w", err)
}
if st.IsDir() {
return metainfo.Hash{}, fmt.Errorf("seed_file: only single files are supported, %s is a directory", abs)
}
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: build info: %w", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: marshal info: %w", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: makeAnnounceList(trackerURLs),
CreatedBy: "unarr-seed-file",
CreationDate: time.Now().Unix(),
}
ih := mi.HashInfoBytes()
t, err := client.AddTorrent(mi)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: add torrent: %w", err)
}
// Mark every piece as needed so the client treats us as a complete
// seeder right away — anacrolix's verifier will hash the file
// asynchronously and flip pieces to "have" as it goes.
t.DownloadAll()
return ih, nil
}
// makeAnnounceList shapes the tracker URL slice into the bencoded
// AnnounceList format anacrolix expects.
func makeAnnounceList(urls []string) metainfo.AnnounceList {
if len(urls) == 0 {
return nil
}
tier := make([]string, 0, len(urls))
for _, u := range urls {
if u == "" {
continue
}
tier = append(tier, u)
}
if len(tier) == 0 {
return nil
}
return metainfo.AnnounceList{tier}
}
// chooseSeedPieceLength picks the piece size for a single-file torrent
// based on the libtorrent / qBittorrent ladder. Mirrored from the
// wstracker-probe seeder so generated torrents are interoperable.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024:
return 16 * 1024
case size < 64*1024*1024:
return 64 * 1024
case size < 512*1024*1024:
return 256 * 1024
case size < 4*1024*1024*1024:
return 1024 * 1024
default:
return 4 * 1024 * 1024
}
}
// SeedFileOnDownloader is a convenience wrapper that pulls the
// underlying anacrolix client out of a TorrentDownloader and forwards
// to SeedFile. trackerURLs default to the downloader's WebRTC
// trackers when nil/empty.
func SeedFileOnDownloader(d *TorrentDownloader, filePath string) (metainfo.Hash, error) {
if d == nil {
return metainfo.Hash{}, errors.New("seed_file: downloader is nil")
}
trackers := d.cfg.WebRTCTrackers
if !d.cfg.WebRTCEnabled {
// We could still build the torrent, but no browser would find
// it via the WSS tracker — bail loud so the operator notices.
return metainfo.Hash{}, errors.New("seed_file: WebRTC peer disabled in config; set [downloads.webrtc].enabled = true to use this feature")
}
return SeedFile(d.client, filePath, trackers)
}

View file

@ -1,164 +0,0 @@
package engine
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestSeedFile_RejectsMissingFile — explicit error rather than crashing
// inside anacrolix when the path doesn't exist.
func TestSeedFile_RejectsMissingFile(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if _, err := SeedFile(dl.client, "/nonexistent/path", nil); err == nil {
t.Fatal("expected error for missing file")
}
}
// TestSeedFile_RejectsDirectory — single-file torrents only for now.
func TestSeedFile_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
subDir := filepath.Join(dir, "sub")
if err := os.Mkdir(subDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := SeedFile(dl.client, subDir, nil); err == nil {
t.Fatal("expected error for directory path")
}
}
// TestSeedFile_BuildsDeterministicInfoHash — the same file should yield
// the same info_hash on every call so the web client can poll for it.
func TestSeedFile_BuildsDeterministicInfoHash(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.bin")
payload := []byte("hello world — torrentclaw seed_file test")
if err := os.WriteFile(file, payload, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mkClient := func() *TorrentDownloader {
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: t.TempDir(),
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
return dl
}
dl1 := mkClient()
defer dl1.Shutdown(context.Background())
hash1, err := SeedFile(dl1.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("first SeedFile: %v", err)
}
dl2 := mkClient()
defer dl2.Shutdown(context.Background())
hash2, err := SeedFile(dl2.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("second SeedFile: %v", err)
}
if hash1 != hash2 {
t.Fatalf("info_hash not deterministic: %s vs %s", hash1.HexString(), hash2.HexString())
}
if hash1.HexString() == "" || len(hash1.HexString()) != 40 {
t.Fatalf("info_hash is not 40 hex chars: %q", hash1.HexString())
}
}
// TestSeedFileOnDownloader_RequiresWebRTC — silent failure mode is the
// worst UX; bail loud when the operator hasn't opted into WebRTC.
func TestSeedFileOnDownloader_RequiresWebRTC(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: false,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
file := filepath.Join(dir, "data.bin")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if _, err := SeedFileOnDownloader(dl, file); err == nil {
t.Fatal("expected error when WebRTC disabled")
}
}
// TestChooseSeedPieceLength_LadderShape — sanity-check the breakpoints
// stay aligned with the libtorrent reference (16 KiB → 4 MiB).
func TestChooseSeedPieceLength_LadderShape(t *testing.T) {
cases := []struct {
size int64
expect int64
}{
{1, 16 * 1024},
{4 * 1024 * 1024, 64 * 1024},
{64 * 1024 * 1024, 256 * 1024},
{512 * 1024 * 1024, 1024 * 1024},
{4 * 1024 * 1024 * 1024, 4 * 1024 * 1024},
}
for _, c := range cases {
if got := chooseSeedPieceLength(c.size); got != c.expect {
t.Errorf("chooseSeedPieceLength(%d) = %d want %d", c.size, got, c.expect)
}
}
}
// TestMakeAnnounceList_HandlesEmpty — nil/empty in → nil out, so
// AddTorrent doesn't see a dangling tier with no URLs.
func TestMakeAnnounceList_HandlesEmpty(t *testing.T) {
if got := makeAnnounceList(nil); got != nil {
t.Errorf("nil input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{}); got != nil {
t.Errorf("empty input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{"", " ", ""}); got != nil {
// Empty strings should be filtered; if everything is empty,
// nil is the right answer.
// (We do NOT trim whitespace today — only literal "".)
if len(got) != 1 || len(got[0]) != 1 {
t.Errorf("expected 1 single-element tier, got %+v", got)
}
}
got := makeAnnounceList([]string{"wss://a", "", "wss://b"})
if len(got) != 1 || len(got[0]) != 2 {
t.Fatalf("expected 1 tier of 2 URLs, got %+v", got)
}
}

View file

@ -12,7 +12,7 @@ import (
"time"
)
// streamSource abstracts the byte source served over the WebRTC DataChannel.
// streamSource abstracts the byte source consumed by the HLS transcoder.
// Two implementations:
// - diskFileSource — direct passthrough of the on-disk file.
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in

View file

@ -16,7 +16,6 @@ import (
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term"
@ -73,14 +72,6 @@ type TorrentConfig struct {
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0)
// WebRTC peer (WebTorrent protocol) for browser ↔ unarr P2P streaming.
// When enabled, anacrolix/torrent's built-in webtorrent package handles
// the WSS signaling + WebRTC data channels. Implies upload allowed for
// every torrent in the client (browsers can't pull pieces otherwise).
WebRTCEnabled bool
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
// add-on). nil = downloads in the clear. Brought up by the daemon.
@ -111,26 +102,11 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
// WebRTC peers (browsers) can only pull pieces from us if upload is
// enabled. We honour SeedEnabled for the long-tail seed-after-complete
// behaviour but unconditionally allow upload while WebRTC is on so an
// active download can still serve to a watching browser.
tcfg.NoUpload = !cfg.SeedEnabled && !cfg.WebRTCEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning) // bumped from Critical for WebRTC peer + tracker announce visibility
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
// WebRTC / WebTorrent peer: anacrolix auto-routes ws://+wss:// trackers
// to the bundled webtorrent.TrackerClient. We only need to populate the
// ICE server list so the SDP offers we send carry usable candidates.
if cfg.WebRTCEnabled {
tcfg.DisableWebtorrent = false
if len(cfg.ICEServers) > 0 {
tcfg.ICEServerList = cfg.ICEServers
}
log.Printf("[torrent] WebRTC peer enabled (trackers=%d ice_servers=%d)",
len(cfg.WebRTCTrackers), len(cfg.ICEServers))
} else {
tcfg.DisableWebtorrent = true
}
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true
// --- Performance optimizations ---
@ -657,30 +633,17 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
return totalBytes, fileName
}
// buildMagnet composes a magnet URI for the info hash. extraTrackers (e.g.
// wss://… for WebRTC peer signaling) are prepended so anacrolix's
// webtorrent.TrackerClient picks them up first; the static UDP list
// follows. Empty / whitespace entries in extraTrackers are skipped.
func buildMagnet(infoHash string, extraTrackers ...string) string {
// buildMagnet composes a magnet URI for the info hash with the static
// tracker list.
func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash}
for _, t := range extraTrackers {
t = strings.TrimSpace(t)
if t == "" {
continue
}
params = append(params, "tr="+url.QueryEscape(t))
}
for _, tracker := range defaultTrackers {
params = append(params, "tr="+url.QueryEscape(tracker))
}
return "magnet:?" + strings.Join(params, "&")
}
// buildMagnet on the downloader injects its WebRTC trackers when enabled.
func (d *TorrentDownloader) buildMagnet(infoHash string) string {
if d != nil && d.cfg.WebRTCEnabled {
return buildMagnet(infoHash, d.cfg.WebRTCTrackers...)
}
return buildMagnet(infoHash)
}

View file

@ -0,0 +1,64 @@
package engine
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and
// makes libx264 refuse with "VBV bitrate > level limit". This helper picks
// the bitrate that matches the level libx264 will actually accept.
func capForHeight(height int) qualityCap {
switch {
case height <= 0:
return qualityCap{}
case height <= 480:
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
case height <= 720:
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case height <= 1080:
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case height <= 1440:
return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"}
default:
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
}
}

View file

@ -11,10 +11,9 @@ import (
"time"
)
// TranscodeOpts steers how Transcoder builds its ffmpeg command line. Defaults
// match the project's plan/clever-weaving-dove.md (Fase 2.5):
// TranscodeOpts steers how Transcoder builds its ffmpeg command line.
//
// - Output: fragmented MP4 readable by browser <video> via MSE-less Range.
// - Output: fragmented MP4 chunked into HLS segments by the muxer.
// - Audio: AAC stereo @ 192kbps unless source already AAC (then -c:a copy).
// - Video: copy when h264 8-bit; otherwise transcode to h264 with HW encode
// when available, software fallback at "veryfast" preset.
@ -31,11 +30,11 @@ type TranscodeOpts struct {
}
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
// fragmented MP4 bytes for the WebRTC pump to forward to the browser.
// fragmented MP4 bytes; the HLS muxer slices them into segments served over HTTP.
//
// One Transcoder == one playback position. A seek beyond the buffered window
// requires Close()ing this transcoder and starting a new one with a higher
// StartSeconds (handled in webrtc_stream.go).
// StartSeconds (handled by the HLS session at ffmpeg start time).
//
// A single internal goroutine owns cmd.Wait() — never call cmd.Wait()
// directly from outside (os/exec forbids concurrent Wait callers). Use
@ -269,12 +268,9 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
filterChain = "format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
// Force AAC-LC stereo 48 kHz so MSE's CHUNK_DEMUXER accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape that MSE refuses to
// parse (the <video src=blob:> demuxer is more forgiving), so we
// always downmix to stereo and resample to 48 kHz here. Source
// material that's already stereo passes through losslessly aside
// from the re-encode.
// Force AAC-LC stereo 48 kHz so the hls.js demuxer accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape the demuxer refuses
// to parse, so always downmix to stereo + resample to 48 kHz here.
args = append(args,
"-c:a", "aac",
"-b:a", coalesce(opts.AudioBitrate, "192k"),
@ -285,13 +281,12 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
// Common output flags — fragmented MP4 to a single pipe.
//
// * empty_moov + default_base_moof: write a header-only init segment
// up front so MSE can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media. Without
// this, ffmpeg only splits at keyframes, which on a high-bitrate
// 1080p stream produces 8 MiB+ mdat boxes — MSE refuses to parse
// the first fragment until the whole mdat lands, so playback never
// starts.
// * empty_moov + default_base_moof: header-only init segment up front
// so the demuxer can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media.
// Without it ffmpeg only splits at keyframes; a high-bitrate 1080p
// stream produces 8 MiB+ mdat boxes that delay the first fragment
// until the whole mdat lands and playback never starts.
// * negative_cts_offsets: lets b-frames carry the right pts/dts so
// decoders don't reset the playhead to 0 every fragment.
args = append(args,

View file

@ -1,36 +0,0 @@
package engine
import (
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
// BuildICEServers converts a config.WebRTCConfig into the
// []webrtc.ICEServer slice that anacrolix/torrent's webtorrent client
// needs. STUN entries become bare URLs; TURN entries inherit the shared
// TURNUser / TURNPass credentials. Returns nil when WebRTC is disabled.
func BuildICEServers(cfg config.WebRTCConfig) []webrtc.ICEServer {
if !cfg.Enabled {
return nil
}
var servers []webrtc.ICEServer
for _, s := range cfg.STUNServers {
if s == "" {
continue
}
servers = append(servers, webrtc.ICEServer{URLs: []string{s}})
}
for _, t := range cfg.TURNServers {
if t == "" {
continue
}
entry := webrtc.ICEServer{URLs: []string{t}}
if cfg.TURNUser != "" {
entry.Username = cfg.TURNUser
entry.Credential = cfg.TURNPass
entry.CredentialType = webrtc.ICECredentialTypePassword
}
servers = append(servers, entry)
}
return servers
}

View file

@ -1,807 +0,0 @@
// Package engine — webrtc_stream.go implements the daemon side of the custom
// WebRTC byte-streaming protocol. The browser opens an RTCDataChannel via
// SDP exchange (signalled over the web's HTTP + SSE relay); this code:
//
// 1. Parses the browser's SDP offer.
// 2. Creates a pion PeerConnection bound to the configured ICE servers.
// 3. Answers + trickles its own ICE candidates back through the signal client.
// 4. On DataChannel open, sends a HELLO frame describing the file.
// 5. Services RangeReq frames by reading from disk and emitting RangeData
// chunks (16 KiB each) followed by a RangeEnd.
// 6. Honours app-level backpressure via SetBufferedAmountLowThreshold +
// OnBufferedAmountLow — Chromium closes a DataChannel when bufferedAmount
// exceeds 16 MiB, so we MUST pause the writer.
//
// No anacrolix, no torrent metadata. Just a peer-to-peer file server over
// WebRTC. Pass-through path; transcoding lives in transcoder.go (Fase 2.5).
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine/wire"
)
// Tunables — values match the protocol spec in plan/clever-weaving-dove.md.
const (
// dcChunkPayload is the per-frame application payload size. Must match
// wire.MaxChunkPayload so RangeData frames fit one SCTP message.
dcChunkPayload = wire.MaxChunkPayload
// dcHighWatermark is the bufferedAmount cap above which the writer pauses.
// Chromium closes DCs above 16 MiB; pause well below.
dcHighWatermark = 8 << 20
// dcLowWatermark triggers OnBufferedAmountLow → resume the writer.
dcLowWatermark = 1 << 20
// rangeReqConcurrency is the cap on in-flight range responses per session.
rangeReqConcurrency = 4
// helloDeadline is the max wait for the DataChannel to open after answer.
helloDeadline = 30 * time.Second
)
// WebRTCStreamConfig describes a single browser ↔ daemon stream session.
type WebRTCStreamConfig struct {
SessionID string
FilePath string
FileName string
FileSize int64
ICEServers []webrtc.ICEServer
Signal *agent.Client
// Logger receives diagnostic events; a nil logger swallows everything.
Logger StreamLogger
// Transcode steers on-the-fly transcoding when source codecs are not
// browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it.
Transcode TranscodeRuntime
// Quality overrides the cap from Transcode for this session. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (= defer to
// Transcode defaults).
Quality string
}
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// StreamLogger is an injectable logger so tests can capture events.
type StreamLogger interface {
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
type nopLogger struct{}
func (nopLogger) Infof(string, ...any) {}
func (nopLogger) Warnf(string, ...any) {}
func (nopLogger) Errorf(string, ...any) {}
func logger(l StreamLogger) StreamLogger {
if l == nil {
return nopLogger{}
}
return l
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and
// makes libx264 refuse with "VBV bitrate > level limit". This helper picks
// the bitrate that matches the level libx264 will actually accept.
func capForHeight(height int) qualityCap {
switch {
case height <= 0:
return qualityCap{}
case height <= 480:
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
case height <= 720:
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case height <= 1080:
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case height <= 1440:
return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"}
default:
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
}
}
// buildStreamSource picks between passthrough and transcoded source. ffprobe
// failure or missing ffmpeg falls back to passthrough — the browser surfaces
// a clearer codec error than us refusing to start.
//
// Quality override (cfg.Quality) can force a downscale even when the source
// codec is browser-friendly: a 4K h264 file watched on a phone with quality
// "720p" must transcode (otherwise we'd ship 4K bytes for a 6" screen).
func buildStreamSource(
ctx context.Context,
abs string,
displayName string,
cfg WebRTCStreamConfig,
log StreamLogger,
) (streamSource, error) {
tc := cfg.Transcode
qcap := resolveQualityCap(cfg.Quality)
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
return newDiskFileSource(abs)
}
probe, err := ProbeFile(ctx, tc.FFprobePath, abs)
if err != nil {
log.Warnf("[wrtc %s] probe failed (%v) — passthrough", agent.ShortID(cfg.SessionID), err)
return newDiskFileSource(abs)
}
action := DecideAction(probe)
// Quality cap can promote a passthrough/remux decision into a full video
// transcode when the source resolution exceeds the requested cap.
if qcap.MaxHeight > 0 && probe.Height > 0 && probe.Height > qcap.MaxHeight && action != ActionTranscodeVideo {
log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode",
agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, qcap.MaxHeight)
action = ActionTranscodeVideo
}
if action == ActionPassthrough {
log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)",
agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container)
return newDiskFileSource(abs)
}
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec,
action, coalesce(cfg.Quality, "default"))
maxHeight := tc.MaxHeight
videoBitrate := tc.VideoBitrate
if qcap.MaxHeight > 0 {
maxHeight = qcap.MaxHeight
videoBitrate = qcap.VideoBitrate
}
opts := TranscodeOpts{
Action: action,
HWAccel: tc.HWAccel,
Preset: tc.Preset,
VideoBitrate: videoBitrate,
AudioBitrate: tc.AudioBitrate,
MaxHeight: maxHeight,
SourceHeight: probe.Height,
FFmpegPath: tc.FFmpegPath,
}
return newTranscodeSource(ctx, abs, probe, action, opts, displayName)
}
// RunWebRTCStream blocks until the session ends — either the DataChannel
// closes, the peer connection drops, or ctx is cancelled. Always returns a
// non-nil error explaining the termination reason.
func RunWebRTCStream(ctx context.Context, cfg WebRTCStreamConfig) error {
log := logger(cfg.Logger)
if cfg.SessionID == "" {
return errors.New("webrtc_stream: empty SessionID")
}
if cfg.FilePath == "" {
return errors.New("webrtc_stream: empty FilePath")
}
abs, err := filepath.Abs(cfg.FilePath)
if err != nil {
return fmt.Errorf("webrtc_stream: resolve path: %w", err)
}
displayName := cfg.FileName
if displayName == "" {
displayName = filepath.Base(abs)
}
// Decide passthrough vs transcoding. Probe is best-effort: if ffprobe
// is missing or fails we fall back to passthrough (the browser will
// surface a clearer error than us guessing wrong).
source, err := buildStreamSource(ctx, abs, displayName, cfg, log)
if err != nil {
return fmt.Errorf("webrtc_stream: build source: %w", err)
}
defer source.Close()
// 1. Build PeerConnection.
api := webrtc.NewAPI()
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: cfg.ICEServers,
})
if err != nil {
return fmt.Errorf("webrtc_stream: new peer connection: %w", err)
}
defer pc.Close()
sessionCtx, cancelSession := context.WithCancel(ctx)
defer cancelSession()
// Stop the session when ICE drops permanently. "Disconnected" is
// transient per RFC 8445 (NAT rebind, brief packet loss) — wait for
// "Failed" or "Closed" before tearing down.
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Infof("[wrtc %s] ice=%s", agent.ShortID(cfg.SessionID), state.String())
switch state {
case webrtc.ICEConnectionStateFailed,
webrtc.ICEConnectionStateClosed:
cancelSession()
case webrtc.ICEConnectionStateUnknown,
webrtc.ICEConnectionStateNew,
webrtc.ICEConnectionStateChecking,
webrtc.ICEConnectionStateConnected,
webrtc.ICEConnectionStateCompleted,
webrtc.ICEConnectionStateDisconnected:
// Disconnected is transient (RFC 8445 — NAT rebind / packet loss);
// the others are normal progress states. Don't tear the session down.
}
})
// Trickle our ICE candidates back to the browser.
// PostSignal runs on its own goroutine so a slow signal server can't
// stall pion's ICE-gathering thread.
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidateEnd,
Payload: "",
})
}()
return
}
init := c.ToJSON()
payload, _ := json.Marshal(init)
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidate,
Payload: string(payload),
})
}()
})
// Browser is the offerer — we react to the DataChannel it creates.
dcReady := make(chan *webrtc.DataChannel, 1)
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
log.Infof("[wrtc %s] data channel '%s' open", agent.ShortID(cfg.SessionID), dc.Label())
select {
case dcReady <- dc:
default:
// Browser opened a second DC — ignore, we only serve one.
log.Warnf("[wrtc %s] extra data channel ignored", agent.ShortID(cfg.SessionID))
}
})
// 2. Drive the SDP exchange. Any error from the loop (browser sent
// "bye", signal stream closed, etc.) cancels the session so we don't
// dangle on the DC waiting for a peer that's already gone.
sdpDone := make(chan error, 1)
go func() {
err := runSDPExchange(sessionCtx, pc, cfg)
sdpDone <- err
if err != nil && sessionCtx.Err() == nil {
log.Infof("[wrtc %s] signal loop ended: %v", agent.ShortID(cfg.SessionID), err)
cancelSession()
}
}()
// 3. Wait for either SDP error or DataChannel open.
var dc *webrtc.DataChannel
select {
case err := <-sdpDone:
if err != nil {
return fmt.Errorf("sdp exchange: %w", err)
}
// SDP complete — wait for the DC.
select {
case dc = <-dcReady:
case <-time.After(helloDeadline):
return errors.New("webrtc_stream: data channel never opened")
case <-sessionCtx.Done():
return sessionCtx.Err()
}
case dc = <-dcReady:
// DC opened before SDP loop reported done (typical: the loop keeps
// running to ferry remote ICE candidates).
case <-sessionCtx.Done():
return sessionCtx.Err()
}
// 4. Wire up the data channel pump.
pump := newDataChannelPump(dc, source, log, cancelSession)
dc.OnOpen(pump.onOpen)
dc.OnMessage(pump.onMessage)
dc.OnClose(func() {
log.Infof("[wrtc %s] data channel closed", agent.ShortID(cfg.SessionID))
cancelSession()
})
<-sessionCtx.Done()
pump.shutdown()
return sessionCtx.Err()
}
// runSDPExchange consumes signal events from the browser and answers the SDP
// offer. Keeps running for the lifetime of sessionCtx so trickle candidates
// flow in both directions. Reopens the SSE stream on every clean close — the
// server caps each response at ~25 s.
func runSDPExchange(ctx context.Context, pc *webrtc.PeerConnection, cfg WebRTCStreamConfig) error {
gotOffer := false
for ctx.Err() == nil {
stream, err := cfg.Signal.OpenSignalStream(ctx, cfg.SessionID)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("open signal stream: %w", err)
}
err = consumeSignalStream(ctx, pc, cfg, stream, &gotOffer)
stream.Close()
if err != nil {
return err
}
}
return ctx.Err()
}
// consumeSignalStream drains a single SSE connection until it closes or
// produces a hard error. Returns nil on a clean server-side disconnect so the
// caller can reopen.
func consumeSignalStream(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
stream *agent.SignalEventStream,
gotOffer *bool,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-stream.Events():
if !ok {
if err := stream.Err(); err != nil {
return fmt.Errorf("signal stream: %w", err)
}
return nil
}
if err := handleSignal(ctx, pc, cfg, msg, gotOffer); err != nil {
return err
}
}
}
}
func handleSignal(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
msg agent.SignalMessage,
gotOffer *bool,
) error {
switch msg.Type {
case agent.SignalMsgAnswer:
// Browser is the offerer in our protocol — we never expect an answer
// from the other side. Drop silently (also satisfies exhaustive lint).
return nil
case agent.SignalMsgOffer:
if *gotOffer {
return nil // ignore duplicates
}
var offer webrtc.SessionDescription
if err := json.Unmarshal([]byte(msg.Payload), &offer); err != nil {
return fmt.Errorf("decode offer: %w", err)
}
if err := pc.SetRemoteDescription(offer); err != nil {
return fmt.Errorf("set remote description: %w", err)
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("create answer: %w", err)
}
if err := pc.SetLocalDescription(answer); err != nil {
return fmt.Errorf("set local description: %w", err)
}
// Send back the local description *with* gathered candidates so far —
// remaining candidates trickle separately via OnICECandidate.
ld := pc.LocalDescription()
payload, _ := json.Marshal(ld)
if err := cfg.Signal.PostSignal(ctx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgAnswer,
Payload: string(payload),
}); err != nil {
return fmt.Errorf("post answer: %w", err)
}
*gotOffer = true
case agent.SignalMsgCandidate:
if !*gotOffer {
// Browser may trickle candidates before we've seen the offer in
// rare race conditions — drop. Browser will retransmit.
return nil
}
var init webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(msg.Payload), &init); err != nil {
return fmt.Errorf("decode candidate: %w", err)
}
if err := pc.AddICECandidate(init); err != nil {
return fmt.Errorf("add ice candidate: %w", err)
}
case agent.SignalMsgCandidateEnd:
// No-op — pion gathers complete on its own.
case agent.SignalMsgBye:
return errors.New("browser sent bye")
}
return nil
}
// dataChannelPump owns the DC + stream source and serves wire-protocol frames.
type dataChannelPump struct {
dc *webrtc.DataChannel
source streamSource
log StreamLogger
cancel context.CancelFunc
// Flow control: writers wait on resumeCh when bufferedAmount goes high.
paused atomic.Bool
resumeCh chan struct{}
// Active range responses keyed by stream_id so CANCEL frames can stop them.
activeMu sync.Mutex
active map[uint32]context.CancelFunc
// Bound concurrent in-flight responses.
sem chan struct{}
// closed once shutdown() has been called.
closed atomic.Bool
}
func newDataChannelPump(
dc *webrtc.DataChannel,
source streamSource,
log StreamLogger,
cancel context.CancelFunc,
) *dataChannelPump {
p := &dataChannelPump{
dc: dc,
source: source,
log: log,
cancel: cancel,
resumeCh: make(chan struct{}, 1),
active: make(map[uint32]context.CancelFunc),
sem: make(chan struct{}, rangeReqConcurrency),
}
dc.SetBufferedAmountLowThreshold(dcLowWatermark)
dc.OnBufferedAmountLow(p.onBufferedAmountLow)
return p
}
func (p *dataChannelPump) onOpen() {
// Use estimated size for transcoded streams so the browser scrubber has
// something to anchor on. Real size is reflected by Range responses as
// ffmpeg writes more bytes; the estimate just bootstraps the UI.
announceSize := p.source.EstimatedSize()
transcoding := p.source.Transcoded()
// Browsers refuse to start playback when Content-Length is 0. If we don't
// have a duration estimate (e.g. ffprobe couldn't tag the source), declare
// a large sentinel so the browser issues range requests; the Transcoding
// flag tells it the value is provisional.
if transcoding && announceSize <= 0 {
announceSize = math.MaxInt64
}
// Seekable=true even for transcoded sources because we read from a tmp
// file (random access). Seek backwards just works; seek forward beyond
// what ffmpeg has produced will block briefly inside ReadAt.
seekable := true
hello := wire.HelloPayload{
FileSize: uint64(announceSize),
Transcoding: transcoding,
Seekable: seekable,
FileName: p.source.FileName(),
}
payload := wire.EncodeHello(hello)
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameHello,
Flags: wire.HelloFlags(transcoding, seekable),
StreamID: 0,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Errorf("send hello: %v", err)
p.cancel()
}
}
func (p *dataChannelPump) onMessage(msg webrtc.DataChannelMessage) {
if len(msg.Data) < wire.HeaderSize {
p.log.Warnf("dc: short frame %d bytes", len(msg.Data))
return
}
hdr, err := wire.DecodeHeader(msg.Data[:wire.HeaderSize])
if err != nil {
p.log.Warnf("dc: bad header: %v", err)
return
}
payload := msg.Data[wire.HeaderSize:]
if uint32(len(payload)) != hdr.Length {
p.log.Warnf("dc: payload length mismatch: hdr=%d got=%d", hdr.Length, len(payload))
return
}
switch hdr.Type {
case wire.FrameRangeReq:
req, err := wire.DecodeRangeReq(payload)
if err != nil {
p.log.Warnf("dc: bad range_req: %v", err)
return
}
go p.serveRange(hdr.StreamID, req)
case wire.FrameCancel:
p.cancelStream(hdr.StreamID)
case wire.FramePing:
p.sendSimpleFrame(wire.FramePong, hdr.StreamID, nil)
case wire.FramePong:
// no-op
default:
p.log.Warnf("dc: unknown frame type 0x%02x", hdr.Type)
}
}
func (p *dataChannelPump) cancelStream(streamID uint32) {
p.activeMu.Lock()
cancel, ok := p.active[streamID]
delete(p.active, streamID)
p.activeMu.Unlock()
if ok {
cancel()
}
}
func (p *dataChannelPump) sendSimpleFrame(t wire.FrameType, streamID uint32, payload []byte) {
frame := wire.EncodeFrame(wire.Header{
Type: t,
StreamID: streamID,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Warnf("dc: send type=0x%02x: %v", t, err)
}
}
func (p *dataChannelPump) serveRange(streamID uint32, req wire.RangeReqPayload) {
if p.closed.Load() {
return
}
// Bound concurrency.
select {
case p.sem <- struct{}{}:
case <-time.After(5 * time.Second):
p.log.Warnf("dc: range_req sid=%d dropped (concurrency cap)", streamID)
p.sendRangeEnd(streamID, 1)
return
}
defer func() { <-p.sem }()
// Reject offsets above MaxInt64 — uint64→int64 narrowing would wrap to a
// negative value and bypass the bounds check, then ReadAt would be called
// with a negative offset.
currentSize := p.source.Size()
finalSize := p.source.EstimatedSize()
if req.Offset > math.MaxInt64 {
p.sendRangeEnd(streamID, 2) // out of range
return
}
// For transcoded streams `currentSize` grows over time; only reject when
// the offset is past the *estimated* final size.
if int64(req.Offset) >= finalSize && p.source.Final() {
p.sendRangeEnd(streamID, 2)
return
}
want := int64(req.Length)
if req.Length > math.MaxInt64 {
want = 0 // treat absurd length as "remainder of file"
}
// Cap by *final* size, not currentSize. For a still-transcoding stream
// currentSize grows over time and ReadAt below already blocks until
// ffmpeg produces the requested bytes (with a deadline). If we cap
// `want` by currentSize here we'll send an empty RangeEnd whenever the
// browser asks for bytes faster than ffmpeg writes them — which is
// always true on the first few seconds — and the browser then aborts
// playback with "Format error".
cap := finalSize
if !p.source.Final() && cap < int64(req.Offset)+1 {
// Estimate too small: serve as much as the browser asked for and
// let ReadAt block.
cap = int64(req.Offset) + want
}
if int64(req.Offset) >= cap && p.source.Final() {
// Past true end of a finished file.
p.sendRangeEnd(streamID, 0)
return
}
remaining := cap - int64(req.Offset)
if remaining < 0 {
remaining = 0
}
if want <= 0 || want > remaining {
want = remaining
}
p.log.Infof("dc: range_req sid=%d offset=%d wantReq=%d wantServe=%d currentSize=%d final=%v",
streamID, req.Offset, req.Length, want, currentSize, p.source.Final())
if want <= 0 {
// Only happens for a finished file when offset is at/past EOF.
p.sendRangeEnd(streamID, 0)
return
}
ctx, cancel := context.WithCancel(context.Background())
p.activeMu.Lock()
if p.active == nil {
p.activeMu.Unlock()
cancel()
p.sendRangeEnd(streamID, 3)
return
}
p.active[streamID] = cancel
p.activeMu.Unlock()
defer func() {
p.activeMu.Lock()
delete(p.active, streamID)
p.activeMu.Unlock()
cancel()
}()
buf := make([]byte, dcChunkPayload)
offset := int64(req.Offset)
end := offset + want
for offset < end {
if ctx.Err() != nil || p.closed.Load() {
return
}
// Wait if the DC is buffering too much.
if err := p.waitForLowWater(ctx); err != nil {
return
}
chunkLen := int64(len(buf))
if end-offset < chunkLen {
chunkLen = end - offset
}
n, rerr := p.source.ReadAt(buf[:chunkLen], offset)
if n > 0 {
// EOF on a short read means this is the final chunk — flag it so the
// browser doesn't wait for more data before processing RangeEnd.
isLast := offset+int64(n) >= end || rerr == io.EOF
if err := p.sendRangeData(streamID, buf[:n], isLast); err != nil {
p.log.Warnf("dc: send range_data sid=%d: %v", streamID, err)
return
}
offset += int64(n)
}
if rerr != nil {
if rerr == io.EOF {
break
}
p.log.Errorf("dc: read sid=%d: %v", streamID, rerr)
p.sendRangeEnd(streamID, 3)
return
}
}
p.sendRangeEnd(streamID, 0)
}
func (p *dataChannelPump) sendRangeData(streamID uint32, data []byte, last bool) error {
var flags uint8
if last {
flags |= wire.FlagLastChunk
}
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameRangeData,
Flags: flags,
StreamID: streamID,
Length: uint32(len(data)),
}, data)
return p.dc.Send(frame)
}
func (p *dataChannelPump) sendRangeEnd(streamID uint32, status uint32) {
payload := wire.EncodeRangeEnd(wire.RangeEndPayload{Status: status})
p.sendSimpleFrame(wire.FrameRangeEnd, streamID, payload)
}
func (p *dataChannelPump) waitForLowWater(ctx context.Context) error {
if p.dc.BufferedAmount() < dcHighWatermark {
return nil
}
p.paused.Store(true)
for {
// Drain any stale resume signal first.
select {
case <-p.resumeCh:
default:
}
if p.dc.BufferedAmount() < dcHighWatermark {
p.paused.Store(false)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-p.resumeCh:
case <-time.After(500 * time.Millisecond):
// Belt-and-braces poll in case OnBufferedAmountLow misses a fire.
}
}
}
func (p *dataChannelPump) onBufferedAmountLow() {
if !p.paused.Load() {
return
}
select {
case p.resumeCh <- struct{}{}:
default:
}
}
func (p *dataChannelPump) shutdown() {
if !p.closed.CompareAndSwap(false, true) {
return
}
p.activeMu.Lock()
for _, cancel := range p.active {
cancel()
}
p.active = nil
p.activeMu.Unlock()
}

View file

@ -1,177 +0,0 @@
package engine
import (
"context"
"net/url"
"strings"
"testing"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
const validHash = "aaf2c71b0e0a03d3f9b2a3e1d5c6b7a8f0e1d2c3"
// TestBuildMagnet_NoExtras verifies the legacy free-function path keeps
// emitting only the static defaultTrackers list.
func TestBuildMagnet_NoExtras(t *testing.T) {
got := buildMagnet(validHash)
if !strings.HasPrefix(got, "magnet:?xt=urn:btih:"+validHash) {
t.Fatalf("magnet missing xt: %s", got)
}
if !strings.Contains(got, url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")) {
t.Fatal("expected default UDP tracker absent")
}
if strings.Contains(got, "wss%3A") {
t.Fatalf("unexpected WSS tracker leaked when none requested: %s", got)
}
}
// TestBuildMagnet_WithExtraTrackers verifies extraTrackers (e.g. WebRTC
// WSS endpoints) are prepended before the defaults and properly URL-encoded.
func TestBuildMagnet_WithExtraTrackers(t *testing.T) {
got := buildMagnet(validHash, "wss://tracker.torrentclaw.com")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
encUDP := url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("WSS tracker missing: %s", got)
}
wssIdx := strings.Index(got, encWss)
udpIdx := strings.Index(got, encUDP)
if wssIdx < 0 || udpIdx < 0 || wssIdx > udpIdx {
t.Fatalf("WSS tracker should appear BEFORE UDP defaults: wss=%d udp=%d", wssIdx, udpIdx)
}
}
// TestBuildMagnet_TrimsAndSkipsEmpty makes sure callers passing config-derived
// slices with stray whitespace or empty strings don't get malformed magnets.
func TestBuildMagnet_TrimsAndSkipsEmpty(t *testing.T) {
got := buildMagnet(validHash, " wss://tracker.torrentclaw.com ", "", " ")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("trimmed WSS tracker missing: %s", got)
}
if strings.Contains(got, "tr=&") || strings.HasSuffix(got, "tr=") {
t.Fatalf("empty tracker emitted: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCDisabled confirms the downloader
// method does NOT inject WebRTCTrackers when WebRTCEnabled is false.
func TestTorrentDownloader_buildMagnet_WebRTCDisabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: false,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
}}
got := d.buildMagnet(validHash)
if strings.Contains(got, "wss%3A") {
t.Fatalf("WSS tracker leaked while WebRTCEnabled=false: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCEnabled confirms the WSS trackers
// are present when WebRTCEnabled is true.
func TestTorrentDownloader_buildMagnet_WebRTCEnabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com", "wss://tracker2.example.com"},
}}
got := d.buildMagnet(validHash)
for _, want := range []string{
"wss://tracker.torrentclaw.com",
"wss://tracker2.example.com",
} {
if !strings.Contains(got, url.QueryEscape(want)) {
t.Fatalf("expected tracker %q missing in magnet: %s", want, got)
}
}
}
// TestBuildICEServers_DisabledReturnsNil ensures we don't leak STUN/TURN
// configuration into the torrent client when the user has WebRTC off.
func TestBuildICEServers_DisabledReturnsNil(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: false,
STUNServers: []string{"stun:stun.l.google.com:19302"},
})
if got != nil {
t.Fatalf("expected nil ICE servers when disabled, got %+v", got)
}
}
// TestBuildICEServers_STUNOnly converts STUN entries to bare ICEServer
// records with no credentials.
func TestBuildICEServers_STUNOnly(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302", "", "stun:stun1.l.google.com:19302"},
})
if len(got) != 2 {
t.Fatalf("expected 2 STUN servers (empty skipped), got %d (%+v)", len(got), got)
}
if got[0].URLs[0] != "stun:stun.l.google.com:19302" {
t.Fatalf("first server unexpected: %+v", got[0])
}
if got[0].Username != "" || got[0].Credential != nil {
t.Fatalf("STUN entry should have no credentials, got %+v", got[0])
}
}
// TestNewTorrentDownloader_WebRTCEnabled creates a downloader with the
// WebRTC peer fully wired up and confirms the constructor doesn't error
// (anacrolix accepts the ICE server list, port binds, etc.).
func TestNewTorrentDownloader_WebRTCEnabled(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0, // let the OS pick — avoid clashes in CI
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
ICEServers: BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
}),
})
if err != nil {
t.Fatalf("WebRTC-enabled downloader failed to start: %v", err)
}
defer func() {
if err := dl.Shutdown(context.Background()); err != nil {
t.Logf("shutdown: %v", err)
}
}()
// Magnet for any task should now contain the WSS tracker.
got := dl.buildMagnet(validHash)
if !strings.Contains(got, "wss%3A%2F%2Ftracker.torrentclaw.com") {
t.Fatalf("WebRTC magnet missing WSS tracker: %s", got)
}
}
// TestBuildICEServers_TURNWithCreds applies TURNUser/TURNPass to every TURN
// entry so the operator only specifies them once.
func TestBuildICEServers_TURNWithCreds(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
TURNServers: []string{"turn:turn.example.com:3478"},
TURNUser: "alice",
TURNPass: "s3cr3t",
})
if len(got) != 2 {
t.Fatalf("expected 1 STUN + 1 TURN, got %d", len(got))
}
turn := got[1]
if turn.URLs[0] != "turn:turn.example.com:3478" {
t.Fatalf("TURN URL wrong: %+v", turn)
}
if turn.Username != "alice" {
t.Fatalf("TURN username wrong: %s", turn.Username)
}
if turn.Credential != "s3cr3t" {
t.Fatalf("TURN credential wrong: %v", turn.Credential)
}
if turn.CredentialType != webrtc.ICECredentialTypePassword {
t.Fatalf("TURN credential type wrong: %v", turn.CredentialType)
}
}

View file

@ -1,254 +0,0 @@
// Package wire implements the binary frame format used over the WebRTC
// DataChannel between the unarr daemon and the browser stream player.
//
// Header (12 bytes, big-endian):
//
// u8 Type
// u8 Flags
// u16 _reserved
// u32 StreamID -- multiplex range requests
// u32 Length -- payload bytes following the header
//
// Each side encodes one Frame at a time and writes it as a single SCTP
// message (DataChannel send). Browsers cap message size at 64 KiB-ish, so
// callers MUST split RANGE_DATA payloads into chunks <= MaxChunkPayload.
package wire
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
// FrameType identifies the wire message kind.
type FrameType uint8
const (
FrameHello FrameType = 0x00
FrameRangeReq FrameType = 0x01
FrameRangeData FrameType = 0x02
FrameRangeEnd FrameType = 0x03
FrameCancel FrameType = 0x04
FramePing FrameType = 0x05
FramePong FrameType = 0x06
FrameSeekHint FrameType = 0x07
)
// Flag bits — interpretation depends on FrameType.
const (
// FlagLastChunk on a RangeData frame marks the final chunk for a stream_id.
FlagLastChunk uint8 = 1 << 0
// FlagTranscoding on a Hello frame indicates the daemon will transcode.
FlagTranscoding uint8 = 1 << 1
// FlagSeekable on a Hello frame indicates random-access is supported.
FlagSeekable uint8 = 1 << 2
)
// HeaderSize is the fixed length of every frame header.
const HeaderSize = 12
// MaxChunkPayload is the safe per-frame payload cap that works on every
// browser implementation (Chromium fragments at 16 KiB internally above).
// Callers MUST chunk RangeData payloads to <= this size.
const MaxChunkPayload = 16 * 1024
// MaxFrameSize is the largest frame the parser will accept. Anything bigger
// is treated as a corrupted stream — close the channel.
const MaxFrameSize = HeaderSize + 64*1024
// Header is the parsed 12-byte frame header.
type Header struct {
Type FrameType
Flags uint8
StreamID uint32
Length uint32
}
// EncodeHeader writes h to dst (must be at least HeaderSize bytes).
func EncodeHeader(dst []byte, h Header) {
if len(dst) < HeaderSize {
panic("wire: dst too small for header")
}
dst[0] = byte(h.Type)
dst[1] = h.Flags
dst[2] = 0
dst[3] = 0
binary.BigEndian.PutUint32(dst[4:8], h.StreamID)
binary.BigEndian.PutUint32(dst[8:12], h.Length)
}
// DecodeHeader parses src (must be at least HeaderSize bytes) into h.
func DecodeHeader(src []byte) (Header, error) {
if len(src) < HeaderSize {
return Header{}, fmt.Errorf("wire: header needs %d bytes, got %d", HeaderSize, len(src))
}
h := Header{
Type: FrameType(src[0]),
Flags: src[1],
StreamID: binary.BigEndian.Uint32(src[4:8]),
Length: binary.BigEndian.Uint32(src[8:12]),
}
if h.Length > MaxFrameSize-HeaderSize {
return Header{}, fmt.Errorf("wire: payload length %d exceeds max %d", h.Length, MaxFrameSize-HeaderSize)
}
return h, nil
}
// EncodeFrame allocates and returns a complete frame (header + payload).
// Use this for one-shot sends; for hot-path RangeData prefer EncodeHeader
// into a pre-allocated buffer to avoid per-frame allocations.
func EncodeFrame(h Header, payload []byte) []byte {
if int(h.Length) != len(payload) {
panic(fmt.Sprintf("wire: header length %d != payload len %d", h.Length, len(payload)))
}
buf := make([]byte, HeaderSize+len(payload))
EncodeHeader(buf[:HeaderSize], h)
copy(buf[HeaderSize:], payload)
return buf
}
// ReadFrame reads one full frame from r. Returns the parsed header and a
// freshly allocated payload slice. On any size violation the connection
// must be closed — the protocol has no resync.
func ReadFrame(r io.Reader) (Header, []byte, error) {
headerBuf := make([]byte, HeaderSize)
if _, err := io.ReadFull(r, headerBuf); err != nil {
return Header{}, nil, err
}
h, err := DecodeHeader(headerBuf)
if err != nil {
return Header{}, nil, err
}
if h.Length == 0 {
return h, nil, nil
}
payload := make([]byte, h.Length)
if _, err := io.ReadFull(r, payload); err != nil {
return Header{}, nil, err
}
return h, payload, nil
}
// HelloPayload describes the file the daemon is about to serve. It is the
// first frame the daemon writes after the DataChannel opens.
type HelloPayload struct {
FileSize uint64
Transcoding bool
Seekable bool
FileName string
}
// EncodeHello marshals h into a payload byte slice.
//
// Layout: u64 file_size | u32 name_len | name_bytes
func EncodeHello(h HelloPayload) []byte {
nameBytes := []byte(h.FileName)
buf := make([]byte, 8+4+len(nameBytes))
binary.BigEndian.PutUint64(buf[0:8], h.FileSize)
binary.BigEndian.PutUint32(buf[8:12], uint32(len(nameBytes)))
copy(buf[12:], nameBytes)
return buf
}
// DecodeHello parses a Hello payload. The transcoding/seekable bits live in
// the frame Flags byte, not the payload — pass them in.
func DecodeHello(payload []byte, flags uint8) (HelloPayload, error) {
if len(payload) < 12 {
return HelloPayload{}, errors.New("wire: hello payload too short")
}
size := binary.BigEndian.Uint64(payload[0:8])
nameLen := binary.BigEndian.Uint32(payload[8:12])
if int(nameLen) > len(payload)-12 {
return HelloPayload{}, fmt.Errorf("wire: hello name_len %d exceeds payload", nameLen)
}
return HelloPayload{
FileSize: size,
Transcoding: flags&FlagTranscoding != 0,
Seekable: flags&FlagSeekable != 0,
FileName: string(payload[12 : 12+nameLen]),
}, nil
}
// HelloFlags returns the flag byte for a Hello frame given the booleans.
func HelloFlags(transcoding, seekable bool) uint8 {
var f uint8
if transcoding {
f |= FlagTranscoding
}
if seekable {
f |= FlagSeekable
}
return f
}
// RangeReqPayload is the browser → daemon request for bytes [Offset, Offset+Length).
type RangeReqPayload struct {
Offset uint64
Length uint64
}
// EncodeRangeReq marshals p. Layout: u64 offset | u64 length.
func EncodeRangeReq(p RangeReqPayload) []byte {
buf := make([]byte, 16)
binary.BigEndian.PutUint64(buf[0:8], p.Offset)
binary.BigEndian.PutUint64(buf[8:16], p.Length)
return buf
}
// DecodeRangeReq parses a 16-byte range request payload.
func DecodeRangeReq(payload []byte) (RangeReqPayload, error) {
if len(payload) != 16 {
return RangeReqPayload{}, fmt.Errorf("wire: range_req payload must be 16 bytes, got %d", len(payload))
}
return RangeReqPayload{
Offset: binary.BigEndian.Uint64(payload[0:8]),
Length: binary.BigEndian.Uint64(payload[8:16]),
}, nil
}
// RangeEndPayload signals end-of-response for a stream_id with a status code.
// Status 0 == OK; non-zero values are app-defined error codes.
type RangeEndPayload struct {
Status uint32
}
// EncodeRangeEnd marshals p.
func EncodeRangeEnd(p RangeEndPayload) []byte {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf[0:4], p.Status)
return buf
}
// DecodeRangeEnd parses a 4-byte range_end payload.
func DecodeRangeEnd(payload []byte) (RangeEndPayload, error) {
if len(payload) != 4 {
return RangeEndPayload{}, fmt.Errorf("wire: range_end payload must be 4 bytes, got %d", len(payload))
}
return RangeEndPayload{
Status: binary.BigEndian.Uint32(payload[0:4]),
}, nil
}
// SeekHintPayload tells the daemon a seek to timestamp_ms is imminent so it
// can pre-warm a transcoder pipeline before bytes are requested.
type SeekHintPayload struct {
TimestampMs uint64
}
// EncodeSeekHint marshals p.
func EncodeSeekHint(p SeekHintPayload) []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf[0:8], p.TimestampMs)
return buf
}
// DecodeSeekHint parses an 8-byte seek_hint payload.
func DecodeSeekHint(payload []byte) (SeekHintPayload, error) {
if len(payload) != 8 {
return SeekHintPayload{}, fmt.Errorf("wire: seek_hint payload must be 8 bytes, got %d", len(payload))
}
return SeekHintPayload{
TimestampMs: binary.BigEndian.Uint64(payload[0:8]),
}, nil
}

View file

@ -1,193 +0,0 @@
package wire
import (
"bytes"
"testing"
)
func TestHeaderRoundtrip(t *testing.T) {
cases := []Header{
{Type: FrameHello, Flags: FlagSeekable, StreamID: 0, Length: 32},
{Type: FrameRangeReq, Flags: 0, StreamID: 7, Length: 16},
{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 4242, Length: 16380},
{Type: FrameRangeEnd, Flags: 0, StreamID: 1, Length: 4},
{Type: FrameCancel, Flags: 0, StreamID: 9, Length: 0},
{Type: FramePing, Flags: 0, StreamID: 0, Length: 0},
}
for _, want := range cases {
buf := make([]byte, HeaderSize)
EncodeHeader(buf, want)
got, err := DecodeHeader(buf)
if err != nil {
t.Fatalf("decode: %v (want %+v)", err, want)
}
if got != want {
t.Errorf("roundtrip mismatch: got %+v want %+v", got, want)
}
}
}
func TestDecodeHeaderShort(t *testing.T) {
if _, err := DecodeHeader([]byte{0, 0, 0}); err == nil {
t.Fatal("expected error on short header")
}
}
func TestDecodeHeaderRejectsHugeLength(t *testing.T) {
// Synthesize a header with payload length above MaxFrameSize.
buf := make([]byte, HeaderSize)
buf[0] = byte(FrameRangeData)
buf[8] = 0xff
buf[9] = 0xff
buf[10] = 0xff
buf[11] = 0xff
if _, err := DecodeHeader(buf); err == nil {
t.Fatal("expected error on oversized payload length")
}
}
func TestEncodeFramePanicsOnLengthMismatch(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on header length / payload mismatch")
}
}()
EncodeFrame(Header{Type: FrameRangeData, Length: 5}, []byte{1, 2, 3})
}
func TestReadFrameRoundtrip(t *testing.T) {
want := Header{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 99, Length: 5}
payload := []byte{0xde, 0xad, 0xbe, 0xef, 0x42}
frame := EncodeFrame(want, payload)
r := bytes.NewReader(frame)
got, gotPayload, err := ReadFrame(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if !bytes.Equal(gotPayload, payload) {
t.Errorf("payload mismatch: %x want %x", gotPayload, payload)
}
}
func TestReadFrameZeroPayload(t *testing.T) {
want := Header{Type: FrameCancel, StreamID: 7}
frame := EncodeFrame(want, nil)
got, payload, err := ReadFrame(bytes.NewReader(frame))
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if len(payload) != 0 {
t.Errorf("expected empty payload, got %d bytes", len(payload))
}
}
func TestHelloRoundtrip(t *testing.T) {
want := HelloPayload{
FileSize: 1<<32 + 12345,
Transcoding: false,
Seekable: true,
FileName: "Tangled.Ever.After.2025.1080p.WEB-DL.h264.mp4",
}
flags := HelloFlags(want.Transcoding, want.Seekable)
payload := EncodeHello(want)
got, err := DecodeHello(payload, flags)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("hello mismatch: %+v want %+v", got, want)
}
}
func TestHelloRejectsTruncatedPayload(t *testing.T) {
if _, err := DecodeHello([]byte{1, 2, 3}, 0); err == nil {
t.Fatal("expected error on truncated hello")
}
}
func TestHelloRejectsNameLenOverrun(t *testing.T) {
// file_size + name_len=999 but no name bytes → should fail.
buf := make([]byte, 12)
buf[8], buf[9], buf[10], buf[11] = 0, 0, 0x03, 0xe7 // 999
if _, err := DecodeHello(buf, 0); err == nil {
t.Fatal("expected error on name_len overrun")
}
}
func TestRangeReqRoundtrip(t *testing.T) {
want := RangeReqPayload{Offset: 1 << 30, Length: 1 << 20}
got, err := DecodeRangeReq(EncodeRangeReq(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_req mismatch: %+v want %+v", got, want)
}
}
func TestRangeReqRejectsWrongLength(t *testing.T) {
if _, err := DecodeRangeReq(make([]byte, 15)); err == nil {
t.Fatal("expected error on 15-byte payload")
}
if _, err := DecodeRangeReq(make([]byte, 17)); err == nil {
t.Fatal("expected error on 17-byte payload")
}
}
func TestRangeEndRoundtrip(t *testing.T) {
want := RangeEndPayload{Status: 42}
got, err := DecodeRangeEnd(EncodeRangeEnd(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_end mismatch: %+v want %+v", got, want)
}
if _, err := DecodeRangeEnd(make([]byte, 3)); err == nil {
t.Fatal("expected error on short range_end payload")
}
}
func TestSeekHintRoundtrip(t *testing.T) {
want := SeekHintPayload{TimestampMs: 123_456}
got, err := DecodeSeekHint(EncodeSeekHint(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("seek_hint mismatch: %+v want %+v", got, want)
}
if _, err := DecodeSeekHint(make([]byte, 7)); err == nil {
t.Fatal("expected error on short seek_hint payload")
}
}
func TestHelloFlagsHelper(t *testing.T) {
if HelloFlags(false, false) != 0 {
t.Error("expected 0 for both false")
}
if HelloFlags(true, false) != FlagTranscoding {
t.Error("expected FlagTranscoding only")
}
if HelloFlags(false, true) != FlagSeekable {
t.Error("expected FlagSeekable only")
}
if HelloFlags(true, true) != (FlagTranscoding | FlagSeekable) {
t.Error("expected both flags")
}
}
// Sanity check that MaxChunkPayload + HeaderSize fits inside MaxFrameSize so
// callers can rely on the chunk cap without their own bookkeeping.
func TestMaxChunkFitsInMaxFrame(t *testing.T) {
if MaxChunkPayload+HeaderSize > MaxFrameSize {
t.Fatalf("chunk %d + hdr %d > max frame %d", MaxChunkPayload, HeaderSize, MaxFrameSize)
}
}