feat: initial commit — unarr CLI

Search, inspect, stream, and download torrents from the terminal.
Replaces the entire *arr stack with a single binary.
This commit is contained in:
Deivid Soto 2026-03-28 11:29:42 +01:00
commit 29cf0a0126
85 changed files with 10178 additions and 0 deletions

288
internal/config/config.go Normal file
View file

@ -0,0 +1,288 @@
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"`
}
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"`
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
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
TVShowsDir string `toml:"tv_shows_dir"`
}
type DaemonConfig struct {
PollInterval string `toml:"poll_interval"`
HeartbeatInterval string `toml:"heartbeat_interval"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
type GeneralConfig struct {
Country string `toml:"country"`
Locale string `toml:"locale"`
NoColor bool `toml:"no_color"`
}
// Default returns a Config with sensible defaults.
func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
},
Organize: OrganizeConfig{
Enabled: true,
},
Daemon: DaemonConfig{
PollInterval: "30s",
HeartbeatInterval: "30s",
},
Notifications: NotificationsConfig{
Enabled: true,
},
General: GeneralConfig{
Country: "US",
Locale: "en",
},
}
}
// 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"
}
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
}

View file

@ -0,0 +1,202 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("default APIURL = %q, want https://torrentclaw.com", cfg.Auth.APIURL)
}
if cfg.Download.PreferredMethod != "auto" {
t.Errorf("default PreferredMethod = %q, want auto", cfg.Download.PreferredMethod)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("default MaxConcurrent = %d, want 3", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("default Country = %q, want US", cfg.General.Country)
}
if cfg.Daemon.HeartbeatInterval != "30s" {
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval)
}
}
func TestLoadMissingFile(t *testing.T) {
cfg, err := Load("/nonexistent/path/config.toml")
if err != nil {
t.Fatalf("Load nonexistent should return defaults, got err: %v", err)
}
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("missing file should return default APIURL, got %q", cfg.Auth.APIURL)
}
}
func TestSaveAndLoad(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
cfg := Default()
cfg.Auth.APIKey = "tc_test123"
cfg.Auth.APIURL = "https://custom.example.com"
cfg.General.Country = "ES"
cfg.Download.Dir = "/media/downloads"
cfg.Agent.ID = "agent-uuid-123"
cfg.Agent.Name = "Test Machine"
if err := Save(cfg, path); err != nil {
t.Fatalf("Save failed: %v", err)
}
// File should exist
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("config file was not created")
}
// No .tmp file left behind
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Error("temp file was not cleaned up")
}
// Load it back
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if loaded.Auth.APIKey != "tc_test123" {
t.Errorf("APIKey = %q, want tc_test123", loaded.Auth.APIKey)
}
if loaded.Auth.APIURL != "https://custom.example.com" {
t.Errorf("APIURL = %q, want https://custom.example.com", loaded.Auth.APIURL)
}
if loaded.General.Country != "ES" {
t.Errorf("Country = %q, want ES", loaded.General.Country)
}
if loaded.Download.Dir != "/media/downloads" {
t.Errorf("Dir = %q, want /media/downloads", loaded.Download.Dir)
}
if loaded.Agent.ID != "agent-uuid-123" {
t.Errorf("AgentID = %q, want agent-uuid-123", loaded.Agent.ID)
}
if loaded.Agent.Name != "Test Machine" {
t.Errorf("AgentName = %q, want Test Machine", loaded.Agent.Name)
}
}
func TestLoadPreservesDefaults(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// Write partial config (only auth section)
os.WriteFile(path, []byte(`[auth]
api_key = "tc_partial"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Auth.APIKey != "tc_partial" {
t.Errorf("APIKey = %q, want tc_partial", cfg.Auth.APIKey)
}
// Defaults should be preserved for missing sections
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("APIURL should default, got %q", cfg.Auth.APIURL)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("MaxConcurrent should default to 3, got %d", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("Country should default to US, got %q", cfg.General.Country)
}
}
func TestApplyEnvOverrides(t *testing.T) {
cfg := Default()
t.Setenv("UNARR_API_KEY", "tc_env_key")
t.Setenv("UNARR_API_URL", "https://env.example.com")
t.Setenv("UNARR_COUNTRY", "DE")
t.Setenv("UNARR_DOWNLOAD_DIR", "/env/downloads")
cfg.ApplyEnvOverrides()
if cfg.Auth.APIKey != "tc_env_key" {
t.Errorf("APIKey = %q, want tc_env_key", cfg.Auth.APIKey)
}
if cfg.Auth.APIURL != "https://env.example.com" {
t.Errorf("APIURL = %q, want https://env.example.com", cfg.Auth.APIURL)
}
if cfg.General.Country != "DE" {
t.Errorf("Country = %q, want DE", cfg.General.Country)
}
if cfg.Download.Dir != "/env/downloads" {
t.Errorf("Dir = %q, want /env/downloads", cfg.Download.Dir)
}
}
func TestSaveCreatesDirectory(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "nested", "deep", "config.toml")
cfg := Default()
if err := Save(cfg, path); err != nil {
t.Fatalf("Save with nested dir failed: %v", err)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("config file was not created in nested dir")
}
}
func TestParseSpeed(t *testing.T) {
tests := []struct {
input string
want int64
}{
{"0", 0},
{"", 0},
{"10MB", 10 * 1024 * 1024},
{"500KB", 500 * 1024},
{"1GB", 1024 * 1024 * 1024},
{"1.5MB", int64(1.5 * 1024 * 1024)},
{"10mb", 10 * 1024 * 1024},
{"1024", 1024},
}
for _, tt := range tests {
got, err := ParseSpeed(tt.input)
if err != nil {
t.Errorf("ParseSpeed(%q) error: %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("ParseSpeed(%q) = %d, want %d", tt.input, got, tt.want)
}
}
// Error cases
if _, err := ParseSpeed("abc"); err == nil {
t.Error("ParseSpeed(\"abc\") should error")
}
if _, err := ParseSpeed("-5MB"); err == nil {
t.Error("ParseSpeed(\"-5MB\") should error")
}
}
func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
os.WriteFile(path, []byte(`not valid toml [[[`), 0o644)
_, err := Load(path)
if err == nil {
t.Error("expected error for invalid TOML, got nil")
}
}

View file

@ -0,0 +1,100 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestValidatePaths_Dangerous(t *testing.T) {
dangerous := []string{"/", "/etc", "/bin", "/sbin", "/usr", "/lib", "/lib64",
"/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "/root",
"/System", "/Library", "/private"}
for _, d := range dangerous {
// Test all three path fields
for _, field := range []string{"download", "movies", "tvshows"} {
cfg := Default()
switch field {
case "download":
cfg.Download.Dir = d
case "movies":
cfg.Organize.MoviesDir = d
case "tvshows":
cfg.Organize.TVShowsDir = d
}
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject %s=%q", field, d)
}
}
}
}
func TestValidatePaths_HomeRoot(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = home
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject home root %q", home)
}
}
func TestValidatePaths_HiddenDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = filepath.Join(home, ".ssh")
if err := cfg.ValidatePaths(); err == nil {
t.Error("ValidatePaths() should reject ~/.ssh")
}
}
func TestValidatePaths_Valid(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
valid := []string{
filepath.Join(home, "Downloads"),
filepath.Join(home, "Media"),
filepath.Join(home, "Media", "Movies"),
"/mnt/storage/downloads",
}
for _, d := range valid {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should accept %q, got: %v", d, err)
}
}
}
func TestValidatePaths_AllowedHiddenDirs(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
// .local and .config are whitelisted
allowed := []string{
filepath.Join(home, ".local", "share", "unarr"),
filepath.Join(home, ".config", "unarr"),
}
for _, d := range allowed {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should allow %q, got: %v", d, err)
}
}
}

58
internal/config/paths.go Normal file
View file

@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"runtime"
)
const appName = "unarr"
// Dir returns the configuration directory following XDG conventions.
// - Linux: ~/.config/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %APPDATA%/unarr
//
// Overridable via UNARR_CONFIG_DIR env var.
func Dir() string {
if d := os.Getenv("UNARR_CONFIG_DIR"); d != "" {
return d
}
switch runtime.GOOS {
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", appName)
case "windows":
return filepath.Join(os.Getenv("APPDATA"), appName)
default: // linux, freebsd, etc.
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", appName)
}
}
// FilePath returns the full path to the config file.
func FilePath() string {
return filepath.Join(Dir(), "config.toml")
}
// DataDir returns the data directory for logs, cache, etc.
// - Linux: ~/.local/share/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %LOCALAPPDATA%/unarr
func DataDir() string {
switch runtime.GOOS {
case "darwin":
return Dir() // macOS uses same dir for config and data
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), appName)
default:
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", appName)
}
}

View file

@ -0,0 +1,53 @@
package config
import (
"os"
"strings"
"testing"
)
func TestDir(t *testing.T) {
dir := Dir()
if dir == "" {
t.Error("Dir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("Dir() = %q, should contain 'unarr'", dir)
}
}
func TestFilePath(t *testing.T) {
path := FilePath()
if !strings.HasSuffix(path, "config.toml") {
t.Errorf("FilePath() = %q, should end with config.toml", path)
}
}
func TestDataDir(t *testing.T) {
dir := DataDir()
if dir == "" {
t.Error("DataDir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("DataDir() = %q, should contain 'unarr'", dir)
}
}
func TestDirOverrideEnv(t *testing.T) {
t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
dir := Dir()
if dir != "/custom/path" {
t.Errorf("Dir() with env = %q, want /custom/path", dir)
}
}
func TestDirXDGOverride(t *testing.T) {
// Clear the custom env so XDG takes effect
os.Unsetenv("UNARR_CONFIG_DIR")
t.Setenv("XDG_CONFIG_HOME", "/xdg/config")
dir := Dir()
if dir != "/xdg/config/unarr" {
t.Errorf("Dir() with XDG = %q, want /xdg/config/unarr", dir)
}
}