Skip to content

feat: add BitTorrent engine with magnet link and .torrent file support#326

Open
mvanhorn wants to merge 3 commits intoSurgeDM:mainfrom
mvanhorn:feat/bittorrent-support
Open

feat: add BitTorrent engine with magnet link and .torrent file support#326
mvanhorn wants to merge 3 commits intoSurgeDM:mainfrom
mvanhorn:feat/bittorrent-support

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

@mvanhorn mvanhorn commented Apr 5, 2026

Summary

Add a BitTorrent engine wrapping anacrolix/torrent that supports magnet URIs and .torrent files. The engine is modular per #170's community request.

Why this matters

Issue #170: "one reason I can't switch yet is because aria2 has torrent support while surge does not." @SuperCoolPencil confirmed this is planned (12 heart reactions on the reply). @sfzaw requested modular design: "please make this feature modular if possible."

anacrolix/torrent (6K stars) is the standard Go BitTorrent library, used by Gopeed (22.8K stars) and bitmagnet (3.9K stars). It supports DHT, PEX, uTP, WebTorrent, WebSeeds, BitTorrent v2, and protocol encryption.

Changes

  • Add internal/engine/torrent/ package:
    • Engine wrapping torrent.Client with Surge-compatible progress reporting
    • AddMagnet(uri) and AddTorrentFile(path) for adding torrents
    • Download(ctx, torrent, progressCh) for progress tracking with peer count and speed
    • IsMagnetURI() and IsTorrentFile() for source detection
  • Add TorrentSettings to config (enabled, seed_ratio, max_peers) with TUI tab
  • When Enabled: false (default), the engine is not initialized (modular per request)
  • No upload by default (seed ratio 0) to avoid surprising users

This is Phase 1: the core engine and settings. Wiring into the download lifecycle, CLI commands, and TUI display will follow in separate PRs.

Closes #170

Testing

Tests

go test ./... -count=1   # All 19 packages pass

This contribution was developed with AI assistance (Codex + Claude Code).

Greptile Summary

This PR adds a BitTorrent engine to Surge by wrapping the anacrolix/torrent library, supporting magnet URIs and .torrent files. It introduces internal/engine/torrent/ (engine, config, tests) and extends internal/config/settings.go with TorrentSettings. The engine is modular (opt-in via Enabled: false default), and all previously flagged issues from earlier review rounds have been resolved — context cancellation is now non-blocking, IsTorrentFile uses == instead of HasSuffix, MaxPeers is correctly wired via EstablishedConnsPerTorrent, and test coverage includes positive and edge cases.

Key remaining concerns:

  • SeedRatio is not enforced at runtime — any non-zero value only toggles seeding on indefinitely; the ratio target is never monitored or enforced, making the setting misleading to users who configure it.
  • SeedTime exists in engine Config but has no counterpart in TorrentSettings — when Phase 2 bridges settings to the engine, this field will silently default to zero.
  • Dependency footprintanacrolix/torrent brings in ~50+ transitive dependencies including a full pion/WebRTC stack, SQLite bindings, and gorilla/websocket, which meaningfully increases binary size and attack surface for a tool with an explicit lightweight goal.

Confidence Score: 4/5

Safe to merge for Phase 1 (no production wiring yet), but SeedRatio silently misbehaves when non-zero — misleading to users who configure it.

All previously flagged P0/P1 issues from prior rounds are resolved (non-blocking channel send, IsTorrentFile logic, MaxPeers wiring, done guard, test coverage). One new P1 remains: SeedRatio is exposed with a ratio-limit description but only acts as a binary toggle, meaning users who set a target ratio will get unlimited seeding. This should be addressed before GA, though it does not break Phase 1 functionality since the engine is not yet wired into downloads.

internal/engine/torrent/engine.go (SeedRatio enforcement), internal/engine/torrent/config.go (SeedTime/ListenPort exposure gap), go.mod (dependency footprint)

Important Files Changed

Filename Overview
internal/engine/torrent/engine.go Core engine wrapping anacrolix/torrent; SeedRatio config toggles seeding on but the ratio target is never monitored or enforced, causing unlimited seeding for any non-zero value
internal/engine/torrent/config.go Engine Config struct with SeedTime and ListenPort fields that have no counterparts in TorrentSettings, creating a silent mapping gap for Phase 2
internal/engine/torrent/engine_test.go Good coverage of detection helpers, config defaults, and engine construction; missing tests for seeding behavior and progress reporting
internal/config/settings.go Adds TorrentSettings with Enabled/SeedRatio/MaxPeers; omits SeedTime and ListenPort which exist in the engine Config
internal/config/settings_test.go Comprehensive settings tests covering defaults, JSON round-trip, partial JSON, corrupted/truncated file fallbacks, and metadata validation
go.mod Adds anacrolix/torrent v1.61.0 with ~50+ transitive dependencies including pion/WebRTC, SQLite bindings, and gorilla/websocket

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User adds magnet/torrent] --> B{IsMagnetURI?}
    B -- yes --> C[Engine.AddMagnet]
    B -- no --> D{IsTorrentFile?}
    D -- yes --> E[Engine.AddTorrentFile]
    D -- no --> F[Error: unsupported source]
    C --> G[anacrolix client.AddMagnet]
    E --> H[anacrolix client.AddTorrentFromFile]
    G --> I[torrent.Torrent handle]
    H --> I
    I --> J[Engine.Download]
    J --> K{Wait for GotInfo}
    K -- timeout 2m --> L[Error: metadata timeout]
    K -- ctx cancelled --> M[Return ctx.Err]
    K -- success --> N[t.DownloadAll]
    N --> O[500ms ticker loop]
    O --> P{ctx.Done?}
    P -- yes --> M
    P -- no --> Q[Compute progress + speed]
    Q --> R[Non-blocking send to progressCh]
    R --> S{done = total>0 AND completed>=total}
    S -- yes --> T[Return nil]
    S -- no --> O

    subgraph Settings
        U[TorrentSettings.Enabled]
        V[TorrentSettings.SeedRatio]
        W[TorrentSettings.MaxPeers]
        X[engine.Config.SeedTime - no UI mapping]
        Y[engine.Config.ListenPort - no UI mapping]
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: internal/engine/torrent/engine.go
Line: 46-51

Comment:
**`SeedRatio` config is stored but never enforced**

When `cfg.SeedRatio > 0`, the engine sets `clientCfg.Seed = true` and begins uploading — but there is no code that ever monitors the actual upload/download ratio and stops seeding when the target is reached. From a user perspective, the `seed_ratio` setting in the TUI is described as "Upload-to-download ratio target. 0 disables seeding" — but setting it to, say, `1.0` will cause the client to seed indefinitely rather than stopping when it has uploaded as much as it downloaded.

This means `SeedRatio` currently behaves as a binary on/off switch rather than a ratio limiter. Since the setting is already exposed in the UI with a ratio-based description, users will configure it expecting ratio-limited seeding and get unlimited seeding instead. Either the behavior should match the description (track cumulative bytes and stop seeding at the threshold), or the description should be changed to reflect the current binary behavior (e.g. "Enable seeding after download completes") until ratio enforcement is implemented in a later phase.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/engine/torrent/config.go
Line: 12-13

Comment:
**`SeedTime` has no counterpart in `TorrentSettings`**

`Config.SeedTime time.Duration` controls the maximum seeding duration after a download completes, but `TorrentSettings` in `internal/config/settings.go` exposes only `SeedRatio` and `MaxPeers`. There is also no `seed_time` key in `GetSettingsMetadata()`.

When Phase 2 bridges `settings.Torrent` to `engine.Config`, `SeedTime` will silently default to zero. If seeding is enabled via `SeedRatio > 0`, the engine will seed indefinitely with no way for users to bound it by time. Consider either adding `SeedTime` to `TorrentSettings` now (while the plumbing is being laid), or removing it from the engine `Config` until it can be fully surfaced. Similarly, `ListenPort` in `Config` has no corresponding user-visible setting.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: go.mod
Line: 30-52

Comment:
**Heavyweight transitive dependency tree**

`anacrolix/torrent` pulls in a substantial transitive dependency set: a full pion/WebRTC stack (13+ `pion/*` packages), SQLite bindings (`go-llsqlite`), gorilla/websocket, OpenTelemetry tracing (`go-logr`), and cgo-dependent libraries (`go-libutp`, `mmap-go`). The `go.mod` diff shows ~50 new indirect entries.

Surge's design goal is to remain lightweight and fast — every addition increases binary size, startup time, and maintenance burden. Since Phase 1 only introduces the engine package (not yet wired into downloads), it may be worth evaluating whether a build tag (`//go:build !notorrent`) could keep these dependencies out of default builds, or whether a subset of the library (DHT + metadata only, disabling WebTorrent/WebRTC) could reduce the footprint until the feature is fully integrated.

**Rule Used:** What: Reject unnecessary dependencies, abstraction... ([source](https://app.greptile.com/review/custom-context?memory=56e90a47-406e-439b-895b-903cf8c985f1))

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: internal/engine/torrent/engine.go
Line: 27

Comment:
**Misleading `Speed` field comment**

The `Progress.Speed` field is documented as `// bytes/sec (caller computes from deltas)`, but the engine itself computes the speed from deltas on lines 128–132 and sends the pre-computed value in every `Progress` report. The comment implies callers must compute this themselves, which is incorrect and could confuse future consumers of the `Progress` type.

```suggestion
	Speed          float64 // bytes/sec, computed over each 500 ms reporting interval
```

**Rule Used:** What: Comments must explain *why* code exists, not... ([source](https://app.greptile.com/review/custom-context?memory=4e45ef13-32c2-4753-8060-a44ef767144d))

How can I resolve this? If you propose a fix, please make it concise.

Reviews (4): Last reviewed commit: "fix(torrent): address greptile review fi..." | Re-trigger Greptile

Context used:

  • Rule used - What: Reject unnecessary dependencies, abstraction... (source)
  • Rule used - What: Comments must explain why code exists, not... (source)

Add a torrent engine package wrapping anacrolix/torrent (6K stars, used
by Gopeed and bitmagnet). Supports magnet URIs, .torrent files, DHT,
PEX, and protocol encryption. Downloads report progress with peer count
and speed metrics.

The engine is modular per community request on SurgeDM#170. When Torrent is
disabled in settings (the default), the engine is not initialized.
Torrent settings include seed ratio, max peers, and an enable toggle.

Wiring into the download lifecycle, CLI, and TUI display will follow in
separate PRs. This PR establishes the core engine and settings.

Closes SurgeDM#170

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 5, 2026

Binary Size Analysis

⚠️ Size Increased

Version Human Readable Raw Bytes
Main 18.91 MB 19829028
PR 18.91 MB 19833124
Difference 4.00 KB 4096

Comment on lines +71 to +87
// AddMagnet adds a magnet link and returns a handle to monitor progress.
func (e *Engine) AddMagnet(magnetURI string) (*torrent.Torrent, error) {
t, err := e.client.AddMagnet(magnetURI)
if err != nil {
return nil, fmt.Errorf("torrent: failed to add magnet: %w", err)
}
return t, nil
}

// AddTorrentFile adds a .torrent file and returns a handle.
func (e *Engine) AddTorrentFile(path string) (*torrent.Torrent, error) {
t, err := e.client.AddTorrentFromFile(path)
if err != nil {
return nil, fmt.Errorf("torrent: failed to add torrent file: %w", err)
}
return t, nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Public API leaks the anacrolix/torrent type across the package boundary

AddMagnet, AddTorrentFile, and Download all expose or accept *torrent.Torrent directly. This means every future caller (download lifecycle, CLI, TUI) must import github.com/anacrolix/torrent, tightly coupling Surge internals to a third-party type. Swapping libraries later would require changes in every call site.

Consider wrapping the handle:

// Handle represents an in-progress torrent download.
type Handle struct {
    t *torrent.Torrent
}

func (e *Engine) AddMagnet(magnetURI string) (*Handle, error) { ... }
func (e *Engine) Download(ctx context.Context, h *Handle, progressCh chan<- Progress) error { ... }

This keeps the anacrolix/torrent import confined to this package.

Rule Used: What: Enforce separation of concerns by keeping bu... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/engine/torrent/engine.go
Line: 71-87

Comment:
**Public API leaks the `anacrolix/torrent` type across the package boundary**

`AddMagnet`, `AddTorrentFile`, and `Download` all expose or accept `*torrent.Torrent` directly. This means every future caller (download lifecycle, CLI, TUI) must import `github.com/anacrolix/torrent`, tightly coupling Surge internals to a third-party type. Swapping libraries later would require changes in every call site.

Consider wrapping the handle:

```go
// Handle represents an in-progress torrent download.
type Handle struct {
    t *torrent.Torrent
}

func (e *Engine) AddMagnet(magnetURI string) (*Handle, error) { ... }
func (e *Engine) Download(ctx context.Context, h *Handle, progressCh chan<- Progress) error { ... }
```

This keeps the `anacrolix/torrent` import confined to this package.

**Rule Used:** What: Enforce separation of concerns by keeping bu... ([source](https://app.greptile.com/review/custom-context?memory=c1138776-52c4-4b49-81a0-1cd68fe194dc))

How can I resolve this? If you propose a fix, please make it concise.

The anacrolix/torrent SQLite storage may hold file locks briefly after
Close() on Windows, causing t.TempDir() cleanup to fail. Use
os.MkdirTemp with defer RemoveAll instead, which tolerates locked files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sfzaw

This comment was marked as resolved.

- Fix IsTorrentFile: use exact == comparison instead of HasSuffix
- Fix blocking channel send: use non-blocking select/default
- Fix done check: require total > 0 to prevent false completion
- Wire MaxPeers to EstablishedConnsPerTorrent
- Replace descriptive comments with rationale comments
- Add positive and edge case tests for IsTorrentFile
@mvanhorn
Copy link
Copy Markdown
Contributor Author

mvanhorn commented Apr 6, 2026

@sfzaw Thanks for sharing those concerns and for the thoughtful writeup. Glad the modular approach addresses them.

Addressed greptile findings in a9f317d:

  • P0: IsTorrentFile now uses exact == comparison instead of HasSuffix (.mytorrent would have matched before)
  • P1: Progress channel send is non-blocking via select/default, so a slow consumer can't stall the download loop or block context cancellation
  • P1: done check now requires total > 0 to prevent false completion on zero-length or partially-parsed metadata
  • P1: MaxPeers wired to EstablishedConnsPerTorrent so the config value actually takes effect
  • P2: Replaced descriptive comments with rationale comments
  • P2: Added positive and edge case tests for IsTorrentFile

Left the API wrapping (P2 - *torrent.Torrent leak) for a separate pass since it touches all callers.

Verified: go test ./internal/engine/torrent/ - 4/4 pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Torrent support

2 participants