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:
commit
29cf0a0126
85 changed files with 10178 additions and 0 deletions
288
internal/config/config.go
Normal file
288
internal/config/config.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue