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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
64
internal/engine/transcode_quality.go
Normal file
64
internal/engine/transcode_quality.go
Normal 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"}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue