Adds the ffmpeg-binary half of the resolution stack so the upcoming
WebRTC streaming transcoder (Fase 3.3) has a single point of entry.
Search order matches ResolveFFprobe so operators don't need to learn a
second mental model:
1. Explicit path (--ffmpeg flag / library.ffmpeg_path config)
2. FFMPEG_PATH env var
3. "ffmpeg" on PATH (system install)
4. Adjacent to the unarr executable (release tarball bundles it here —
this is the preferred path; see Fase 3.2 goreleaser changes)
5. Cache dir (sibling of the cached ffprobe binary)
6. Auto-download from ffbinaries.com (~70MB) as last resort
Includes:
- internal/library/mediainfo/ffmpeg.go — ResolveFFmpeg + actionable
Docker / non-Docker error messages
- internal/library/mediainfo/ffmpeg_download.go — DownloadFFmpeg, reuses
ffprobePlatformKey + ffprobeAPIClient + ffprobeDLClient + extractFromZip
helpers; bumps maxZipSize to 200MB (ffmpeg static is ~70-100MB)
- internal/config: LibraryConfig.FFmpegPath toml field for explicit paths
- 4 unit tests: explicit OK, explicit missing, env var, sibling cache path
Tarball bundling and the actual transcoding pipeline land in the next
two commits.
342 lines
10 KiB
Go
342 lines
10 KiB
Go
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"`
|
|
}
|
|
|
|
type AgentConfig struct {
|
|
ID string `toml:"id"`
|
|
Name string `toml:"name"`
|
|
}
|
|
|
|
type DownloadConfig struct {
|
|
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)
|
|
WebRTC WebRTCConfig `toml:"webrtc"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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 WebRTC 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.
|
|
func Default() Config {
|
|
return Config{
|
|
Auth: AuthConfig{
|
|
APIURL: "https://torrentclaw.com",
|
|
},
|
|
Download: DownloadConfig{
|
|
PreferredMethod: "auto",
|
|
MaxConcurrent: 3,
|
|
StreamPort: 11818,
|
|
WebRTC: WebRTCConfig{
|
|
Enabled: false,
|
|
Trackers: []string{"wss://tracker.torrentclaw.com"},
|
|
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
|
|
},
|
|
},
|
|
Organize: OrganizeConfig{
|
|
Enabled: true,
|
|
},
|
|
Daemon: DaemonConfig{},
|
|
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)
|
|
}
|
|
|
|
if err := toml.Unmarshal(data, &cfg); err != nil {
|
|
return cfg, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
// Re-apply defaults for zero values that should have defaults
|
|
if cfg.Auth.APIURL == "" {
|
|
cfg.Auth.APIURL = "https://torrentclaw.com"
|
|
}
|
|
if cfg.Download.PreferredMethod == "" {
|
|
cfg.Download.PreferredMethod = "auto"
|
|
}
|
|
if cfg.Download.MaxConcurrent == 0 {
|
|
cfg.Download.MaxConcurrent = 3
|
|
}
|
|
if cfg.General.Country == "" {
|
|
cfg.General.Country = "US"
|
|
}
|
|
if cfg.Download.StreamPort == 0 {
|
|
cfg.Download.StreamPort = 11818
|
|
}
|
|
// Re-apply WebRTC defaults only when the user enabled WebRTC but didn't
|
|
// supply trackers/STUN — leave both empty if disabled to keep config diffs clean.
|
|
if cfg.Download.WebRTC.Enabled {
|
|
if len(cfg.Download.WebRTC.Trackers) == 0 {
|
|
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
|
|
}
|
|
if len(cfg.Download.WebRTC.STUNServers) == 0 {
|
|
cfg.Download.WebRTC.STUNServers = []string{
|
|
"stun:stun.l.google.com:19302",
|
|
"stun:stun1.l.google.com:19302",
|
|
}
|
|
}
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// 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
|
|
}
|