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" `
}
// 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
}