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:
parent
197e33956a
commit
719429b06e
22 changed files with 973 additions and 119 deletions
65
internal/cmd/completion.go
Normal file
65
internal/cmd/completion.go
Normal 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
|
||||
}
|
||||
14
internal/cmd/completion_helpers.go
Normal file
14
internal/cmd/completion_helpers.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
239
internal/cmd/daemon_install.go
Normal file
239
internal/cmd/daemon_install.go
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
22
internal/cmd/doctor_unix.go
Normal file
22
internal/cmd/doctor_unix.go
Normal 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
|
||||
}
|
||||
33
internal/cmd/doctor_windows.go
Normal file
33
internal/cmd/doctor_windows.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
53
internal/cmd/reload_unix.go
Normal file
53
internal/cmd/reload_unix.go
Normal 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")
|
||||
}
|
||||
}()
|
||||
}
|
||||
13
internal/cmd/reload_windows.go
Normal file
13
internal/cmd/reload_windows.go
Normal 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) {}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
125
internal/cmd/self_update.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue