unarr/internal/config/config.go

458 lines
16 KiB
Go
Raw Normal View History

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"`
Library LibraryConfig `toml:"library"`
}
type AuthConfig struct {
APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"`
// 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"`
}
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)
fix(security): UPnP opt-in, bounded SSE reader, signed self-update Phase 2 security audit follow-up. Three independent hardenings against the unauthenticated daemon surface, the long-lived agent SSE stream and the self-update channel. UPnP is now opt-in. The stream port + /hls endpoints have no auth, so publishing them on the WAN via the gateway was a default that exposed active downloads to anyone scanning the operator's external IP. New config downloads.enable_upnp (default false) gates the mapping; LAN and Tailscale clients continue to work unchanged. A startup log makes the new default visible. The agent SSE reader now uses a bounded bufio.Scanner instead of an unbounded ReadString. A hostile or buggy server can no longer grow daemon memory by streaming a single line forever or by emitting unbounded data: continuation lines — both are capped at 256 KiB and 1 MiB respectively, and an error is surfaced so SignalLoop reconnects. Self-update now verifies an ed25519 signature over checksums.txt when the binary was built with a release public key embedded (injected via goreleaser ldflags from RELEASE_SIGNING_PUBKEY). The companion scripts/sign-checksums runs in the release workflow when both the public-key variable and the private-key secret are present, uploading checksums.txt.sig next to the existing checksums file. Builds without the embedded key continue to update with SHA256-only verification; a --allow-unsigned flag is provided so users on a signed build can still install pre-signing releases or recover from an accidental unsigned release. A new scripts/gen-release-key helper documents the one-time keypair generation procedure required before flipping signing on.
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"`
HLSCache HLSCacheConfig `toml:"hls_cache"`
VPN VPNConfig `toml:"vpn"`
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
2026-05-26 20:39:57 +02:00
Funnel FunnelConfig `toml:"funnel"`
}
// 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
}
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
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"`
}
// 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"`
// 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
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
TVShowsDir string `toml:"tv_shows_dir"`
}
type DaemonConfig struct {
StatusInterval string `toml:"status_interval"`
// 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
}
func boolPtr(v bool) *bool { return &v }
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
type GeneralConfig struct {
Country string `toml:"country"`
Locale string `toml:"locale"`
NoColor bool `toml:"no_color"`
}
type LibraryConfig struct {
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
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
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")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
}
// 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.
func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
// 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",
},
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
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",
Preset: "veryfast",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
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,
},
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),
},
Organize: OrganizeConfig{
Enabled: true,
},
Notifications: NotificationsConfig{
Enabled: true,
},
General: GeneralConfig{
Country: "US",
Locale: "en",
},
Library: LibraryConfig{
AutoScan: true,
ScanInterval: "24h",
Workers: 8,
},
}
}
// 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)
}
meta, err := toml.Decode(string(data), &cfg)
if err != nil {
return cfg, fmt.Errorf("parse config: %w", err)
}
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") {
cfg.Auth.APIURL = "https://torrentclaw.com"
}
if !meta.IsDefined("auth", "mirrors") {
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
}
if !meta.IsDefined("downloads", "preferred_method") {
cfg.Download.PreferredMethod = "auto"
}
if !meta.IsDefined("downloads", "max_concurrent") {
cfg.Download.MaxConcurrent = 3
}
if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("general", "country") {
cfg.General.Country = "US"
}
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
}
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
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.
}
// 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, 0o755); 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()), 0o600); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
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
}