2026-03-28 11:29:42 +01:00
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// Config holds all persistent CLI configuration.
type Config struct {
Auth AuthConfig ` toml:"auth" `
Agent AgentConfig ` toml:"agent" `
Download DownloadConfig ` toml:"downloads" `
Organize OrganizeConfig ` toml:"organize" `
Daemon DaemonConfig ` toml:"daemon" `
Notifications NotificationsConfig ` toml:"notifications" `
General GeneralConfig ` toml:"general" `
2026-03-29 16:54:32 +02:00
Library LibraryConfig ` toml:"library" `
2026-03-28 11:29:42 +01:00
}
type AuthConfig struct {
APIKey string ` toml:"api_key" `
APIURL string ` toml:"api_url" `
2026-05-15 16:26:43 +02:00
// Mirrors lists alternate base URLs the agent will fall back to when the
// primary api_url is unreachable. Ordered by preference. Refreshed at
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
// running agent survives a primary takedown without a new release.
Mirrors [ ] string ` toml:"mirrors" `
2026-03-28 11:29:42 +01:00
}
type AgentConfig struct {
ID string ` toml:"id" `
Name string ` toml:"name" `
}
type DownloadConfig struct {
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
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)
2026-05-15 17:29:22 +02:00
EnableUPnP bool ` toml:"enable_upnp" ` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
fix(security): CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.
Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.
URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.
State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.
ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.
Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.
Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
2026-05-15 18:48:59 +02:00
CORSExtraOrigins [ ] string ` toml:"cors_extra_origins" ` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
WebRTC WebRTCConfig ` toml:"webrtc" `
Transcode TranscodeConfig ` toml:"transcode" `
feat(vpn): split-tunnel torrent traffic through managed WireGuard
In-process userspace WireGuard tunnel (wireguard-go + gVisor netstack) for
the managed-VPN add-on. No root, no OS routing changes: only the embedded
anacrolix/torrent client's peer + tracker traffic is routed through the
tunnel, so the swarm and trackers see the VPN IP, not the user's home IP.
unarr's control plane (API, heartbeats) keeps using the normal net.
- internal/vpn: FetchConfig (GET /api/internal/agent/vpn-config, Bearer auth,
typed errors for disabled/not_provisioned/slot_on_device) + Up (parse .conf
→ uapi, CreateNetTUN, device Up) + DialContext/ListenPacket adapters.
- engine/torrent.go: when a tunnel is set, wire TrackerDialContext +
HTTPDialContext + TrackerListenPacket to netstack, DisableUTP, and
AddDialer(NetworkDialer{tcp, netstack}) for peer conns.
- config: downloads.vpn.enabled flag.
- daemon: bring up the tunnel before the torrent client; non-fatal on
failure (logs + downloads in the clear); slot_on_device warns the user.
- version bump 0.8.1 → 0.9.0.
Pairs with the web VPN add-on (dormant behind NEXT_PUBLIC_VPN_ENABLED).
Runtime-verified once a VPNResellers trial provides a live endpoint.
2026-05-20 23:16:54 +02:00
VPN VPNConfig ` toml:"vpn" `
}
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
// routes only the torrent client's peer/tracker traffic through an in-process
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
// add-on on the account; otherwise the daemon logs and downloads in the clear.
type VPNConfig struct {
Enabled bool ` toml:"enabled" `
2026-05-20 23:27:34 +02:00
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
// of fetching one from the web API. For self-hosted / personal-VPN testing:
// point it at a peer .conf from your own WireGuard server and the torrent
// client split-tunnels through it with no web/provider plumbing.
ConfigFile string ` toml:"config_file" `
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
}
// TranscodeConfig controls real-time transcoding for the in-browser player
// when source codecs aren't browser-decodable (HEVC, AV1, AC3, DTS, etc.).
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
// explicit paths via the library config).
type TranscodeConfig struct {
Enabled bool ` toml:"enabled" ` // master switch
HWAccel string ` toml:"hw_accel" ` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
Preset string ` toml:"preset" ` // libx264 preset; "veryfast" by default
VideoBitrate string ` toml:"video_bitrate" ` // e.g. "5M"
AudioBitrate string ` toml:"audio_bitrate" ` // e.g. "192k"
MaxHeight int ` toml:"max_height" ` // optional downscale cap (e.g. 720)
MaxConcurrent int ` toml:"max_concurrent" ` // safety cap on simultaneous transcoder processes
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.
2026-05-06 08:59:58 +02:00
}
// 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
2026-03-28 11:29:42 +01:00
}
type OrganizeConfig struct {
Enabled bool ` toml:"enabled" `
MoviesDir string ` toml:"movies_dir" `
TVShowsDir string ` toml:"tv_shows_dir" `
}
type DaemonConfig struct {
2026-04-08 18:50:59 +02:00
StatusInterval string ` toml:"status_interval" `
2026-03-28 11:29:42 +01:00
}
type NotificationsConfig struct {
Enabled bool ` toml:"enabled" `
}
type GeneralConfig struct {
Country string ` toml:"country" `
Locale string ` toml:"locale" `
NoColor bool ` toml:"no_color" `
}
2026-03-29 16:54:32 +02:00
type LibraryConfig struct {
2026-03-29 20:22:15 +02:00
ScanPath string ` toml:"scan_path" ` // remembered from last scan
Workers int ` toml:"workers" ` // concurrent ffprobe (default 8)
FFprobePath string ` toml:"ffprobe_path" ` // optional explicit path
2026-05-06 09:49:32 +02:00
FFmpegPath string ` toml:"ffmpeg_path" ` // optional explicit path (used by WebRTC streaming transcoder)
2026-03-29 20:22:15 +02:00
BackupDir string ` toml:"backup_dir" ` // for replaced files
AutoScan bool ` toml:"auto_scan" ` // enable daily auto-scan in daemon (default true)
ScanInterval string ` toml:"scan_interval" ` // e.g. "24h", "12h", "6h" (default "24h")
2026-04-10 16:35:12 +02:00
AllowDelete bool ` toml:"allow_delete" ` // allow web UI to request file deletion from disk
2026-03-29 16:54:32 +02:00
}
2026-05-08 17:21:53 +02:00
// Default returns a Config with sensible defaults. Used both for fresh
// installs (no config file yet) and as the baseline for Load — fields not
// present in the user's TOML keep their Default() value.
2026-03-28 11:29:42 +01:00
func Default ( ) Config {
return Config {
Auth : AuthConfig {
APIURL : "https://torrentclaw.com" ,
2026-05-15 16:26:43 +02:00
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
// on the server. Users can override with `unarr mirrors update`,
// which pulls the live list from /api/v1/mirrors.
Mirrors : [ ] string {
"https://torrentclaw.to" ,
} ,
2026-03-28 11:29:42 +01:00
} ,
Download : DownloadConfig {
PreferredMethod : "auto" ,
MaxConcurrent : 3 ,
2026-03-31 16:55:50 +02:00
StreamPort : 11818 ,
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.
2026-05-06 08:59:58 +02:00
WebRTC : WebRTCConfig {
2026-05-08 17:21:53 +02:00
Enabled : true ,
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.
2026-05-06 08:59:58 +02:00
Trackers : [ ] string { "wss://tracker.torrentclaw.com" } ,
STUNServers : [ ] string { "stun:stun.l.google.com:19302" , "stun:stun1.l.google.com:19302" } ,
} ,
feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
Transcode : TranscodeConfig {
Enabled : true ,
HWAccel : "auto" ,
Preset : "veryfast" ,
AudioBitrate : "192k" ,
MaxConcurrent : 2 ,
} ,
2026-03-28 11:29:42 +01:00
} ,
Organize : OrganizeConfig {
Enabled : true ,
} ,
2026-04-08 18:50:59 +02:00
Daemon : DaemonConfig { } ,
2026-03-28 11:29:42 +01:00
Notifications : NotificationsConfig {
Enabled : true ,
} ,
General : GeneralConfig {
Country : "US" ,
Locale : "en" ,
} ,
2026-03-29 20:22:15 +02:00
Library : LibraryConfig {
AutoScan : true ,
ScanInterval : "24h" ,
Workers : 8 ,
} ,
2026-03-28 11:29:42 +01:00
}
}
// Load reads config from the default or specified path.
// Falls back to defaults for any missing values.
// If the file does not exist, returns defaults without error.
func Load ( path string ) ( Config , error ) {
if path == "" {
path = FilePath ( )
}
cfg := Default ( )
data , err := os . ReadFile ( path )
if err != nil {
if os . IsNotExist ( err ) {
return cfg , nil
}
return cfg , fmt . Errorf ( "read config: %w" , err )
}
2026-05-08 17:21:53 +02:00
meta , err := toml . Decode ( string ( data ) , & cfg )
if err != nil {
2026-03-28 11:29:42 +01:00
return cfg , fmt . Errorf ( "parse config: %w" , err )
}
2026-05-08 17:21:53 +02:00
applyDefaults ( & cfg , meta )
return cfg , nil
}
// applyDefaults fills in sensible defaults for keys that the user did not
// define in the TOML file. We use MetaData (rather than zero-value checks) so
// that explicitly setting a field to its zero value (e.g. `enabled = false`)
// is respected — only truly missing keys get defaulted. This lets a fresh
// install work out of the box for streaming without forcing every user to
// edit the TOML, while still letting power users disable features.
func applyDefaults ( cfg * Config , meta toml . MetaData ) {
if ! meta . IsDefined ( "auth" , "api_url" ) {
2026-03-28 11:29:42 +01:00
cfg . Auth . APIURL = "https://torrentclaw.com"
}
2026-05-15 16:26:43 +02:00
if ! meta . IsDefined ( "auth" , "mirrors" ) {
cfg . Auth . Mirrors = [ ] string { "https://torrentclaw.to" }
}
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "preferred_method" ) {
2026-03-28 11:29:42 +01:00
cfg . Download . PreferredMethod = "auto"
}
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "max_concurrent" ) {
2026-03-28 11:29:42 +01:00
cfg . Download . MaxConcurrent = 3
}
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "stream_port" ) {
cfg . Download . StreamPort = 11818
}
if ! meta . IsDefined ( "general" , "country" ) {
2026-03-28 11:29:42 +01:00
cfg . General . Country = "US"
}
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "webrtc" , "enabled" ) {
cfg . Download . WebRTC . Enabled = true
2026-03-31 16:55:50 +02:00
}
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "webrtc" , "trackers" ) {
cfg . Download . WebRTC . Trackers = [ ] string { "wss://tracker.torrentclaw.com" }
}
if ! meta . IsDefined ( "downloads" , "webrtc" , "stun_servers" ) {
cfg . Download . WebRTC . STUNServers = [ ] string {
"stun:stun.l.google.com:19302" ,
"stun:stun1.l.google.com:19302" ,
2026-05-07 10:13:45 +02:00
}
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.
2026-05-06 08:59:58 +02:00
}
2026-03-28 11:29:42 +01:00
2026-05-08 17:21:53 +02:00
if ! meta . IsDefined ( "downloads" , "transcode" , "enabled" ) {
cfg . Download . Transcode . Enabled = true
}
if ! meta . IsDefined ( "downloads" , "transcode" , "hw_accel" ) {
cfg . Download . Transcode . HWAccel = "auto"
}
if ! meta . IsDefined ( "downloads" , "transcode" , "preset" ) {
cfg . Download . Transcode . Preset = "veryfast"
}
if ! meta . IsDefined ( "downloads" , "transcode" , "audio_bitrate" ) {
cfg . Download . Transcode . AudioBitrate = "192k"
}
if ! meta . IsDefined ( "downloads" , "transcode" , "max_concurrent" ) {
cfg . Download . Transcode . MaxConcurrent = 2
}
2026-03-28 11:29:42 +01:00
}
// Save writes config to the default or specified path using atomic write.
func Save ( cfg Config , path string ) error {
if path == "" {
path = FilePath ( )
}
dir := filepath . Dir ( path )
if err := os . MkdirAll ( dir , 0 o755 ) ; err != nil {
return fmt . Errorf ( "create config dir: %w" , err )
}
var buf strings . Builder
encoder := toml . NewEncoder ( & buf )
if err := encoder . Encode ( cfg ) ; err != nil {
return fmt . Errorf ( "encode config: %w" , err )
}
// Atomic write: write to temp, then rename
tmpPath := path + ".tmp"
if err := os . WriteFile ( tmpPath , [ ] byte ( buf . String ( ) ) , 0 o600 ) ; err != nil {
return fmt . Errorf ( "write temp config: %w" , err )
}
if err := os . Rename ( tmpPath , path ) ; err != nil {
2026-03-30 23:34:36 +02:00
os . Remove ( tmpPath )
2026-03-28 11:29:42 +01:00
return fmt . Errorf ( "rename config: %w" , err )
}
return nil
}
// ParseSpeed parses a human-readable speed string into bytes/s.
// Supports: "10MB", "500KB", "1GB", "1024", "0" (unlimited).
func ParseSpeed ( s string ) ( int64 , error ) {
s = strings . TrimSpace ( s )
if s == "" || s == "0" {
return 0 , nil
}
s = strings . ToUpper ( s )
multiplier := int64 ( 1 )
switch {
case strings . HasSuffix ( s , "GB" ) :
multiplier = 1024 * 1024 * 1024
s = strings . TrimSuffix ( s , "GB" )
case strings . HasSuffix ( s , "MB" ) :
multiplier = 1024 * 1024
s = strings . TrimSuffix ( s , "MB" )
case strings . HasSuffix ( s , "KB" ) :
multiplier = 1024
s = strings . TrimSuffix ( s , "KB" )
}
n , err := strconv . ParseFloat ( strings . TrimSpace ( s ) , 64 )
if err != nil {
return 0 , fmt . Errorf ( "invalid speed %q: %w" , s , err )
}
if n < 0 {
return 0 , fmt . Errorf ( "speed cannot be negative: %s" , s )
}
return int64 ( n * float64 ( multiplier ) ) , nil
}
// ApplyEnvOverrides applies UNARR_* environment variable overrides.
func ( c * Config ) ApplyEnvOverrides ( ) {
if v := os . Getenv ( "UNARR_API_KEY" ) ; v != "" {
c . Auth . APIKey = v
}
if v := os . Getenv ( "UNARR_API_URL" ) ; v != "" {
c . Auth . APIURL = v
}
if v := os . Getenv ( "UNARR_COUNTRY" ) ; v != "" {
c . General . Country = v
}
if v := os . Getenv ( "UNARR_DOWNLOAD_DIR" ) ; v != "" {
c . Download . Dir = v
}
}
// dangerousPaths are system-critical directories that should never be used as
// download or organize targets (per platform).
var dangerousPaths = func ( ) map [ string ] bool {
m := map [ string ] bool { }
// Unix
for _ , p := range [ ] string {
"/" , "/bin" , "/sbin" , "/usr" , "/lib" , "/lib64" , "/boot" , "/dev" , "/proc" , "/sys" ,
"/etc" , "/var" , "/tmp" , "/root" ,
// macOS
"/System" , "/Library" , "/private" , "/private/etc" , "/private/tmp" , "/private/var" ,
} {
m [ p ] = true
}
// Windows
if runtime . GOOS == "windows" {
for _ , drive := range [ ] string { "C" , "D" } {
for _ , p := range [ ] string {
drive + ` :\ ` ,
drive + ` :\Windows ` ,
drive + ` :\Windows\System32 ` ,
drive + ` :\Program Files ` ,
drive + ` :\Program Files (x86) ` ,
} {
m [ filepath . Clean ( p ) ] = true
}
}
}
return m
} ( )
// ValidatePaths checks that configured directories are safe to write to.
// Returns an error if any path points to a system directory or the user's
// home directory root (must use a subdirectory).
func ( c * Config ) ValidatePaths ( ) error {
home , _ := os . UserHomeDir ( )
check := func ( label , dir string ) error {
if dir == "" {
return nil
}
abs , err := filepath . Abs ( dir )
if err != nil {
return fmt . Errorf ( "%s: invalid path %q: %w" , label , dir , err )
}
clean := filepath . Clean ( abs )
if dangerousPaths [ clean ] {
return fmt . Errorf ( "%s: refusing to use system directory %q" , label , clean )
}
// Block home root — require a subdirectory
if home != "" && clean == filepath . Clean ( home ) {
return fmt . Errorf ( "%s: use a subdirectory of your home, not %q itself" , label , clean )
}
// Block hidden dirs under home (e.g. ~/.ssh, ~/.gnupg)
if home != "" && strings . HasPrefix ( clean , filepath . Clean ( home ) + string ( filepath . Separator ) ) {
rel , _ := filepath . Rel ( home , clean )
first := strings . SplitN ( rel , string ( filepath . Separator ) , 2 ) [ 0 ]
if strings . HasPrefix ( first , "." ) && first != ".local" && first != ".config" {
return fmt . Errorf ( "%s: refusing to use hidden directory %q" , label , clean )
}
}
return nil
}
if err := check ( "downloads.dir" , c . Download . Dir ) ; err != nil {
return err
}
if err := check ( "organize.movies_dir" , c . Organize . MoviesDir ) ; err != nil {
return err
}
if err := check ( "organize.tv_shows_dir" , c . Organize . TVShowsDir ) ; err != nil {
return err
}
return nil
}