feat(torrent): act as WebTorrent peer for browser ↔ unarr P2P streaming

Wires anacrolix/torrent's built-in webtorrent package so a browser
running webtorrent.js can fetch pieces from this CLI via WebRTC data
channels. The daemon stays the seeder; we never relay bytes through
TorrentClaw infrastructure — same legal posture as today.

Changes:
- internal/config: new [downloads.webrtc] section
  (enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass).
  Disabled by default, opt-in via config.toml. When enabled but
  trackers / STUN slices are empty, defaults are reapplied on Load() so
  users get a working setup with a single `enabled = true`.
- internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers
  / ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList
  and forces NoUpload=false when WebRTC is on (browsers can't pull
  otherwise). buildMagnet now accepts variadic extra trackers and the
  downloader method prepends WSS trackers so anacrolix's
  webtorrent.TrackerClient picks them up first.
- internal/engine/webrtc.go: BuildICEServers helper converts the TOML
  WebRTCConfig into []webrtc.ICEServer with shared TURN credentials.
- internal/cmd/daemon.go + download.go: pass WebRTC config through to
  the engine.

Tests (8 new, all green; full suite 0 lint issues, 0 vet):
- buildMagnet free function: defaults-only, with extras, trim+empty-skip
- downloader method: WebRTC disabled keeps WSS out, enabled prepends them
- BuildICEServers: nil when disabled, STUN-only path, TURN+credentials
- NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC
  peer enabled, magnet contains wss://tracker.torrentclaw.com)

End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a
manual test once tracker.torrentclaw.com WSS is live.
This commit is contained in:
Deivid Soto 2026-05-06 08:59:58 +02:00
parent 6955b6144b
commit f6117ddeb9
6 changed files with 310 additions and 13 deletions

View file

@ -189,6 +189,9 @@ func runDaemonStart() error {
MaxUploadRate: maxUl,
ListenPort: cfg.Download.ListenPort,
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)

View file

@ -114,6 +114,9 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)

View file

@ -34,16 +34,30 @@ type AgentConfig struct {
}
type DownloadConfig struct {
Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
WebRTC WebRTCConfig `toml:"webrtc"`
}
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
// can fetch pieces via WebRTC data channels — required by the in-browser
// player on torrentclaw.com. Disabled by default; enabling implies upload
// is allowed for active torrents (browsers can't download otherwise).
type WebRTCConfig struct {
Enabled bool `toml:"enabled"` // master switch
Trackers []string `toml:"trackers"` // wss:// signaling trackers
STUNServers []string `toml:"stun_servers"` // stun:host:port
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
TURNPass string `toml:"turn_pass"` // optional
}
type OrganizeConfig struct {
@ -86,6 +100,11 @@ func Default() Config {
PreferredMethod: "auto",
MaxConcurrent: 3,
StreamPort: 11818,
WebRTC: WebRTCConfig{
Enabled: false,
Trackers: []string{"wss://tracker.torrentclaw.com"},
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
},
},
Organize: OrganizeConfig{
Enabled: true,
@ -144,6 +163,19 @@ func Load(path string) (Config, error) {
if cfg.Download.StreamPort == 0 {
cfg.Download.StreamPort = 11818
}
// Re-apply WebRTC defaults only when the user enabled WebRTC but didn't
// supply trackers/STUN — leave both empty if disabled to keep config diffs clean.
if cfg.Download.WebRTC.Enabled {
if len(cfg.Download.WebRTC.Trackers) == 0 {
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
}
if len(cfg.Download.WebRTC.STUNServers) == 0 {
cfg.Download.WebRTC.STUNServers = []string{
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
}
}
}
return cfg, nil
}

View file

@ -16,6 +16,7 @@ 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"
"golang.org/x/term"
"golang.org/x/time/rate"
@ -70,6 +71,14 @@ type TorrentConfig struct {
SeedEnabled bool
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
}
// TorrentDownloader downloads torrents via BitTorrent P2P.
@ -96,9 +105,27 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !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.Critical)
// 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
}
// --- Performance optimizations ---
// Storage: mmap instead of default file backend.
@ -235,7 +262,7 @@ func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, erro
}
func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) {
magnet := buildMagnet(task.InfoHash)
magnet := d.buildMagnet(task.InfoHash)
t, err := d.client.AddMagnet(magnet)
if err != nil {
@ -604,14 +631,33 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
return totalBytes, fileName
}
func buildMagnet(infoHash string) string {
// 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 {
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)
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {

36
internal/engine/webrtc.go Normal file
View file

@ -0,0 +1,36 @@
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

@ -0,0 +1,177 @@
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)
}
}