package engine import ( "context" "fmt" "log" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/anacrolix/dht/v2" "github.com/anacrolix/dht/v2/krpc" 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" "golang.org/x/time/rate" ) var defaultTrackers = []string{ // Tier 1: ngosang/trackerslist "best" + newtrackon "stable" "udp://tracker.opentrackr.org:1337/announce", "udp://open.tracker.cl:1337/announce", "udp://tracker.openbittorrent.com:6969/announce", "udp://tracker.torrent.eu.org:451/announce", "udp://open.stealth.si:80/announce", "udp://exodus.desync.com:6969/announce", "udp://open.demonii.com:1337/announce", "udp://tracker.qu.ax:6969/announce", "udp://tracker.dler.org:6969/announce", "udp://tracker.filemail.com:6969/announce", "udp://tracker.theoks.net:6969/announce", "udp://tracker.bittor.pw:1337/announce", "udp://tracker-udp.gbitt.info:80/announce", "udp://open.dstud.io:6969/announce", "udp://leet-tracker.moe:1337/announce", // Tier 2: newtrackon stable (95%+ uptime) "udp://tracker.torrust-demo.com:6969/announce", "udp://tracker.plx.im:6969/announce", "udp://tracker.tryhackx.org:6969/announce", "udp://tracker.fnix.net:6969/announce", "udp://tracker.srv00.com:6969/announce", "udp://tracker.corpscorp.online:80/announce", "udp://tracker.opentorrent.top:6969/announce", "udp://tracker.flatuslifir.is:6969/announce", "udp://tracker.gmi.gd:6969/announce", "udp://tracker.t-1.org:6969/announce", "udp://tracker.bluefrog.pw:2710/announce", "udp://evan.im:6969/announce", // Tier 3: additional coverage "udp://t.overflow.biz:6969/announce", "udp://wepzone.net:6969/announce", "udp://tracker.alaskantf.com:6969/announce", "udp://tracker.therarbg.to:6969/announce", } // TorrentConfig holds settings for the BitTorrent downloader. type TorrentConfig struct { DataDir string MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited) StallTimeout time.Duration // no progress during download for this long = stall (default 10m) MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited) MaxDownloadRate int64 // bytes/s, 0 = unlimited MaxUploadRate int64 // bytes/s, 0 = unlimited ListenPort int // fixed port for incoming peers (default 42069, 0 = random) 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 // 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. VPNTunnel *vpn.Tunnel } // TorrentDownloader downloads torrents via BitTorrent P2P. type TorrentDownloader struct { client *torrent.Client cfg TorrentConfig activeMu sync.Mutex active map[string]*torrent.Torrent // taskID -> torrent handle } // NewTorrentDownloader creates a BitTorrent downloader with a long-lived client. func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { // MetadataTimeout: 0 = unlimited (wait forever like qBittorrent) // StallTimeout: default 30m (no bytes for 30 min = dead torrent, frees the slot) if cfg.StallTimeout == 0 { cfg.StallTimeout = 30 * time.Minute } if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } 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 // 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. // The library author notes file storage has "very high system overhead". // mmap improves I/O throughput and piece verification speed significantly. tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) // Fixed port for incoming peer connections (enables UPnP port mapping). // With ListenPort=0, only ~30% of peers can connect to us. listenPort := cfg.ListenPort if listenPort == 0 { listenPort = 42069 } tcfg.ListenPort = listenPort // Connection limits: more peers = more download sources. // Defaults are conservative (50/25/100). Beyond ~100 established, diminishing returns. tcfg.EstablishedConnsPerTorrent = 80 tcfg.HalfOpenConnsPerTorrent = 50 tcfg.TotalHalfOpenConns = 150 // Pipeline depth: bytes downloaded but not yet hash-verified. // Default 64 MiB throttles fast connections. The library author recommends // "set a very large MaxUnverifiedBytes" for speed (Discussion #741). tcfg.MaxUnverifiedBytes = 256 << 20 // 256 MiB // Faster peer discovery: default is 10 dials/s which is very conservative. tcfg.DialRateLimiter = rate.NewLimiter(40, 40) // IPv6 peer selection is poor in anacrolix (Issue #713) — wastes connections. tcfg.DisableIPv6 = true // Accept incoming connections faster + clean up useless peers. tcfg.DisableAcceptRateLimiting = true tcfg.DropDuplicatePeerIds = true tcfg.DropMutuallyCompletePeers = true // --- Rate limiting --- if cfg.MaxDownloadRate > 0 { burst := int(cfg.MaxDownloadRate) if burst < 256*1024 { burst = 256 * 1024 } tcfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxDownloadRate), burst) } if cfg.MaxUploadRate > 0 { burst := int(cfg.MaxUploadRate) if burst < 256*1024 { burst = 256 * 1024 } tcfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxUploadRate), burst) } // --- DHT tuning --- // Feed cached nodes into the bootstrap traversal (not just AddDhtNodes post-creation). // StartingNodes are used during the initial Bootstrap() which populates the routing table // much faster than async pings from AddDhtNodes(). dhtNodesPath := dhtNodesBinPath() tcfg.DhtStartingNodes = func(network string) dht.StartingNodesGetter { return func() ([]dht.Addr, error) { addrs, _ := dht.GlobalBootstrapAddrs(network) // Merge cached nodes from previous session cached, err := dht.ReadNodesFromFile(dhtNodesPath) if err == nil && len(cached) > 0 { for _, ni := range cached { addrs = append(addrs, dht.NewAddr(ni.Addr.UDP())) } log.Printf("[torrent] DHT: loaded %d cached nodes into bootstrap", len(cached)) } return addrs, nil } } // Tune DHT server for faster warmup and more aggressive peer discovery. tcfg.ConfigureAnacrolixDhtServer = func(cfg *dht.ServerConfig) { // Increase send rate: default 250/s burst 25 is conservative. // Higher rate lets bootstrap query more nodes concurrently. cfg.SendLimiter = rate.NewLimiter(500, 50) // Faster query retries: default 2s, reduce to 1s for quicker fallback. cfg.QueryResendDelay = func() time.Duration { return time.Second } // Accept all node IDs regardless of BEP 42 validation. // Fills routing table faster; most clients don't enforce BEP 42 strictly. cfg.NoSecurity = true // Request both IPv4 node lists in responses. cfg.DefaultWant = []krpc.Want{krpc.WantNodes} } // Re-announce active torrents to DHT periodically (keeps routing table healthy). tcfg.PeriodicallyAnnounceTorrentsToDht = true // --- Managed-VPN split-tunnel --- // Route the torrent client's outbound peer + tracker traffic through the // in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not // the user's. unarr's control plane keeps using the normal net. uTP (UDP // peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled; // inbound peers don't apply (leech-only, no port forward). if cfg.VPNTunnel != nil { tcfg.DisableUTP = true tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)") } // Try to create client; if the port is in use, try the next few ports. var client *torrent.Client var err error for attempt := 0; attempt < 10; attempt++ { client, err = torrent.NewClient(tcfg) if err == nil { break } if !strings.Contains(err.Error(), "address already in use") { return nil, fmt.Errorf("create torrent client: %w", err) } tcfg.ListenPort++ log.Printf("[torrent] port %d in use, trying %d", tcfg.ListenPort-1, tcfg.ListenPort) } if err != nil { return nil, fmt.Errorf("create torrent client (all ports busy): %w", err) } if tcfg.ListenPort != listenPort { log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort) } // Route outgoing peer dials through the VPN tunnel (TCP). Added after client // creation; DialForPeerConns defaults to true so this is used for peers. if cfg.VPNTunnel != nil { client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net}) } // Restore DHT nodes with full node IDs (direct routing table insertion, no async pings). for _, s := range client.DhtServers() { if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok { if added, err := w.Server.AddNodesFromFile(dhtNodesPath); err == nil && added > 0 { log.Printf("[torrent] DHT: restored %d nodes directly into routing table", added) } } } return &TorrentDownloader{ client: client, cfg: cfg, active: make(map[string]*torrent.Torrent), }, nil } func (d *TorrentDownloader) Method() DownloadMethod { return MethodTorrent } func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, error) { return task.InfoHash != "", nil } func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) { magnet := d.buildMagnet(task.InfoHash) t, err := d.client.AddMagnet(magnet) if err != nil { return nil, fmt.Errorf("add magnet: %w", err) } // Track active torrent d.activeMu.Lock() d.active[task.ID] = t d.activeMu.Unlock() cleanup := func() { d.activeMu.Lock() delete(d.active, task.ID) d.activeMu.Unlock() if !d.cfg.SeedEnabled { t.Drop() } } // 1. Wait for metadata (0 = unlimited, like qBittorrent) if d.cfg.MetadataTimeout > 0 { log.Printf("[%s] waiting for metadata (timeout: %s, trackers: %d)...", task.ID[:8], d.cfg.MetadataTimeout, len(defaultTrackers)) } else { log.Printf("[%s] waiting for metadata (no timeout, trackers: %d)...", task.ID[:8], len(defaultTrackers)) } if d.cfg.MetadataTimeout > 0 { metaCtx, metaCancel := context.WithTimeout(ctx, d.cfg.MetadataTimeout) defer metaCancel() select { case <-t.GotInfo(): log.Printf("[%s] metadata received: %s (%d files)", task.ID[:8], t.Name(), len(t.Files())) case <-metaCtx.Done(): stats := t.Stats() cleanup() return nil, fmt.Errorf("metadata timeout after %s (peers: %d)", d.cfg.MetadataTimeout, stats.ActivePeers) } } else { // Unlimited — wait until metadata arrives or context is cancelled select { case <-t.GotInfo(): log.Printf("[%s] metadata received: %s (%d files)", task.ID[:8], t.Name(), len(t.Files())) case <-ctx.Done(): cleanup() return nil, fmt.Errorf("cancelled while waiting for metadata") } } // 2. Select files to download (prefer largest video + matching subs) totalBytes, fileName := d.selectFiles(t, task.ID) log.Printf("[%s] downloading %s (%s)", task.ID[:8], fileName, formatBytes(totalBytes)) // 3. Poll progress with stall detection result, err := d.pollDownload(ctx, t, task, totalBytes, fileName, progressCh) if err != nil { cleanup() return nil, err } // 4. Determine file path // For multi-file torrents, fileName includes the torrent dir prefix (e.g. "TorrentName/file.mkv"). // Try the full path first, then just the file inside the torrent dir. filePath := filepath.Join(d.cfg.DataDir, fileName) if _, statErr := os.Stat(filePath); statErr != nil { // File might have been moved — try torrent directory dirPath := filepath.Join(d.cfg.DataDir, t.Name()) if fi, statErr2 := os.Stat(dirPath); statErr2 == nil && fi.IsDir() { // Look for the actual file inside the directory base := filepath.Base(fileName) candidate := filepath.Join(dirPath, base) if _, statErr3 := os.Stat(candidate); statErr3 == nil { filePath = candidate } else { filePath = dirPath } } else { filePath = dirPath } } result.FilePath = filePath result.FileName = filepath.Base(fileName) result.Method = MethodTorrent result.Size = totalBytes // If seeding enabled, keep alive (don't cleanup). // The manager handles seeding lifecycle. if !d.cfg.SeedEnabled { cleanup() } return result, nil } func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent, task *Task, totalBytes int64, fileName string, progressCh chan<- Progress) (*Result, error) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() // MaxTimeout = 0 means unlimited (like qBittorrent) var deadline <-chan time.Time if d.cfg.MaxTimeout > 0 { deadline = time.After(d.cfg.MaxTimeout) } lastBytesAt := time.Now() lastBytes := int64(0) isTTY := term.IsTerminal(int(os.Stderr.Fd())) for { select { case <-ctx.Done(): if isTTY { fmt.Fprintln(os.Stderr) } return nil, fmt.Errorf("cancelled") case <-deadline: if isTTY { fmt.Fprintln(os.Stderr) } return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout) case <-ticker.C: downloaded := t.BytesCompleted() now := time.Now() // Speed calculation speed := downloaded - lastBytes if speed < 0 { speed = 0 } // Stall detection (0 = disabled, like qBittorrent) if downloaded > lastBytes { lastBytesAt = now lastBytes = downloaded } else if d.cfg.StallTimeout > 0 && now.Sub(lastBytesAt) > d.cfg.StallTimeout { stats := t.Stats() return nil, fmt.Errorf("stalled: no progress for %s (peers: %d, seeds: %d)", d.cfg.StallTimeout, stats.ActivePeers, stats.ConnectedSeeders) } // ETA var eta int if speed > 0 { remaining := totalBytes - downloaded eta = int(remaining / speed) } // Peer stats stats := t.Stats() // Terminal progress pct := int(float64(downloaded) / float64(totalBytes) * 100) line := fmt.Sprintf("[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d", task.ID[:8], pct, formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed), stats.ActivePeers, stats.ConnectedSeeders) if isTTY { fmt.Fprintf(os.Stderr, "\r\033[K%s", line) } else { log.Print(line) } // Report progress p := Progress{ DownloadedBytes: downloaded, TotalBytes: totalBytes, SpeedBps: speed, ETA: eta, Peers: stats.ActivePeers, Seeds: stats.ConnectedSeeders, FileName: fileName, } task.UpdateProgress(p) select { case progressCh <- p: default: // don't block if channel full } // Check completion if downloaded >= totalBytes { if isTTY { fmt.Fprintln(os.Stderr) // newline after \r progress } log.Printf("[%s] download complete: %s", task.ID[:8], fileName) return &Result{}, nil } } } } // Pause drops the torrent handle but keeps partial files on disk for resume. func (d *TorrentDownloader) Pause(taskID string) error { d.activeMu.Lock() t, ok := d.active[taskID] delete(d.active, taskID) d.activeMu.Unlock() if !ok { return nil } t.Drop() log.Printf("[%s] paused (files kept for resume)", taskID[:8]) return nil } // Cancel drops the torrent handle and removes partial files from disk. func (d *TorrentDownloader) Cancel(taskID string) error { d.activeMu.Lock() t, ok := d.active[taskID] delete(d.active, taskID) d.activeMu.Unlock() if !ok { return nil } name := t.Name() t.Drop() if name != "" { path, err := safePath(d.cfg.DataDir, name) if err != nil { log.Printf("[%s] cancel blocked: %v", taskID[:8], err) return nil } if fi, statErr := os.Stat(path); statErr == nil { if fi.IsDir() { os.RemoveAll(path) } else { os.Remove(path) } log.Printf("[%s] cleaned up partial download: %s", taskID[:8], name) } } return nil } func (d *TorrentDownloader) Shutdown(ctx context.Context) error { // Save DHT nodes in binary format for next session (warm start) saveDhtNodesBinary(d.client) d.activeMu.Lock() for id, t := range d.active { t.Drop() delete(d.active, id) } d.activeMu.Unlock() errs := d.client.Close() if len(errs) > 0 { return fmt.Errorf("close client: %v", errs[0]) } return nil } // SaveDhtNodes persists DHT nodes to disk (for periodic saves from daemon). func (d *TorrentDownloader) SaveDhtNodes() { saveDhtNodesBinary(d.client) } // GetStreamProvider returns a FileProvider for the largest video file in an active torrent. // Used with the persistent StreamServer's SetFile() method. func (d *TorrentDownloader) GetStreamProvider(taskID string) (FileProvider, error) { d.activeMu.Lock() t, ok := d.active[taskID] d.activeMu.Unlock() if !ok { return nil, fmt.Errorf("no active torrent for task %s", taskID[:8]) } // Select largest video file files := t.Files() var video *torrent.File for _, f := range files { ext := strings.ToLower(filepath.Ext(f.DisplayPath())) if VideoExts[ext] && (video == nil || f.Length() > video.Length()) { video = f } } if video == nil { // No video — use largest file for _, f := range files { if video == nil || f.Length() > video.Length() { video = f } } } if video == nil { return nil, fmt.Errorf("torrent has no files") } return NewTorrentFileProvider(video), nil } // VideoExts is the canonical set of video file extensions used for file selection. var VideoExts = map[string]bool{ ".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".wmv": true, ".ts": true, ".webm": true, ".mov": true, ".mpg": true, ".mpeg": true, ".vob": true, ".flv": true, } var subExts = map[string]bool{ ".srt": true, ".ass": true, ".sub": true, ".ssa": true, ".vtt": true, } // selectFiles picks the largest video file + matching subtitles. // Falls back to downloading everything if no video file is found. // Returns the total bytes to download and the primary file name. func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (totalBytes int64, fileName string) { files := t.Files() if len(files) <= 1 { t.DownloadAll() return t.Length(), t.Name() } // Find largest video file var video *torrent.File for _, f := range files { ext := strings.ToLower(filepath.Ext(f.DisplayPath())) if VideoExts[ext] && (video == nil || f.Length() > video.Length()) { video = f } } if video == nil { // No video (music, software, etc.) — download everything t.DownloadAll() return t.Length(), t.Name() } // Download only the video video.Download() totalBytes = video.Length() fileName = video.DisplayPath() // Also download matching subtitles videoBase := strings.TrimSuffix(video.DisplayPath(), filepath.Ext(video.DisplayPath())) var subCount int for _, f := range files { ext := strings.ToLower(filepath.Ext(f.DisplayPath())) if subExts[ext] { fBase := strings.TrimSuffix(f.DisplayPath(), filepath.Ext(f.DisplayPath())) // Match by prefix (handles Movie.en.srt, Movie.es.srt) if strings.HasPrefix(fBase, videoBase) || filepath.Dir(f.DisplayPath()) == filepath.Dir(video.DisplayPath()) { f.Download() totalBytes += f.Length() subCount++ } } } skipped := len(files) - 1 - subCount if skipped > 0 { log.Printf("[%s] selected: %s (%s) + %d subs, skipped %d files", taskID[:8], filepath.Base(fileName), formatBytes(video.Length()), subCount, skipped) } 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 { 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 { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp]) } // --------------------------------------------------------------------------- // DHT node persistence — binary format with node IDs for direct table insertion // --------------------------------------------------------------------------- const dhtNodesBinFile = "dht-nodes.bin" // dhtNodesBinPath returns the path to the binary DHT nodes cache file. func dhtNodesBinPath() string { return filepath.Join(config.DataDir(), dhtNodesBinFile) } // saveDhtNodesBinary exports known DHT nodes with full node IDs (20-byte ID + address). // Binary format allows AddNodesFromFile to insert directly into routing table buckets // without needing async pings, which is much faster than text-based host:port persistence. func saveDhtNodesBinary(client *torrent.Client) { var allNodes []krpc.NodeInfo for _, s := range client.DhtServers() { if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok { allNodes = append(allNodes, w.Nodes()...) } } if len(allNodes) == 0 { return } // Cap at 200 nodes to prevent unbounded file growth if len(allNodes) > 200 { allNodes = allNodes[:200] } path := dhtNodesBinPath() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return } if err := dht.WriteNodesToFile(allNodes, path); err != nil { log.Printf("[torrent] DHT: error saving nodes: %v", err) return } log.Printf("[torrent] DHT: saved %d nodes to cache", len(allNodes)) }