docs: improve CLI help, shell completion, and README

- Add command groups (Getting Started, Search, Downloads, Daemon, System)
- Add shell completion command (bash, zsh, fish, powershell)
- Add flag completions for --type, --quality, --sort, --lang, --genre,
  --country, --method, --player
- Improve Long descriptions and Examples for all commands
- Split doctor disk check into platform-specific files (Unix/Windows)
- Validate infoHash length before truncating (prevent panic)
- Fix references to non-existent 'unarr daemon start' command
- Move stats command to System & Diagnostics group
- Rewrite README with complete documentation, correct config format
  (toml not yaml), all commands, shell completion section
This commit is contained in:
Deivid Soto 2026-03-28 21:36:27 +01:00
parent 197e33956a
commit 719429b06e
22 changed files with 973 additions and 119 deletions

274
README.md
View file

@ -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 <query>` | Search for movies and TV shows with advanced filters |
| `unarr inspect <magnet\|hash\|name>` | TrueSpec analysis — quality, codec, seed health |
| `unarr popular` | Show popular movies and TV shows |
| `unarr recent` | Show recently added content |
| `unarr watch <query>` | Find where to watch — streaming + torrents |
### Downloads & Streaming
| Command | Description |
|---------|-------------|
| `unarr download <hash\|magnet>` | One-shot download (no daemon needed) |
| `unarr stream <hash\|magnet>` | 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 <shell>` | 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 <hash> --player mpv
unarr stream <hash> --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 <Tab>` 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

View file

@ -0,0 +1,65 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion <bash|zsh|fish|powershell>",
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
}

View file

@ -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
}

View file

@ -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()
},

View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.torrentclaw.unarr</string>
<key>ProgramArguments</key>
<array>
<string>{{.BinPath}}</string>
<string>start</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{{.LogDir}}/unarr.log</string>
<key>StandardErrorPath</key>
<string>{{.LogDir}}/unarr.err.log</string>
</dict>
</plist>
`
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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -24,7 +24,13 @@ func newDownloadCmd() *cobra.Command {
Use: "download <info_hash|magnet>",
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 == "" {

View file

@ -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`,

View file

@ -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`,

View file

@ -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")
}
}()
}

View file

@ -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) {}

View file

@ -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"),

View file

@ -31,9 +31,11 @@ func newSearchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "search <query>",
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
}

125
internal/cmd/self_update.go Normal file
View file

@ -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 <binary>.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
}

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -27,9 +27,14 @@ func newStreamCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stream <magnet|infohash>",
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 <hash> --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
}

View file

@ -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)
},

View file

@ -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
}