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
Transcode TranscodeConfig ` toml:"transcode" `
2026-05-26 23:39:02 +02:00
HLSCache HLSCacheConfig ` toml:"hls_cache" `
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" `
2026-05-26 20:39:57 +02:00
Funnel FunnelConfig ` toml:"funnel" `
}
2026-05-26 23:39:02 +02:00
// HLSCacheConfig controls the persistent HLS segment cache. A completed encode
// is kept on disk so a second play of the same file at the same quality skips
// ffmpeg entirely. Old entries are evicted (LRU) once the cache exceeds the
// size budget. Enabled by default — disable to save disk space at the cost of
// re-encoding every play.
type HLSCacheConfig struct {
Enabled bool ` toml:"enabled" ` // default: true
SizeGB int ` toml:"size_gb" ` // size budget in gigabytes; default: 5; minimum: 1
Dir string ` toml:"dir" ` // override storage path; default: ~/.cache/unarr/hls-cache
}
2026-05-26 20:39:57 +02:00
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
// daemon's HLS server over a public HTTPS hostname (https://<random>.try
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
// from this daemon across any network without Tailscale or a public IP —
// the cost is that bytes proxy through CloudFlare's network. Off by default.
type FunnelConfig struct {
Enabled bool ` toml:"enabled" `
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
}
// 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 {
2026-05-27 10:46:03 +02:00
Enabled bool ` toml:"enabled" ` // master switch
HWAccel string ` toml:"hw_accel" ` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string ` toml:"preset" `
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
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
}
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-05-26 23:39:02 +02:00
// AutoUpgrade gates the daemon's response to a server-flagged upgrade
// (set via the "Force update" button on the web). When true the daemon
// downloads + replaces the binary in-place and exits so the service
// supervisor respawns on the new version. When false the daemon only
// logs "new version available" and the operator must run `unarr update`
// manually. Default: true. Available since unarr 0.9.6.
AutoUpgrade * bool ` toml:"auto_upgrade" `
}
// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
// when the user has not set it explicitly. Pointer-vs-bool because Go's
// zero-value bool would collapse "unset" and "false" together.
func ( d DaemonConfig ) AutoUpgradeEnabled ( ) bool {
if d . AutoUpgrade == nil {
return true
}
return * d . AutoUpgrade
2026-03-28 11:29:42 +01:00
}
2026-05-26 23:39:02 +02:00
func boolPtr ( v bool ) * bool { return & v }
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-26 18:04:35 +02:00
FFmpegPath string ` toml:"ffmpeg_path" ` // optional explicit path (used by the HLS 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(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" ,
2026-05-27 10:46:03 +02:00
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset : "" ,
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
AudioBitrate : "192k" ,
MaxConcurrent : 2 ,
} ,
2026-05-26 20:39:57 +02:00
Funnel : FunnelConfig {
// On by default so headless installs (NAS / Docker) get cross-network
// HTTPS playback without anyone having to terminal in. Users who
// don't want bytes proxied through CloudFlare can opt out with
// `unarr funnel off` (sets enabled=false in the TOML).
Enabled : true ,
} ,
2026-05-26 23:39:02 +02:00
HLSCache : HLSCacheConfig {
// On by default — second play of a recently watched file at the
// same quality skips ffmpeg (instant start, near-zero CPU).
// Users can opt out (hls_cache.enabled=false) or shrink the
// budget (hls_cache.size_gb) when disk is tight.
Enabled : true ,
SizeGB : 5 ,
} ,
} ,
Daemon : DaemonConfig {
// Pointer-to-true so Default() round-trips through TOML marshal
// as `auto_upgrade = true` instead of an omitted key — keeps the
// freshly-written config aligned with what README documents.
AutoUpgrade : boolPtr ( true ) ,
2026-03-28 11:29:42 +01:00
} ,
Organize : OrganizeConfig {
Enabled : true ,
} ,
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" , "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" ) {
2026-05-27 10:46:03 +02:00
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg . Download . Transcode . Preset = ""
2026-05-08 17:21:53 +02:00
}
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-05-26 20:39:57 +02:00
// NOTE: Funnel default-ON only applies to fresh installs (no config file →
// Default() returns Funnel.Enabled=true straight off). When an existing
// config file lacks `[downloads.funnel]` entirely we intentionally do NOT
// flip it on here — that would silently route an upgraded operator's
// traffic through CloudFlare without their consent. They opt in with
// `unarr funnel on` whenever they're ready.
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
}