diff --git a/README.md b/README.md index 728e520..1419ce7 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,16 @@ Search 30+ torrent sources, inspect torrent quality, discover popular content, f ## Installation -### Go install +### Quick install (Linux/macOS) ```bash -go install github.com/torrentclaw/torrentclaw-cli/cmd/unarr@latest +curl -fsSL https://torrentclaw.com/install.sh | sh +``` + +### PowerShell (Windows) + +```powershell +irm https://torrentclaw.com/install.ps1 | iex ``` ### Homebrew (macOS/Linux) @@ -26,6 +32,12 @@ go install github.com/torrentclaw/torrentclaw-cli/cmd/unarr@latest brew install torrentclaw/tap/unarr ``` +### Go install + +```bash +go install github.com/torrentclaw/torrentclaw-cli/cmd/unarr@latest +``` + ### GitHub Releases Download prebuilt binaries for Linux, macOS, and Windows from [GitHub Releases](https://github.com/torrentclaw/torrentclaw-cli/releases). @@ -41,117 +53,184 @@ make build ## Quick Start ```bash -# Configure (first time) -unarr config +# 1. Run the setup wizard (opens browser for API key) +unarr setup -# Search for content +# 2. Search for content unarr search "breaking bad" --type show --quality 1080p -# Inspect a torrent -unarr inspect "magnet:?xt=urn:btih:ABC123&dn=Movie.2023.1080p.BluRay.x265" - -# Popular content -unarr popular --limit 20 - -# Recently added -unarr recent - -# Where to watch (streaming + torrents) -unarr watch "oppenheimer" --country ES - -# System statistics -unarr stats +# 3. Start the download daemon +unarr start ``` ## Commands -### Search +### Getting Started -Search the unarr catalog with advanced filters. +| Command | Description | +|---------|-------------| +| `unarr setup` | First-time configuration wizard (API key, download dir, method) | +| `unarr config` | Edit configuration interactively | + +### Search & Discovery + +| Command | Description | +|---------|-------------| +| `unarr search ` | Search for movies and TV shows with advanced filters | +| `unarr inspect ` | TrueSpec analysis — quality, codec, seed health | +| `unarr popular` | Show popular movies and TV shows | +| `unarr recent` | Show recently added content | +| `unarr watch ` | Find where to watch — streaming + torrents | + +### Downloads & Streaming + +| Command | Description | +|---------|-------------| +| `unarr download ` | One-shot download (no daemon needed) | +| `unarr stream ` | Stream a torrent directly to mpv/vlc/browser | + +### Daemon Management + +| Command | Description | +|---------|-------------| +| `unarr start` | Start the download daemon (foreground) | +| `unarr stop` | How to stop the running daemon | +| `unarr status` | Show daemon status and active downloads | +| `unarr daemon install` | Install as system service (systemd/launchd) | +| `unarr daemon uninstall` | Remove the system service | + +### System & Diagnostics + +| Command | Description | +|---------|-------------| +| `unarr stats` | Show catalog statistics | +| `unarr doctor` | Diagnose configuration and connectivity | +| `unarr self-update` | Update unarr to the latest version | +| `unarr version` | Show version info | +| `unarr completion ` | Generate shell completion scripts | + +--- + +## Search + +Search the catalog with advanced filters. Results include quality scores, seed health, and metadata from 30+ sources. ```bash unarr search "inception" --sort seeders --min-rating 7 --lang es +unarr search "breaking bad" --type show --quality 1080p +unarr search "matrix" --json | jq '.results[].title' ``` **Filters:** -| Flag | Description | Example | -|------|-------------|---------| +| Flag | Description | Values | +|------|-------------|--------| | `--type` | Content type | `movie`, `show` | | `--quality` | Video quality | `480p`, `720p`, `1080p`, `2160p` | -| `--lang` | Audio language (ISO 639) | `es`, `en`, `fr` | -| `--genre` | Genre filter | `Action`, `Comedy`, `Drama` | +| `--lang` | Audio language (ISO 639) | `es`, `en`, `fr`, `de`, ... | +| `--genre` | Genre | `Action`, `Comedy`, `Drama`, `Horror`, ... | | `--year-min` | Minimum release year | `2020` | | `--year-max` | Maximum release year | `2026` | -| `--min-rating` | Minimum IMDb/TMDb rating | `7` | +| `--min-rating` | Minimum IMDb/TMDb rating | `0`-`10` | | `--sort` | Sort order | `relevance`, `seeders`, `year`, `rating`, `added` | -| `--limit` | Results per page (1-50) | `10` | -| `--page` | Page number | `2` | -| `--country` | Country for streaming info | `US`, `ES` | +| `--limit` | Results per page | `1`-`50` | +| `--page` | Page number | `1`, `2`, ... | +| `--country` | Country for streaming info | `US`, `ES`, `GB`, ... | -### Inspect +## Inspect -TrueSpec analysis — parse a torrent and show detailed specs. +TrueSpec analysis — parse a torrent and show detailed quality specs. ```bash unarr inspect "Oppenheimer.2023.1080p.BluRay.x265" +unarr inspect abc123def456abc123def456abc123def456abc1 +unarr inspect "magnet:?xt=urn:btih:ABC123&dn=Movie.2023.1080p" ``` -Accepts magnet URIs, 40-character info hashes, or torrent names. +Accepts magnet URIs, 40-character info hashes, or torrent file names. Shows quality, codec, size, seeds, languages, source, quality score, health, and alternatives. -Output includes: quality, codec, size, seeds, languages, source, quality score, and health. - -### Watch +## Watch Find where to watch — streaming services alongside torrent options. ```bash unarr watch "oppenheimer" --country ES +unarr watch "breaking bad" --json ``` Shows legal streaming options first (subscription, free, rent, buy), then torrent alternatives. -### Popular +## Stream -Show trending content ranked by community engagement. +Stream a torrent directly to a media player without waiting for the full download. ```bash -unarr popular --limit 20 +unarr stream abc123def456abc123def456abc123def456abc1 +unarr stream "magnet:?xt=urn:btih:..." --port 8080 +unarr stream --player mpv +unarr stream --no-open # just print the URL ``` -### Recent +Downloads pieces sequentially and serves the video over a local HTTP server. Auto-detects mpv, vlc, or your default browser. -Show the most recently added content. +## Download + +One-shot download by info hash or magnet link (no daemon required). ```bash -unarr recent --limit 20 +unarr download abc123def456abc123def456abc123def456abc1 +unarr download "magnet:?xt=urn:btih:..." --method torrent ``` -### Stats +## Daemon -Display unarr system statistics. +The daemon receives download tasks from the web dashboard and executes them automatically. ```bash -unarr stats +# Start in foreground (Ctrl+C to stop) +unarr start + +# Or install as a system service (auto-starts on boot) +unarr daemon install + +# Check status +unarr status + +# Uninstall the service +unarr daemon uninstall ``` -### Config +The daemon connects via WebSocket for instant task delivery, with automatic HTTP fallback. It supports torrent, debrid, and usenet downloads concurrently, reports progress to the web dashboard, and handles graceful shutdown. -Interactive configuration setup. +**Service locations:** +- Linux: `~/.config/systemd/user/unarr.service` (systemd) +- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd) + +## Diagnostics ```bash -unarr config +# Run all diagnostic checks +unarr doctor + +# Update to the latest version +unarr self-update +unarr self-update --force # reinstall even if up to date ``` -Saves to `~/.config/unarr/config.yaml`. +`unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version. -## Alias +## Alias (optional) -You can use `un` as a shorthand for `unarr`: +Create a shell alias for shorter commands: ```bash +# Add to ~/.bashrc or ~/.zshrc +alias un=unarr + +# Then use: un search "breaking bad" --type show un popular --limit 5 +un start ``` ## Global Flags @@ -165,7 +244,7 @@ un popular --limit 5 ## JSON Output -All commands support `--json` for scripting: +All query commands support `--json` for scripting: ```bash # Pipe to jq @@ -180,32 +259,97 @@ SEEDS=$(unarr search "inception" --json | jq '.results[0].torrents[0].seeders') ## Configuration -Config file location: `~/.config/unarr/config.yaml` +### Config file -```yaml -api_url: https://torrentclaw.com -api_key: tc_your_api_key_here -country: US +Location: `~/.config/unarr/config.toml` + +```toml +[auth] +api_key = "tc_your_api_key_here" +api_url = "https://torrentclaw.com" + +[agent] +id = "auto-generated-uuid" +name = "My PC" + +[downloads] +dir = "~/Media" +preferred_method = "auto" # auto | torrent | debrid | usenet +max_concurrent = 3 +max_download_speed = "0" # e.g. "10MB", "500KB", "0" = unlimited +max_upload_speed = "0" + +[organize] +enabled = true +movies_dir = "~/Media/Movies" +tv_shows_dir = "~/Media/TV Shows" + +[daemon] +poll_interval = "30s" +heartbeat_interval = "30s" + +[notifications] +enabled = true + +[general] +country = "US" ``` -Environment variables (override config file): +### Environment variables + +Environment variables override config file values: ```bash -export UNARR_API_URL=https://torrentclaw.com export UNARR_API_KEY=tc_your_api_key +export UNARR_API_URL=https://torrentclaw.com export UNARR_COUNTRY=ES +export UNARR_DOWNLOAD_DIR=~/Media ``` +### Speed limits + +Speed limits use human-readable format: + +```toml +max_download_speed = "10MB" # 10 megabytes/sec +max_upload_speed = "1MB" # 1 megabyte/sec +max_download_speed = "500KB" # 500 kilobytes/sec +max_download_speed = "0" # unlimited (default) +``` + +## Shell Completion + +Generate tab-completion scripts for your shell: + +```bash +# Bash — add to ~/.bashrc +eval "$(unarr completion bash)" + +# Zsh — add to ~/.zshrc +eval "$(unarr completion zsh)" + +# Fish +unarr completion fish > ~/.config/fish/completions/unarr.fish + +# PowerShell — add to $PROFILE +unarr completion powershell >> $PROFILE +``` + +Completions provide tab-completion for commands, flags, and flag values (e.g. `--type ` shows `movie` and `show`). + ## Coming Soon -These commands are stubbed and will be available in future releases: +These commands are planned for future releases: -- `unarrupgrade` — Find a better version of a torrent -- `unarrmoreseed` — Find same quality with more seeders -- `unarrcompare` — Compare two torrents side by side -- `unarrscan` — Scan your media library for upgrades -- `unarradd` — Search and add torrents to your client -- `unarrmonitor` — Watch for new episodes of a series +| Command | Description | +|---------|-------------| +| `unarr upgrade` | Find a better version of a torrent | +| `unarr moreseed` | Find same quality with more seeders | +| `unarr compare` | Compare two torrents side by side | +| `unarr scan` | Scan your media library for upgrades | +| `unarr add` | Search and add torrents to your client | +| `unarr monitor` | Watch for new episodes of a series | +| `unarr open` | Open content in the browser | ## Contributing diff --git a/internal/cmd/completion.go b/internal/cmd/completion.go new file mode 100644 index 0000000..6f1cb38 --- /dev/null +++ b/internal/cmd/completion.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newCompletionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "completion ", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for unarr. + +Completions allow you to press Tab to auto-complete commands, flags, +and arguments in your terminal. Follow the instructions for your shell below. + +Bash: + # Add to ~/.bashrc for persistent completions: + echo 'eval "$(unarr completion bash)"' >> ~/.bashrc + + # Or generate a file (recommended for system-wide): + unarr completion bash > /etc/bash_completion.d/unarr + +Zsh: + # Add to ~/.zshrc for persistent completions: + echo 'eval "$(unarr completion zsh)"' >> ~/.zshrc + + # Or if you use oh-my-zsh, place in custom completions dir: + mkdir -p ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/completions + unarr completion zsh > ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/completions/_unarr + +Fish: + # Add to fish completions dir: + unarr completion fish > ~/.config/fish/completions/unarr.fish + +PowerShell: + # Add to your PowerShell profile: + unarr completion powershell >> $PROFILE`, + Example: ` unarr completion bash + unarr completion zsh + unarr completion fish > ~/.config/fish/completions/unarr.fish + eval "$(unarr completion bash)"`, + Args: cobra.ExactArgs(1), + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + RunE: func(cmd *cobra.Command, args []string) error { + switch args[0] { + case "bash": + return rootCmd.GenBashCompletionV2(os.Stdout, true) + case "zsh": + return rootCmd.GenZshCompletion(os.Stdout) + case "fish": + return rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + default: + return fmt.Errorf("unknown shell %q: must be one of bash, zsh, fish, powershell", args[0]) + } + }, + } + + return cmd +} diff --git a/internal/cmd/completion_helpers.go b/internal/cmd/completion_helpers.go new file mode 100644 index 0000000..4b00074 --- /dev/null +++ b/internal/cmd/completion_helpers.go @@ -0,0 +1,14 @@ +package cmd + +import "github.com/spf13/cobra" + +// completionCountryCodes provides shell completion for --country flags. +func completionCountryCodes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{ + "US\tUnited States", "GB\tUnited Kingdom", "ES\tSpain", "FR\tFrance", + "DE\tGermany", "IT\tItaly", "PT\tPortugal", "BR\tBrazil", + "MX\tMexico", "AR\tArgentina", "CA\tCanada", "AU\tAustralia", + "NL\tNetherlands", "SE\tSweden", "NO\tNorway", "DK\tDenmark", + "FI\tFinland", "JP\tJapan", "KR\tSouth Korea", "IN\tIndia", + }, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index d37660a..bcd1d97 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -14,11 +14,20 @@ import ( func newConfigCmd() *cobra.Command { cmd := &cobra.Command{ Use: "config", - Short: "Configure unarr", - Long: `Interactive setup for unarr. + Short: "Edit configuration interactively", + Long: `Edit unarr settings interactively in your terminal. -Configures the API URL, API key, default country, and saves to config file.`, - Example: ` unarr config`, +Prompts for API URL, API key, and default country. Press Enter to keep +the current value. For first-time setup use 'unarr setup' instead. + +Config file: ~/.config/unarr/config.toml +Environment variables override config file values: + UNARR_API_KEY API key + UNARR_API_URL API URL + UNARR_COUNTRY Default country code + UNARR_DOWNLOAD_DIR Download directory`, + Example: ` unarr config + unarr config --config /path/to/config.toml`, RunE: func(cmd *cobra.Command, args []string) error { return runConfig() }, diff --git a/internal/cmd/daemon_install.go b/internal/cmd/daemon_install.go new file mode 100644 index 0000000..6de10c4 --- /dev/null +++ b/internal/cmd/daemon_install.go @@ -0,0 +1,239 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "text/template" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +const systemdTemplate = `[Unit] +Description=unarr download daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={{.BinPath}} start +Restart=always +RestartSec=10 +User={{.User}} +Environment=HOME={{.Home}} + +[Install] +WantedBy=multi-user.target +` + +const launchdTemplate = ` + + + + Label + com.torrentclaw.unarr + ProgramArguments + + {{.BinPath}} + start + + RunAtLoad + + KeepAlive + + StandardOutPath + {{.LogDir}}/unarr.log + StandardErrorPath + {{.LogDir}}/unarr.err.log + + +` + +func newDaemonInstallCmdReal() *cobra.Command { + return &cobra.Command{ + Use: "install", + Short: "Install daemon as a system service (systemd/launchd)", + Long: `Install the unarr daemon as a system service so it starts automatically on boot. + + Linux: Creates a systemd user service (~/.config/systemd/user/unarr.service) + Enables lingering so the service runs without an active login session. + macOS: Creates a launchd user agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist) + +The service is enabled and started immediately after installation. +No sudo or root access is required (uses user-level service managers).`, + Example: ` unarr daemon install`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonInstall() + }, + } +} + +func newDaemonUninstallCmdReal() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Remove daemon system service", + Long: `Stop the daemon and remove the system service created by 'unarr daemon install'. + +Removes the service file and disables automatic startup on boot.`, + Example: ` unarr daemon uninstall`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonUninstall() + }, + } +} + +type serviceData struct { + BinPath string + User string + Home string + LogDir string +} + +func runDaemonInstall() error { + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("find executable: %w", err) + } + binPath, _ = filepath.EvalSymlinks(binPath) + + home, _ := os.UserHomeDir() + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("USERNAME") + } + + data := serviceData{ + BinPath: binPath, + User: user, + Home: home, + LogDir: filepath.Join(home, ".local", "share", "unarr"), + } + + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + + fmt.Println() + bold.Println(" unarr daemon install") + fmt.Println() + + switch runtime.GOOS { + case "linux": + return installSystemd(data, green) + case "darwin": + return installLaunchd(data, green) + default: + return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS) + } +} + +func installSystemd(data serviceData, green *color.Color) error { + // User-level systemd service (no sudo needed) + dir := filepath.Join(data.Home, ".config", "systemd", "user") + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create systemd dir: %w", err) + } + + path := filepath.Join(dir, "unarr.service") + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create service file: %w", err) + } + defer f.Close() + + tmpl := template.Must(template.New("systemd").Parse(systemdTemplate)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("write service file: %w", err) + } + + fmt.Printf(" Created: %s\n", path) + + // Enable and start + exec.Command("systemctl", "--user", "daemon-reload").Run() + exec.Command("systemctl", "--user", "enable", "unarr").Run() + exec.Command("systemctl", "--user", "start", "unarr").Run() + + // Enable lingering so user services run without login session + exec.Command("loginctl", "enable-linger", data.User).Run() + + fmt.Println() + green.Println(" ✓ Installed and started!") + fmt.Println() + fmt.Println(" Manage with:") + fmt.Println(" systemctl --user status unarr") + fmt.Println(" systemctl --user restart unarr") + fmt.Println(" journalctl --user -u unarr -f") + fmt.Println() + + return nil +} + +func installLaunchd(data serviceData, green *color.Color) error { + os.MkdirAll(data.LogDir, 0o755) + + dir := filepath.Join(data.Home, "Library", "LaunchAgents") + os.MkdirAll(dir, 0o755) + + path := filepath.Join(dir, "com.torrentclaw.unarr.plist") + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create plist: %w", err) + } + defer f.Close() + + tmpl := template.Must(template.New("launchd").Parse(launchdTemplate)) + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("write plist: %w", err) + } + + fmt.Printf(" Created: %s\n", path) + + exec.Command("launchctl", "load", path).Run() + + fmt.Println() + green.Println(" ✓ Installed and loaded!") + fmt.Println() + fmt.Println(" Manage with:") + fmt.Println(" launchctl list | grep unarr") + fmt.Println(" launchctl unload " + path) + fmt.Println(" tail -f " + filepath.Join(data.LogDir, "unarr.log")) + fmt.Println() + + return nil +} + +func runDaemonUninstall() error { + home, _ := os.UserHomeDir() + + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + + fmt.Println() + bold.Println(" unarr daemon uninstall") + fmt.Println() + + switch runtime.GOOS { + case "linux": + exec.Command("systemctl", "--user", "stop", "unarr").Run() + exec.Command("systemctl", "--user", "disable", "unarr").Run() + path := filepath.Join(home, ".config", "systemd", "user", "unarr.service") + os.Remove(path) + exec.Command("systemctl", "--user", "daemon-reload").Run() + green.Printf(" ✓ Removed %s\n", path) + + case "darwin": + path := filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist") + exec.Command("launchctl", "unload", path).Run() + os.Remove(path) + green.Printf(" ✓ Removed %s\n", path) + + default: + return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS) + } + + fmt.Println() + return nil +} + diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index d4a0535..e20e9c9 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "runtime" - "syscall" "time" "github.com/fatih/color" @@ -17,8 +16,20 @@ import ( func newDoctorCmd() *cobra.Command { return &cobra.Command{ Use: "doctor", - Short: "Diagnose CLI configuration and connectivity", - Long: "Run diagnostic checks on API connectivity, config validity, disk space, and capabilities.", + Short: "Diagnose configuration and connectivity", + Long: `Run diagnostic checks to verify that unarr is correctly configured. + +Checks performed: + - Config file exists and is readable + - API key is configured + - API server is reachable (with latency) + - Agent is registered with the server + - Download directory exists and is writable + - Disk space is sufficient (warns below 10 GB) + - Current unarr version + +Use this command to troubleshoot connection issues or verify setup.`, + Example: ` unarr doctor`, RunE: func(cmd *cobra.Command, args []string) error { return runDoctor() }, @@ -176,17 +187,7 @@ func runDoctor() error { if dir == "" { return "", fmt.Errorf("not configured") } - var stat syscall.Statfs_t - if err := syscall.Statfs(dir, &stat); err != nil { - return "", err - } - available := int64(stat.Bavail) * int64(stat.Bsize) - gb := float64(available) / (1024 * 1024 * 1024) - msg := fmt.Sprintf("%.1f GB free", gb) - if gb < 10 { - return "!" + msg + " (low)", nil - } - return msg, nil + return checkDiskSpace(dir) }) fmt.Println() diff --git a/internal/cmd/doctor_unix.go b/internal/cmd/doctor_unix.go new file mode 100644 index 0000000..b899c65 --- /dev/null +++ b/internal/cmd/doctor_unix.go @@ -0,0 +1,22 @@ +//go:build !windows + +package cmd + +import ( + "fmt" + "syscall" +) + +func checkDiskSpace(dir string) (string, error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(dir, &stat); err != nil { + return "", err + } + available := int64(stat.Bavail) * int64(stat.Bsize) + gb := float64(available) / (1024 * 1024 * 1024) + msg := fmt.Sprintf("%.1f GB free", gb) + if gb < 10 { + return "!" + msg + " (low)", nil + } + return msg, nil +} diff --git a/internal/cmd/doctor_windows.go b/internal/cmd/doctor_windows.go new file mode 100644 index 0000000..f12a381 --- /dev/null +++ b/internal/cmd/doctor_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package cmd + +import ( + "fmt" + "syscall" + "unsafe" +) + +func checkDiskSpace(dir string) (string, error) { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW") + + var freeBytesAvailable, totalBytes, totalFreeBytes int64 + dirPtr, _ := syscall.UTF16PtrFromString(dir) + ret, _, err := getDiskFreeSpaceEx.Call( + uintptr(unsafe.Pointer(dirPtr)), + uintptr(unsafe.Pointer(&freeBytesAvailable)), + uintptr(unsafe.Pointer(&totalBytes)), + uintptr(unsafe.Pointer(&totalFreeBytes)), + ) + if ret == 0 { + return "", fmt.Errorf("GetDiskFreeSpaceEx: %w", err) + } + + gb := float64(freeBytesAvailable) / (1024 * 1024 * 1024) + msg := fmt.Sprintf("%.1f GB free", gb) + if gb < 10 { + return "!" + msg + " (low)", nil + } + return msg, nil +} diff --git a/internal/cmd/download.go b/internal/cmd/download.go index e9d9024..938ef1a 100644 --- a/internal/cmd/download.go +++ b/internal/cmd/download.go @@ -24,7 +24,13 @@ func newDownloadCmd() *cobra.Command { Use: "download ", Short: "Download a torrent (one-shot, no daemon needed)", Long: `Download a specific torrent by info hash or magnet link. -This is a standalone download — it does not require the daemon to be running.`, + +This is a standalone download that does not require the daemon to be running. +Useful for quick one-off downloads. The file is saved to your configured +download directory. Press Ctrl+C to cancel. + +For managed downloads (queue, progress tracking, web dashboard), use the +daemon instead: 'unarr start'.`, Example: ` unarr download abc123def456abc123def456abc123def456abc1 unarr download "magnet:?xt=urn:btih:..." --method torrent`, Args: cobra.ExactArgs(1), @@ -33,7 +39,10 @@ This is a standalone download — it does not require the daemon to be running.` }, } - cmd.Flags().StringVar(&method, "method", "torrent", "download method: torrent (default)") + cmd.Flags().StringVar(&method, "method", "torrent", "download method: torrent, debrid, usenet") + cmd.RegisterFlagCompletionFunc("method", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"torrent\tBitTorrent P2P", "debrid\tReal-Debrid / AllDebrid", "usenet\tUsenet (requires Pro)"}, cobra.ShellCompDirectiveNoFileComp + }) return cmd } @@ -55,6 +64,9 @@ func runDownload(input, method string) error { return fmt.Errorf("invalid input: provide a 40-char info hash or magnet URI") } } + if len(infoHash) < 40 { + return fmt.Errorf("invalid info hash: expected 40 characters, got %d", len(infoHash)) + } outputDir := cfg.Download.Dir if outputDir == "" { diff --git a/internal/cmd/popular.go b/internal/cmd/popular.go index e1e17c5..b3a7a4d 100644 --- a/internal/cmd/popular.go +++ b/internal/cmd/popular.go @@ -20,8 +20,11 @@ func newPopularCmd() *cobra.Command { cmd := &cobra.Command{ Use: "popular", - Short: "Show popular content", - Long: "Display the most popular movies and TV shows, ranked by community engagement.", + Short: "Show popular movies and TV shows", + Long: `Display the most popular movies and TV shows, ranked by community engagement. + +Results are ordered by trending score. Use --limit to control how many +results to show and --page for pagination.`, Example: ` unarr popular unarr popular --limit 20 unarr popular --page 2 --json`, diff --git a/internal/cmd/recent.go b/internal/cmd/recent.go index 4f9f04f..d4c39bb 100644 --- a/internal/cmd/recent.go +++ b/internal/cmd/recent.go @@ -20,8 +20,11 @@ func newRecentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "recent", - Short: "Show recently added content", - Long: "Display the most recently added movies and TV shows to the catalog.", + Short: "Show recently added movies and TV shows", + Long: `Display the most recently added movies and TV shows to the catalog. + +Shows the latest additions ordered by ingestion date. Use --limit to +control how many results to show and --page for pagination.`, Example: ` unarr recent unarr recent --limit 20 unarr recent --page 2 --json`, diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go new file mode 100644 index 0000000..4407043 --- /dev/null +++ b/internal/cmd/reload_unix.go @@ -0,0 +1,53 @@ +//go:build !windows + +package cmd + +import ( + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/torrentclaw/torrentclaw-cli/internal/agent" + "github.com/torrentclaw/torrentclaw-cli/internal/config" +) + +// ReloadableConfig holds a reference to the daemon for hot-reload. +type ReloadableConfig struct { + Daemon *agent.Daemon +} + +// startReloadWatcher listens for SIGUSR1 and reloads config. +// Only intervals are hot-reloadable (speeds require torrent client restart). +func startReloadWatcher(rc *ReloadableConfig) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGUSR1) + + go func() { + for range sigCh { + log.Println("Received SIGUSR1, reloading config...") + + cfg, err := config.Load("") + if err != nil { + log.Printf("Config reload failed: %v", err) + continue + } + cfg.ApplyEnvOverrides() + + // Update poll interval + if d, _ := time.ParseDuration(cfg.Daemon.PollInterval); d > 0 && rc.Daemon.PollTicker != nil { + rc.Daemon.PollTicker.Reset(d) + log.Printf(" Poll interval: %s", d) + } + + // Update heartbeat interval + if d, _ := time.ParseDuration(cfg.Daemon.HeartbeatInterval); d > 0 && rc.Daemon.HeartbeatTicker != nil { + rc.Daemon.HeartbeatTicker.Reset(d) + log.Printf(" Heartbeat interval: %s", d) + } + + log.Println("Config reloaded successfully") + } + }() +} diff --git a/internal/cmd/reload_windows.go b/internal/cmd/reload_windows.go new file mode 100644 index 0000000..cfc262a --- /dev/null +++ b/internal/cmd/reload_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package cmd + +import "github.com/torrentclaw/torrentclaw-cli/internal/agent" + +// ReloadableConfig holds a reference to the daemon for hot-reload. +type ReloadableConfig struct { + Daemon *agent.Daemon +} + +// startReloadWatcher is a no-op on Windows (no SIGUSR1 support). +func startReloadWatcher(_ *ReloadableConfig) {} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 16d942d..0779299 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -28,7 +28,15 @@ func init() { Long: `unarr is a powerful terminal tool for torrent search and management. Search 30+ torrent sources, inspect torrent quality, discover popular content, -find streaming providers, and manage your media collection — all from your terminal.`, +find streaming providers, and manage your media collection — all from your terminal. + +Get started: + unarr setup First-time configuration wizard + unarr search "breaking bad" Search for content + unarr start Start the download daemon + +Documentation: https://torrentclaw.com/cli +Source: https://github.com/torrentclaw/torrentclaw-cli`, PersistentPreRun: func(cmd *cobra.Command, args []string) { if noColor || os.Getenv("NO_COLOR") != "" { color.NoColor = true @@ -38,33 +46,95 @@ find streaming providers, and manage your media collection — all from your ter SilenceErrors: true, } + // Command groups for organized help output + rootCmd.AddGroup( + &cobra.Group{ID: "start", Title: "Getting Started:"}, + &cobra.Group{ID: "search", Title: "Search & Discovery:"}, + &cobra.Group{ID: "download", Title: "Downloads & Streaming:"}, + &cobra.Group{ID: "daemon", Title: "Daemon Management:"}, + &cobra.Group{ID: "system", Title: "System & Diagnostics:"}, + ) + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.config/unarr/config.toml)") rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "API key (overrides config file and env)") rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "output as JSON (for piping)") rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable colored output") + // Getting Started + setupCmd := newSetupCmd() + setupCmd.GroupID = "start" + configCmd := newConfigCmd() + configCmd.GroupID = "start" + + // Search & Discovery + searchCmd := newSearchCmd() + searchCmd.GroupID = "search" + inspectCmd := newInspectCmd() + inspectCmd.GroupID = "search" + popularCmd := newPopularCmd() + popularCmd.GroupID = "search" + recentCmd := newRecentCmd() + recentCmd.GroupID = "search" + watchCmd := newWatchCmd() + watchCmd.GroupID = "search" + + // Downloads & Streaming + downloadCmd := newDownloadCmd() + downloadCmd.GroupID = "download" + streamCmd := newStreamCmd() + streamCmd.GroupID = "download" + + // Daemon Management + startCmd := newStartCmd() + startCmd.GroupID = "daemon" + stopCmd := newStopCmd() + stopCmd.GroupID = "daemon" + statusCmd := newStatusCmd() + statusCmd.GroupID = "daemon" + daemonCmd := newDaemonCmd() + daemonCmd.GroupID = "daemon" + + // System & Diagnostics + statsCmd := newStatsCmd() + statsCmd.GroupID = "system" + doctorCmd := newDoctorCmd() + doctorCmd.GroupID = "system" + selfUpdateCmd := newSelfUpdateCmd() + selfUpdateCmd.GroupID = "system" + versionCmd := newVersionCmd() + versionCmd.GroupID = "system" + completionCmd := newCompletionCmd() + completionCmd.GroupID = "system" + rootCmd.AddCommand( - newSetupCmd(), - newStartCmd(), - newStopCmd(), - newDaemonCmd(), - newDownloadCmd(), - newStatusCmd(), - newSearchCmd(), - newInspectCmd(), - newPopularCmd(), - newRecentCmd(), - newStatsCmd(), - newWatchCmd(), - newConfigCmd(), - newDoctorCmd(), - newVersionCmd(), + // Getting Started + setupCmd, + configCmd, + // Search & Discovery + searchCmd, + inspectCmd, + popularCmd, + recentCmd, + watchCmd, + // Downloads & Streaming + downloadCmd, + streamCmd, + // Daemon Management + startCmd, + stopCmd, + statusCmd, + daemonCmd, + // System & Diagnostics + statsCmd, + doctorCmd, + selfUpdateCmd, + versionCmd, + completionCmd, // Stubs for future commands newStubCmd("upgrade", "Find a better version of a torrent"), newStubCmd("moreseed", "Find same quality with more seeders"), newStubCmd("compare", "Compare two torrents side by side"), newStubCmd("scan", "Scan your media library for upgrades"), - newStreamCmd(), newStubCmd("add", "Search and add torrents to your client"), newStubCmd("monitor", "Watch for new episodes of a series"), newStubCmd("open", "Open content in the browser"), diff --git a/internal/cmd/search.go b/internal/cmd/search.go index f8edbe8..7134a8c 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -31,9 +31,11 @@ func newSearchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search ", Short: "Search for movies and TV shows", - Long: `Search the catalog with advanced filters. + Long: `Search the catalog for movies and TV shows with advanced filters. -Results include torrent quality scores, seed health, and metadata from 30+ sources.`, +Results include torrent quality scores (0-100), seed health, resolution, codec, +audio, and metadata aggregated from 30+ sources. Use --json for machine-readable +output that can be piped to jq or other tools.`, Example: ` unarr search "breaking bad" --type show --quality 1080p unarr search "oppenheimer" --sort seeders --limit 5 unarr search "inception" --lang es --min-rating 7 @@ -85,5 +87,23 @@ Results include torrent quality scores, seed health, and metadata from 30+ sourc cmd.Flags().IntVar(&page, "page", 0, "page number") cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)") + // Shell completion for flags with known values + cmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"movie\tmovies only", "show\tTV shows only"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("quality", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"480p\tSD", "720p\tHD", "1080p\tFull HD", "2160p\t4K Ultra HD"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("sort", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"relevance\tbest match", "seeders\tmost seeders", "year\tnewest first", "rating\thighest rated", "added\trecently added"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("lang", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"en\tEnglish", "es\tSpanish", "fr\tFrench", "de\tGerman", "it\tItalian", "pt\tPortuguese", "ja\tJapanese", "ko\tKorean", "zh\tChinese", "ru\tRussian"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("genre", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"Action", "Adventure", "Animation", "Comedy", "Crime", "Documentary", "Drama", "Family", "Fantasy", "History", "Horror", "Music", "Mystery", "Romance", "Science Fiction", "Thriller", "War", "Western"}, cobra.ShellCompDirectiveNoFileComp + }) + cmd.RegisterFlagCompletionFunc("country", completionCountryCodes) + return cmd } diff --git a/internal/cmd/self_update.go b/internal/cmd/self_update.go new file mode 100644 index 0000000..cc711e2 --- /dev/null +++ b/internal/cmd/self_update.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "syscall" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/torrentclaw/torrentclaw-cli/internal/upgrade" +) + +func newSelfUpdateCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "self-update", + Short: "Update unarr to the latest version", + Long: `Download and install the latest version of unarr. + +Checks GitHub for the latest release, verifies the checksum, and +replaces the current binary. A backup is kept at .backup.`, + Example: ` unarr self-update + unarr self-update --force`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSelfUpdate(force) + }, + } + + cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date") + + return cmd +} + +func runSelfUpdate(force bool) error { + bold := color.New(color.Bold) + green := color.New(color.FgGreen) + yellow := color.New(color.FgYellow) + + fmt.Println() + bold.Println(" unarr self-update") + fmt.Println() + + // Check latest version + fmt.Print(" Checking latest version... ") + ctx := context.Background() + latest, err := upgrade.CheckLatest(ctx) + if err != nil { + fmt.Println() + return fmt.Errorf("could not check latest version: %w", err) + } + + currentClean := strings.TrimPrefix(Version, "v") + fmt.Printf("v%s\n", latest) + fmt.Printf(" Current version: v%s\n", currentClean) + + if currentClean == latest && !force { + fmt.Println() + green.Println(" ✓ Already up to date!") + fmt.Println() + return nil + } + + if currentClean == latest && force { + yellow.Println(" Forcing reinstall...") + } + + fmt.Println() + + upgrader := &upgrade.Upgrader{ + CurrentVersion: currentClean, + OnProgress: func(msg string) { + fmt.Printf(" %s\n", msg) + }, + } + + result := upgrader.Execute(ctx, latest) + + fmt.Println() + if !result.Success { + return fmt.Errorf("upgrade failed: %v", result.Error) + } + + green.Printf(" ✓ Upgraded v%s → v%s\n", result.OldVersion, result.NewVersion) + if result.BackupPath != "" { + fmt.Printf(" Backup: %s\n", result.BackupPath) + } + fmt.Println() + + // If running as daemon, re-exec to restart with new binary + // For interactive use, just suggest restarting + if isRunningAsDaemon() { + fmt.Println(" Restarting daemon with new version...") + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("could not determine executable path: %w", err) + } + execErr := syscall.Exec(binPath, os.Args, os.Environ()) + if execErr != nil && runtime.GOOS == "windows" { + // Windows doesn't support syscall.Exec — start new process + proc := exec.Command(binPath, os.Args[1:]...) + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + proc.Stdin = os.Stdin + return proc.Start() + } + return execErr + } + + return nil +} + +func isRunningAsDaemon() bool { + // Simple heuristic: check if "start" was in the original args + for _, arg := range os.Args { + if arg == "start" { + return true + } + } + return false +} diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 1bc89d2..3b55b72 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -23,7 +23,17 @@ func newSetupCmd() *cobra.Command { cmd := &cobra.Command{ Use: "setup", Short: "First-time configuration wizard", - Long: "Interactive setup that configures API key, download directory, and preferred download method.", + Long: `Interactive setup that configures API key, download directory, and +preferred download method. + +Opens your browser to create/copy your API key, then walks you through +choosing a download directory, method (torrent, debrid, usenet), and +device name. Validates the API key against the server before saving. + +Run this once after installing unarr. To change settings later, +use 'unarr config' or edit ~/.config/unarr/config.toml directly.`, + Example: ` unarr setup + unarr setup --api-url https://custom.server.com`, RunE: func(cmd *cobra.Command, args []string) error { return runSetup(apiURL) }, @@ -238,7 +248,7 @@ func runSetup(apiURLOverride string) error { } cyan.Printf(" Available: %s\n", strings.Join(features, ", ")) fmt.Println() - fmt.Println(" Next: run", bold.Sprint("unarr daemon start"), "to begin downloading") + fmt.Println(" Next: run", bold.Sprint("unarr start"), "to begin downloading") fmt.Println() return nil diff --git a/internal/cmd/stats.go b/internal/cmd/stats.go index cfd04c5..47905d3 100644 --- a/internal/cmd/stats.go +++ b/internal/cmd/stats.go @@ -13,10 +13,14 @@ import ( func newStatsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "stats", - Short: "Show system statistics", - Long: "Display aggregator statistics including content counts, torrent sources, and recent ingestion history.", - Example: ` unarr stats`, + Use: "stats", + Short: "Show catalog statistics", + Long: `Display aggregator statistics from the unarr catalog. + +Shows total content count, torrent count, sources breakdown, and recent +ingestion activity. Useful for understanding the catalog coverage.`, + Example: ` unarr stats + unarr stats --json`, RunE: func(cmd *cobra.Command, args []string) error { client := getClient() diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 5b82160..bcd3144 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -11,7 +11,11 @@ func newStatusCmd() *cobra.Command { return &cobra.Command{ Use: "status", Short: "Show daemon status and active downloads", - Long: "Display the current state of the daemon, active downloads, and recent activity.", + Long: `Display the current state of the daemon, active downloads, and recent activity. + +Shows the configured agent name, download directory, and preferred method. +When the daemon is running, also displays active downloads and their progress.`, + Example: ` unarr status`, RunE: func(cmd *cobra.Command, args []string) error { return runStatus() }, @@ -39,7 +43,7 @@ func runStatus() error { fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod) fmt.Println() - dim.Println(" Daemon not running. Start with 'unarr daemon start'") + dim.Println(" Daemon not running. Start with 'unarr start'") dim.Println(" (Live status will be shown here when daemon is running)") fmt.Println() diff --git a/internal/cmd/stream.go b/internal/cmd/stream.go index 416fc1b..f28240b 100644 --- a/internal/cmd/stream.go +++ b/internal/cmd/stream.go @@ -27,9 +27,14 @@ func newStreamCmd() *cobra.Command { cmd := &cobra.Command{ Use: "stream ", Short: "Stream a torrent directly to a media player", - Long: `Stream a torrent by info hash or magnet link. -Downloads sequentially and serves the video over HTTP. -Automatically opens mpv, vlc, or your browser.`, + Long: `Stream a torrent by info hash or magnet link without waiting for the full download. + +Downloads pieces sequentially (prioritizing the beginning of the file) and serves +the video over a local HTTP server. Automatically detects and opens mpv, vlc, or +your default browser. + +The stream server runs until you press Ctrl+C. Data is stored temporarily in your +download directory (or system temp if not configured).`, Example: ` unarr stream abc123def456abc123def456abc123def456abc1 unarr stream "magnet:?xt=urn:btih:..." --port 8080 unarr stream --player mpv @@ -43,6 +48,9 @@ Automatically opens mpv, vlc, or your browser.`, cmd.Flags().IntVar(&port, "port", 0, "HTTP server port (default: random available)") cmd.Flags().BoolVar(&noOpen, "no-open", false, "don't open a player, just print the URL") cmd.Flags().StringVar(&playerCmd, "player", "", "media player command (default: auto-detect)") + cmd.RegisterFlagCompletionFunc("player", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"mpv\tmpv media player", "vlc\tVLC media player"}, cobra.ShellCompDirectiveNoFileComp + }) return cmd } diff --git a/internal/cmd/version_cmd.go b/internal/cmd/version_cmd.go index 15ba700..9176548 100644 --- a/internal/cmd/version_cmd.go +++ b/internal/cmd/version_cmd.go @@ -11,6 +11,7 @@ func newVersionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Show unarr version", + Long: "Print the unarr version, operating system, and architecture.", Run: func(cmd *cobra.Command, args []string) { fmt.Printf("unarr %s (%s/%s)\n", Version, runtime.GOOS, runtime.GOARCH) }, diff --git a/internal/cmd/watch.go b/internal/cmd/watch.go index 7b24641..3c237c7 100644 --- a/internal/cmd/watch.go +++ b/internal/cmd/watch.go @@ -75,6 +75,7 @@ then torrent alternatives below. Helps you decide the best way to watch.`, } cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)") + cmd.RegisterFlagCompletionFunc("country", completionCountryCodes) return cmd }