feat: initial commit — unarr CLI

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

109
internal/cmd/config.go Normal file
View file

@ -0,0 +1,109 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Configure unarr",
Long: `Interactive setup for unarr.
Configures the API URL, API key, default country, and saves to config file.`,
Example: ` unarr config`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfig()
},
}
return cmd
}
func runConfig() error {
if !isTerminal() {
return fmt.Errorf("interactive config requires a terminal (use --api-key flag or env vars instead)")
}
reader := bufio.NewReader(os.Stdin)
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cfg := loadConfig()
fmt.Println()
bold.Println(" unarr Configuration")
fmt.Println()
// API URL
currentURL := cfg.Auth.APIURL
fmt.Printf(" API URL [%s]: ", currentURL)
apiURL, _ := reader.ReadString('\n')
apiURL = strings.TrimSpace(apiURL)
if apiURL == "" {
apiURL = currentURL
}
// API Key
currentKey := cfg.Auth.APIKey
keyDisplay := ""
if currentKey != "" {
if len(currentKey) > 8 {
keyDisplay = currentKey[:8] + "..."
} else {
keyDisplay = currentKey
}
}
fmt.Printf(" API Key [%s]: ", keyDisplay)
apiKey, _ := reader.ReadString('\n')
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
apiKey = currentKey
}
// Country
currentCountry := cfg.General.Country
fmt.Printf(" Default country [%s]: ", currentCountry)
country, _ := reader.ReadString('\n')
country = strings.TrimSpace(country)
if country == "" {
country = currentCountry
}
// Apply changes
cfg.Auth.APIURL = apiURL
cfg.Auth.APIKey = apiKey
cfg.General.Country = country
// Save
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("could not save config: %w", err)
}
fmt.Println()
green.Printf(" Configuration saved to %s\n", configPath)
fmt.Println()
return nil
}
// isTerminal checks if stdin is a terminal.
func isTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}

280
internal/cmd/daemon.go Normal file
View file

@ -0,0 +1,280 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
)
// newStartCmd creates the top-level `unarr start` command.
func newStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the download daemon (foreground)",
Long: `Start the unarr daemon in the foreground.
Registers with the server, polls for download tasks, and executes them
using the configured download method. Press Ctrl+C to stop gracefully.`,
Example: ` unarr start
unarr start --config /path/to/config.toml`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonStart()
},
}
}
// newStopCmd creates the top-level `unarr stop` placeholder.
func newStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the running daemon",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Use Ctrl+C in the terminal where the daemon is running.")
fmt.Println(" (Signal-based stop coming in a future release)")
return nil
},
}
}
// newDaemonCmd creates `unarr daemon` for administrative subcommands.
func newDaemonCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "daemon",
Short: "Daemon administration (install, uninstall, logs)",
Long: "Administrative commands for managing the daemon as a system service.",
}
cmd.AddCommand(
newDaemonInstallCmd(),
newDaemonUninstallCmd(),
)
return cmd
}
func newDaemonInstallCmd() *cobra.Command {
return &cobra.Command{
Use: "install",
Short: "Install daemon as a system service (systemd/launchd)",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Service installation coming in a future release.")
fmt.Println(" For now, use: unarr start")
return nil
},
}
}
func newDaemonUninstallCmd() *cobra.Command {
return &cobra.Command{
Use: "uninstall",
Short: "Remove daemon system service",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(" Service uninstall coming in a future release.")
return nil
},
}
}
func runDaemonStart() error {
cfg := loadConfig()
bold := color.New(color.Bold)
// Validate config
if cfg.Auth.APIKey == "" {
return fmt.Errorf("no API key configured — run 'unarr setup' first")
}
if cfg.Agent.ID == "" {
return fmt.Errorf("no agent ID — run 'unarr setup' first")
}
if cfg.Download.Dir == "" {
return fmt.Errorf("no download directory — run 'unarr setup' first")
}
// Validate configured paths are safe
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
// Ensure download dir exists
if err := os.MkdirAll(cfg.Download.Dir, 0o755); err != nil {
return fmt.Errorf("create download dir: %w", err)
}
fmt.Println()
bold.Println(" unarr Daemon")
fmt.Println()
// Parse intervals
pollInterval, _ := time.ParseDuration(cfg.Daemon.PollInterval)
if pollInterval == 0 {
pollInterval = 30 * time.Second
}
heartbeatInterval, _ := time.ParseDuration(cfg.Daemon.HeartbeatInterval)
if heartbeatInterval == 0 {
heartbeatInterval = 30 * time.Second
}
// Create agent client
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
// Create daemon
daemonCfg := agent.DaemonConfig{
AgentID: cfg.Agent.ID,
AgentName: cfg.Agent.Name,
Version: Version,
DownloadDir: cfg.Download.Dir,
PollInterval: pollInterval,
HeartbeatInterval: heartbeatInterval,
}
d := agent.NewDaemon(daemonCfg, ac)
// Create progress reporter
reporter := engine.NewProgressReporter(ac, 3*time.Second)
// Parse speed limits
maxDl, _ := config.ParseSpeed(cfg.Download.MaxDownloadSpeed)
maxUl, _ := config.ParseSpeed(cfg.Download.MaxUploadSpeed)
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir,
StallTimeout: 90 * time.Second,
MaxTimeout: 30 * time.Minute,
MaxDownloadRate: maxDl,
MaxUploadRate: maxUl,
SeedEnabled: false,
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)
}
if maxDl > 0 || maxUl > 0 {
dlStr, ulStr := "unlimited", "unlimited"
if maxDl > 0 {
dlStr = formatSpeedLog(maxDl)
}
if maxUl > 0 {
ulStr = formatSpeedLog(maxUl)
}
log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr)
}
// Create download manager
manager := engine.NewManager(engine.ManagerConfig{
MaxConcurrent: cfg.Download.MaxConcurrent,
OutputDir: cfg.Download.Dir,
Notifications: cfg.Notifications.Enabled,
Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
},
}, reporter, torrentDl)
// Wire: server-side signals -> manager actions + stream tasks
reporter.SetCancelHandler(func(taskID string) {
manager.CancelTask(taskID)
cancelStreamTask(taskID)
})
reporter.SetPauseHandler(func(taskID string) {
manager.PauseTask(taskID)
cancelStreamTask(taskID)
})
reporter.SetDeleteFilesHandler(func(taskID string) {
manager.CancelAndDeleteFiles(taskID)
cancelStreamTask(taskID)
})
// Wire: stream requested on active download → start HTTP server
reporter.SetStreamRequestedHandler(func(taskID string) {
task := manager.GetTask(taskID)
if task == nil {
log.Printf("[%s] stream requested but task not found in manager", taskID[:8])
return
}
if task.GetStreamURL() != "" {
return // already streaming
}
srv, err := torrentDl.StartStream(taskID)
if err != nil {
log.Printf("[%s] stream failed: %v", taskID[:8], err)
return
}
// Register server before setting URL to avoid TOCTOU race
streamRegistry.mu.Lock()
streamRegistry.servers[taskID] = srv
streamRegistry.mu.Unlock()
task.SetStreamURL(srv.URL())
})
// Wire: daemon claimed tasks -> manager
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks {
if t.Mode == "stream" {
go handleStreamTask(ctx, t, reporter, cfg)
} else if manager.HasCapacity() {
manager.Submit(ctx, t)
} else {
log.Printf("[%s] skipped: no capacity (max %d)", t.ID[:8], cfg.Download.MaxConcurrent)
}
}
}
// Signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Start progress reporter in background
go reporter.Run(ctx)
// Start daemon (blocks)
errCh := make(chan error, 1)
go func() {
errCh <- d.Run(ctx)
}()
// Wait for signal or error
select {
case sig := <-sigCh:
fmt.Printf("\n Received %s, shutting down...\n", sig)
cancel()
// Give active downloads 30s to finish
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
manager.Shutdown(shutdownCtx)
fmt.Println(" Daemon stopped.")
return nil
case err := <-errCh:
cancel()
return err
}
}
func formatSpeedLog(bps int64) string {
switch {
case bps >= 1024*1024*1024:
return fmt.Sprintf("%.1f GB/s", float64(bps)/(1024*1024*1024))
case bps >= 1024*1024:
return fmt.Sprintf("%.1f MB/s", float64(bps)/(1024*1024))
case bps >= 1024:
return fmt.Sprintf("%.0f KB/s", float64(bps)/1024)
default:
return fmt.Sprintf("%d B/s", bps)
}
}

211
internal/cmd/doctor.go Normal file
View file

@ -0,0 +1,211 @@
package cmd
import (
"context"
"fmt"
"os"
"runtime"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
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.",
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor()
},
}
}
func runDoctor() error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
red := color.New(color.FgRed)
yellow := color.New(color.FgYellow)
fmt.Println()
bold.Println(" unarr Diagnostics")
fmt.Println()
pass := 0
fail := 0
warn := 0
check := func(name string, fn func() (string, error)) {
msg, err := fn()
if err != nil {
red.Printf(" x %s", name)
if msg != "" {
fmt.Printf(" — %s", msg)
}
fmt.Println()
fail++
} else if msg != "" && msg[0] == '!' {
yellow.Printf(" ! %s", name)
fmt.Printf(" — %s", msg[1:])
fmt.Println()
warn++
} else {
green.Printf(" + %s", name)
if msg != "" {
fmt.Printf(" — %s", msg)
}
fmt.Println()
pass++
}
}
// Config
bold.Println(" Config")
cfg := loadConfig()
check("Config file", func() (string, error) {
path := config.FilePath()
if cfgFile != "" {
path = cfgFile
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return path + " (not found, run unarr setup)", fmt.Errorf("missing")
}
return path, nil
})
check("API key configured", func() (string, error) {
key := apiKeyFlag
if key == "" {
key = cfg.Auth.APIKey
}
if key == "" {
return "run unarr setup to configure", fmt.Errorf("missing")
}
if len(key) > 8 {
return key[:8] + "...", nil
}
return "set", nil
})
fmt.Println()
bold.Println(" Connectivity")
// API connectivity
check("API reachable", func() (string, error) {
client := getClient()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
_, err := client.Health(ctx)
elapsed := time.Since(start)
if err != nil {
return cfg.Auth.APIURL, err
}
return fmt.Sprintf("%s (%dms)", cfg.Auth.APIURL, elapsed.Milliseconds()), nil
})
// Agent registration
check("Agent registration", func() (string, error) {
key := apiKeyFlag
if key == "" {
key = cfg.Auth.APIKey
}
if key == "" {
return "no API key", fmt.Errorf("skipped")
}
if cfg.Agent.ID == "" {
return "no agent ID, run unarr setup", fmt.Errorf("not registered")
}
ac := agent.NewClient(cfg.Auth.APIURL, key, "unarr/"+Version)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
})
if err != nil {
return "", err
}
return fmt.Sprintf("%s (%s) [%s]", resp.User.Name, resp.User.Email, resp.User.Plan), nil
})
fmt.Println()
bold.Println(" Downloads")
check("Download directory", func() (string, error) {
dir := cfg.Download.Dir
if dir == "" {
return "not configured, run unarr setup", fmt.Errorf("missing")
}
fi, err := os.Stat(dir)
if os.IsNotExist(err) {
return dir + " (does not exist)", fmt.Errorf("missing")
}
if !fi.IsDir() {
return dir + " (not a directory)", fmt.Errorf("invalid")
}
return dir, nil
})
check("Download dir writable", func() (string, error) {
dir := cfg.Download.Dir
if dir == "" {
return "", fmt.Errorf("not configured")
}
tmpFile := dir + "/.unarr_write_test"
f, err := os.Create(tmpFile)
if err != nil {
return "", fmt.Errorf("not writable: %w", err)
}
f.Close()
os.Remove(tmpFile)
return "OK", nil
})
check("Disk space", func() (string, error) {
dir := cfg.Download.Dir
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
})
fmt.Println()
bold.Println(" Version")
check("unarr version", func() (string, error) {
return fmt.Sprintf("%s (%s/%s)", Version, runtime.GOOS, runtime.GOARCH), nil
})
// Summary
fmt.Println()
if fail == 0 && warn == 0 {
green.Println(" All checks passed!")
} else if fail == 0 {
yellow.Printf(" %d passed, %d warnings\n", pass, warn)
} else {
red.Printf(" %d passed, %d failed, %d warnings\n", pass, fail, warn)
}
fmt.Println()
return nil
}

149
internal/cmd/download.go Normal file
View file

@ -0,0 +1,149 @@
package cmd
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
)
func newDownloadCmd() *cobra.Command {
var method string
cmd := &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.`,
Example: ` unarr download abc123def456abc123def456abc123def456abc1
unarr download "magnet:?xt=urn:btih:..." --method torrent`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runDownload(args[0], method)
},
}
cmd.Flags().StringVar(&method, "method", "torrent", "download method: torrent (default)")
return cmd
}
func runDownload(input, method string) error {
cfg := loadConfig()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
// Parse input
parsed := parser.Parse(input)
infoHash := parsed.InfoHash
if infoHash == "" {
// Treat as info hash directly if 40 hex chars
input = strings.TrimSpace(input)
if len(input) == 40 {
infoHash = strings.ToLower(input)
} else {
return fmt.Errorf("invalid input: provide a 40-char info hash or magnet URI")
}
}
outputDir := cfg.Download.Dir
if outputDir == "" {
home, _ := os.UserHomeDir()
outputDir = home
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
fmt.Println()
bold.Printf(" Downloading %s...\n", infoHash[:16]+"...")
fmt.Printf(" Method: %s | Output: %s\n", method, outputDir)
fmt.Println()
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: outputDir,
StallTimeout: 90 * time.Second,
MaxTimeout: 60 * time.Minute,
SeedEnabled: false,
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)
}
// Create a dummy reporter (no API reporting for one-shot)
reporter := engine.NewProgressReporter(
agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
5*time.Second,
)
manager := engine.NewManager(engine.ManagerConfig{
MaxConcurrent: 1,
OutputDir: outputDir,
Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
},
}, reporter, torrentDl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("\n Cancelling download...")
cancel()
}()
// Start progress reporter
go reporter.Run(ctx)
// Submit task
task := agent.Task{
ID: "oneshot-" + infoHash[:8],
InfoHash: infoHash,
Title: parsed.Name,
PreferredMethod: method,
}
manager.Submit(ctx, task)
manager.Wait()
// Check result
active := manager.ActiveTasks()
if len(active) == 0 {
green.Println(" Download complete!")
} else {
for _, t := range active {
if t.GetStatus() == engine.StatusFailed {
return fmt.Errorf("download failed: %s", t.ErrorMessage)
}
}
}
// Shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
manager.Shutdown(shutdownCtx)
cancel()
log.SetOutput(os.Stderr) // suppress cleanup logs
fmt.Println()
return nil
}

102
internal/cmd/inspect.go Normal file
View file

@ -0,0 +1,102 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newInspectCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "inspect <magnet|hash|name>",
Short: "Inspect a torrent — TrueSpec analysis",
Long: `Analyze a torrent by magnet URI, info hash, or name.
Parses the torrent metadata (quality, codec, language, year), queries unarr
for enriched data, and shows a detailed TrueSpec report including quality score,
seed health, and available alternatives.`,
Example: ` unarr inspect "magnet:?xt=urn:btih:ABC123&dn=Movie.2023.1080p"
unarr inspect abc123def456... (40-char info hash)
unarr inspect "Oppenheimer.2023.1080p.BluRay.x265"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := args[0]
parsed := parser.Parse(input)
client := getClient()
ctx := context.Background()
// Determine search query
searchQuery := parsed.Name
if searchQuery == "" && parsed.InfoHash != "" {
searchQuery = parsed.InfoHash
}
if searchQuery == "" {
return fmt.Errorf("could not extract a name or hash from input")
}
// Clean the name for searching
cleanQuery := parser.ExtractSearchQuery(searchQuery)
if cleanQuery == "" {
cleanQuery = searchQuery
}
// Search for enriched data
params := tc.SearchParams{
Query: cleanQuery,
Quality: parsed.Quality,
}
resp, err := client.Search(ctx, params)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
// Find matching result
if len(resp.Results) == 0 {
if jsonOut {
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"parsed": parsed,
"found": false,
})
}
ui.PrintInspect(searchQuery, parsed.Year, nil, magnetURI(input, parsed))
return nil
}
result := resp.Results[0]
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"parsed": parsed,
"found": true,
"content": result,
})
}
year := ui.FormatYear(result.Year)
ui.PrintInspect(result.Title, year, result.Torrents, magnetURI(input, parsed))
return nil
},
}
return cmd
}
func magnetURI(input string, parsed parser.ParsedTorrent) string {
if parsed.IsMagnet {
return input
}
if parsed.InfoHash != "" {
return fmt.Sprintf("magnet:?xt=urn:btih:%s", parsed.InfoHash)
}
return ""
}

51
internal/cmd/popular.go Normal file
View file

@ -0,0 +1,51 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newPopularCmd() *cobra.Command {
var (
limit int
page int
)
cmd := &cobra.Command{
Use: "popular",
Short: "Show popular content",
Long: "Display the most popular movies and TV shows, ranked by community engagement.",
Example: ` unarr popular
unarr popular --limit 20
unarr popular --page 2 --json`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Popular(context.Background(), tc.PopularParams{Limit: limit, Page: page})
if err != nil {
return fmt.Errorf("failed to fetch popular content: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintPopularItems(resp.Items)
return nil
},
}
cmd.Flags().IntVar(&limit, "limit", 10, "number of results")
cmd.Flags().IntVar(&page, "page", 0, "page number")
return cmd
}

51
internal/cmd/recent.go Normal file
View file

@ -0,0 +1,51 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newRecentCmd() *cobra.Command {
var (
limit int
page int
)
cmd := &cobra.Command{
Use: "recent",
Short: "Show recently added content",
Long: "Display the most recently added movies and TV shows to the catalog.",
Example: ` unarr recent
unarr recent --limit 20
unarr recent --page 2 --json`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Recent(context.Background(), tc.RecentParams{Limit: limit, Page: page})
if err != nil {
return fmt.Errorf("failed to fetch recent content: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintRecentItems(resp.Items)
return nil
},
}
cmd.Flags().IntVar(&limit, "limit", 10, "number of results")
cmd.Flags().IntVar(&page, "page", 0, "page number")
return cmd
}

126
internal/cmd/root.go Normal file
View file

@ -0,0 +1,126 @@
package cmd
import (
"fmt"
"os"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
tc "github.com/torrentclaw/go-client"
)
var (
cfgFile string
apiKeyFlag string
jsonOut bool
noColor bool
rootCmd *cobra.Command
apiClient *tc.Client
appCfg config.Config
cfgLoaded bool
)
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Short: "unarr — torrent search and management",
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.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
},
SilenceUsage: true,
SilenceErrors: true,
}
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")
rootCmd.AddCommand(
newSetupCmd(),
newStartCmd(),
newStopCmd(),
newDaemonCmd(),
newDownloadCmd(),
newStatusCmd(),
newSearchCmd(),
newInspectCmd(),
newPopularCmd(),
newRecentCmd(),
newStatsCmd(),
newWatchCmd(),
newConfigCmd(),
newDoctorCmd(),
newVersionCmd(),
// 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"),
)
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err))
os.Exit(1)
}
}
// loadConfig loads config once (lazy initialization).
func loadConfig() config.Config {
if cfgLoaded {
return appCfg
}
var err error
appCfg, err = config.Load(cfgFile)
if err != nil {
fmt.Fprintln(os.Stderr, color.YellowString("Warning: config load failed: %s", err))
appCfg = config.Default()
}
appCfg.ApplyEnvOverrides()
cfgLoaded = true
return appCfg
}
// getClient returns a configured API client, initializing it on first use.
func getClient() *tc.Client {
if apiClient != nil {
return apiClient
}
cfg := loadConfig()
var opts []tc.Option
if cfg.Auth.APIURL != "" {
opts = append(opts, tc.WithBaseURL(cfg.Auth.APIURL))
}
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
}
if apiKey != "" {
opts = append(opts, tc.WithAPIKey(apiKey))
}
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
apiClient = tc.NewClient(opts...)
return apiClient
}

89
internal/cmd/search.go Normal file
View file

@ -0,0 +1,89 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newSearchCmd() *cobra.Command {
var (
contentType string
quality string
lang string
genre string
yearMin int
yearMax int
minRating float64
sort string
limit int
page int
country string
)
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search for movies and TV shows",
Long: `Search the catalog with advanced filters.
Results include torrent quality scores, seed health, and metadata from 30+ sources.`,
Example: ` unarr search "breaking bad" --type show --quality 1080p
unarr search "oppenheimer" --sort seeders --limit 5
unarr search "inception" --lang es --min-rating 7
unarr search "matrix" --json | jq '.results[].title'`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
params := tc.SearchParams{
Query: strings.Join(args, " "),
Type: contentType,
Quality: quality,
Language: lang,
Genre: genre,
YearMin: yearMin,
YearMax: yearMax,
MinRating: minRating,
Sort: sort,
Limit: limit,
Page: page,
Country: country,
}
resp, err := client.Search(context.Background(), params)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintSearchResults(resp)
return nil
},
}
cmd.Flags().StringVar(&contentType, "type", "", "content type: movie, show")
cmd.Flags().StringVar(&quality, "quality", "", "video quality: 480p, 720p, 1080p, 2160p")
cmd.Flags().StringVar(&lang, "lang", "", "audio language (ISO 639 code, e.g. es, en)")
cmd.Flags().StringVar(&genre, "genre", "", "genre filter (e.g. Action, Comedy, Drama)")
cmd.Flags().IntVar(&yearMin, "year-min", 0, "minimum release year")
cmd.Flags().IntVar(&yearMax, "year-max", 0, "maximum release year")
cmd.Flags().Float64Var(&minRating, "min-rating", 0, "minimum IMDb/TMDb rating (0-10)")
cmd.Flags().StringVar(&sort, "sort", "", "sort order: relevance, seeders, year, rating, added")
cmd.Flags().IntVar(&limit, "limit", 0, "results per page (1-50)")
cmd.Flags().IntVar(&page, "page", 0, "page number")
cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)")
return cmd
}

281
internal/cmd/setup.go Normal file
View file

@ -0,0 +1,281 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
)
func newSetupCmd() *cobra.Command {
var apiURL string
cmd := &cobra.Command{
Use: "setup",
Short: "First-time configuration wizard",
Long: "Interactive setup that configures API key, download directory, and preferred download method.",
RunE: func(cmd *cobra.Command, args []string) error {
return runSetup(apiURL)
},
}
cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)")
return cmd
}
func runSetup(apiURLOverride string) error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
cyan := color.New(color.FgCyan)
fmt.Println()
bold.Println(" unarr Setup")
fmt.Println()
cfg := loadConfig()
// Determine API URL
apiURL := cfg.Auth.APIURL
if apiURLOverride != "" {
apiURL = apiURLOverride
}
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
// Open browser to API keys page
keysURL := apiURL + "/profile?tab=apikey"
fmt.Printf(" Opening %s ...\n", keysURL)
openBrowser(keysURL)
fmt.Println()
// Step 1: API Key
apiKey := cfg.Auth.APIKey
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("API Key").
Description("Copy it from the page that just opened in your browser").
Placeholder("tc_...").
Value(&apiKey).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("API key is required")
}
if !strings.HasPrefix(s, "tc_") {
return fmt.Errorf("API key should start with tc_")
}
return nil
}),
),
).Run()
if err != nil {
return err
}
apiKey = strings.TrimSpace(apiKey)
// Validate API key by registering with the server
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
agentName = hostname
}
ac := agent.NewClient(apiURL, apiKey, "unarr/"+Version)
resp, err := ac.Register(context.Background(), agent.RegisterRequest{
AgentID: agentID,
Name: agentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
DownloadDir: cfg.Download.Dir,
})
if err != nil {
color.Red("FAILED")
fmt.Println()
return fmt.Errorf("API key validation failed: %w", err)
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
// Step 2: Download directory
downloadDir := cfg.Download.Dir
if downloadDir == "" {
downloadDir = defaultDownloadDir()
}
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Download Directory").
Description("Where should downloaded files be saved?").
Value(&downloadDir),
),
).Run()
if err != nil {
return err
}
downloadDir = expandHome(strings.TrimSpace(downloadDir))
// Step 3: Preferred download method
method := cfg.Download.PreferredMethod
if method == "" {
method = "auto"
}
methodOptions := []huh.Option[string]{
huh.NewOption("Auto (torrent, debrid when available)", "auto"),
huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"),
}
if resp.Features.Debrid {
methodOptions = append(methodOptions,
huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"),
)
}
if resp.Features.Usenet {
methodOptions = append(methodOptions,
huh.NewOption("Usenet only (requires Pro)", "usenet"),
)
}
err = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Download Method").
Description("How do you want to download?").
Options(methodOptions...).
Value(&method),
),
).Run()
if err != nil {
return err
}
// Step 4: Agent name
err = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Device Name").
Description("A name for this machine (shown in the web dashboard)").
Value(&agentName),
),
).Run()
if err != nil {
return err
}
// Save config
cfg.Auth.APIKey = apiKey
cfg.Auth.APIURL = apiURL
cfg.Agent.ID = agentID
cfg.Agent.Name = strings.TrimSpace(agentName)
cfg.Download.Dir = downloadDir
cfg.Download.PreferredMethod = method
// Set organize dirs based on download dir
if cfg.Organize.MoviesDir == "" {
cfg.Organize.MoviesDir = filepath.Join(downloadDir, "Movies")
}
if cfg.Organize.TVShowsDir == "" {
cfg.Organize.TVShowsDir = filepath.Join(downloadDir, "TV Shows")
}
// Validate paths before saving
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
}
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
// Summary
fmt.Println()
green.Println(" Setup complete!")
fmt.Println()
fmt.Printf(" User: %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Printf(" Downloads: %s\n", downloadDir)
fmt.Printf(" Method: %s\n", method)
fmt.Printf(" Agent: %s (%s)\n", agentName, agentID[:8]+"...")
fmt.Printf(" Config: %s\n", configPath)
fmt.Println()
// Features summary
features := []string{}
if resp.Features.Torrent {
features = append(features, "Torrent")
}
if resp.Features.Debrid {
features = append(features, "Debrid")
}
if resp.Features.Usenet {
features = append(features, "Usenet")
}
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
fmt.Println()
fmt.Println(" Next: run", bold.Sprint("unarr daemon start"), "to begin downloading")
fmt.Println()
return nil
}
// openBrowser opens a URL in the default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd
cmd = exec.Command("xdg-open", url)
}
cmd.Start() // fire and forget
}
func defaultDownloadDir() string {
home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, "Media"),
filepath.Join(home, "Downloads", "unarr"),
}
for _, d := range candidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return d
}
}
return filepath.Join(home, "Media")
}
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, path[2:])
}
return path
}

40
internal/cmd/stats.go Normal file
View file

@ -0,0 +1,40 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
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`,
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
resp, err := client.Stats(context.Background())
if err != nil {
return fmt.Errorf("failed to fetch stats: %w", err)
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
ui.PrintStats(resp)
return nil
},
}
return cmd
}

47
internal/cmd/status.go Normal file
View file

@ -0,0 +1,47 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
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.",
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus()
},
}
}
func runStatus() error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
fmt.Println()
bold.Printf(" unarr %s\n", Version)
fmt.Println()
cfg := loadConfig()
if cfg.Auth.APIKey == "" {
dim.Println(" Not configured. Run 'unarr setup' first.")
fmt.Println()
return nil
}
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...")
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
fmt.Println()
dim.Println(" Daemon not running. Start with 'unarr daemon start'")
dim.Println(" (Live status will be shown here when daemon is running)")
fmt.Println()
return nil
}

206
internal/cmd/stream.go Normal file
View file

@ -0,0 +1,206 @@
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newStreamCmd() *cobra.Command {
var (
port int
noOpen bool
playerCmd string
)
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.`,
Example: ` unarr stream abc123def456abc123def456abc123def456abc1
unarr stream "magnet:?xt=urn:btih:..." --port 8080
unarr stream <hash> --player mpv
unarr stream <hash> --no-open`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runStream(args[0], port, noOpen, playerCmd)
},
}
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)")
return cmd
}
func runStream(input string, port int, noOpen bool, playerCmd string) error {
cfg := loadConfig()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
dim := color.New(color.FgHiBlack)
// Parse input
parsed := parser.Parse(input)
magnetOrHash := input
if parsed.InfoHash != "" && !parsed.IsMagnet {
magnetOrHash = parsed.InfoHash
} else if parsed.InfoHash == "" {
trimmed := strings.TrimSpace(input)
if len(trimmed) == 40 {
magnetOrHash = strings.ToLower(trimmed)
} else if !strings.HasPrefix(trimmed, "magnet:") {
return fmt.Errorf("invalid input: provide a 40-char info hash or magnet URI")
}
}
// Data directory
dataDir := cfg.Download.Dir
if dataDir == "" {
dataDir = filepath.Join(os.TempDir(), "unarr-stream")
}
// Create engine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: dataDir,
Port: port,
MetaTimeout: 60 * time.Second,
NoOpen: noOpen,
PlayerCmd: playerCmd,
})
if err != nil {
return fmt.Errorf("create stream engine: %w", err)
}
// Signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Println("\n Shutting down...")
cancel()
}()
// Header
fmt.Println()
bold.Println(" unarr Stream")
fmt.Println()
// Start engine (metadata + file selection)
dim.Println(" Waiting for metadata...")
if err := eng.Start(ctx, magnetOrHash); err != nil {
eng.Shutdown(context.Background())
return err
}
fileName := eng.FileName()
fileSize := eng.FileLength()
bold.Printf(" File: %s (%s)\n", fileName, ui.FormatBytes(fileSize))
if !eng.IsVideoFile() {
yellow.Println(" Warning: no video files found, streaming largest file")
}
// Start HTTP server
srv := engine.NewStreamServer(eng, port)
streamURL, err := srv.Start(ctx)
if err != nil {
eng.Shutdown(context.Background())
return fmt.Errorf("start server: %w", err)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Println()
// Buffer before opening player
dim.Print(" Buffering...")
err = eng.WaitBuffer(ctx, func(buffered, target int64) {
pct := int(float64(buffered) / float64(target) * 100)
if pct > 100 {
pct = 100
}
fmt.Printf("\r Buffering: %d%% (%s / %s) ",
pct, ui.FormatBytes(buffered), ui.FormatBytes(target))
})
if err != nil {
srv.Shutdown(context.Background())
eng.Shutdown(context.Background())
return err
}
fmt.Println()
// Start progress tracking
eng.StartProgressLoop(ctx)
// Open player
if !noOpen {
playerName, _, openErr := engine.OpenPlayer(streamURL, playerCmd)
if openErr != nil {
yellow.Printf(" Could not open player: %s\n", openErr)
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
} else {
green.Printf(" Opened in %s\n", playerName)
}
} else {
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
}
fmt.Println()
// Progress loop until Ctrl+C
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
completed := false
for {
select {
case <-ctx.Done():
goto shutdown
case <-ticker.C:
p := eng.Progress()
pct := 0
if p.TotalBytes > 0 {
pct = int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100)
}
fmt.Printf("\r %d%% | %s/s | Peers: %d | Seeds: %d ",
pct, ui.FormatBytes(p.SpeedBps), p.Peers, p.Seeds)
if pct >= 100 && !completed {
completed = true
fmt.Println()
green.Println(" Download complete! Stream server still running. Ctrl+C to exit.")
}
}
}
shutdown:
fmt.Println()
fmt.Println()
dim.Println(" Cleaning up...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
srv.Shutdown(shutdownCtx)
eng.Shutdown(shutdownCtx)
fmt.Println(" Done.")
fmt.Println()
return nil
}

View file

@ -0,0 +1,140 @@
package cmd
import (
"context"
"log"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
"github.com/torrentclaw/torrentclaw-cli/internal/config"
"github.com/torrentclaw/torrentclaw-cli/internal/engine"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
// streamRegistry tracks active stream tasks and servers for cancellation.
var streamRegistry = struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
servers map[string]*engine.StreamServer // servers for active download streams
}{
cancels: make(map[string]context.CancelFunc),
servers: make(map[string]*engine.StreamServer),
}
// cancelStreamTask cancels a running stream task and shuts down any stream server.
func cancelStreamTask(taskID string) {
streamRegistry.mu.Lock()
if cancel, ok := streamRegistry.cancels[taskID]; ok {
cancel()
delete(streamRegistry.cancels, taskID)
}
if srv, ok := streamRegistry.servers[taskID]; ok {
srv.Shutdown(context.Background())
delete(streamRegistry.servers, taskID)
}
streamRegistry.mu.Unlock()
}
// handleStreamTask manages a streaming task lifecycle outside the Manager.
// It creates a StreamEngine, buffers, starts an HTTP server, and reports
// progress until the task is cancelled or the download completes.
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config) {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
// Register for web-initiated cancellation
streamRegistry.mu.Lock()
streamRegistry.cancels[at.ID] = cancel
streamRegistry.mu.Unlock()
defer func() {
streamRegistry.mu.Lock()
delete(streamRegistry.cancels, at.ID)
streamRegistry.mu.Unlock()
}()
task := engine.NewTaskFromAgent(at)
task.ResolvedMethod = engine.MethodTorrent
reporter.Track(task)
defer reporter.ReportFinal(context.Background(), task)
// 1. Create StreamEngine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: cfg.Download.Dir,
MetaTimeout: 60 * time.Second,
})
if err != nil {
task.ErrorMessage = "create stream engine: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
defer eng.Shutdown(context.Background())
// 2. Wait for metadata + select file
task.Transition(engine.StatusResolving)
if err := eng.Start(ctx, at.InfoHash); err != nil {
task.ErrorMessage = err.Error()
task.Transition(engine.StatusFailed)
return
}
task.FileName = eng.FileName()
task.TotalBytes = eng.FileLength()
task.Transition(engine.StatusDownloading)
log.Printf("[%s] stream: %s (%s)", at.ID[:8], eng.FileName(), ui.FormatBytes(eng.FileLength()))
// 3. Buffer initial data
if err := eng.WaitBuffer(ctx, nil); err != nil {
task.ErrorMessage = "buffering failed: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
// 4. Start HTTP server
srv := engine.NewStreamServer(eng, 0)
streamURL, err := srv.Start(ctx)
if err != nil {
task.ErrorMessage = "start HTTP server: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
defer srv.Shutdown(context.Background())
// 5. Report stream URL — the reporter will send this to the web
task.StreamURL = streamURL
log.Printf("[%s] stream ready: %s", at.ID[:8], streamURL)
// 6. Progress loop
eng.StartProgressLoop(ctx)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[%s] stream stopped", at.ID[:8])
return
case <-ticker.C:
p := eng.Progress()
task.UpdateProgress(engine.Progress{
DownloadedBytes: p.DownloadedBytes,
TotalBytes: p.TotalBytes,
SpeedBps: p.SpeedBps,
Peers: p.Peers,
Seeds: p.Seeds,
FileName: p.FileName,
})
if p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
task.Transition(engine.StatusCompleted)
log.Printf("[%s] stream download complete, server stays up until cancelled", at.ID[:8])
// Don't return — keep HTTP server running so the player
// can finish reading. The stream stops when the user
// cancels from the web or the daemon shuts down.
<-ctx.Done()
return
}
}
}
}

22
internal/cmd/stubs.go Normal file
View file

@ -0,0 +1,22 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func newStubCmd(name, short string) *cobra.Command {
return &cobra.Command{
Use: name,
Short: short + " (coming soon)",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println()
color.New(color.FgYellow).Printf(" ⚠️ '%s' is coming in a future release.\n", name)
fmt.Println()
fmt.Println(" Follow progress at: https://github.com/torrentclaw/torrentclaw-cli")
fmt.Println()
},
}
}

4
internal/cmd/version.go Normal file
View file

@ -0,0 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.2.0-dev"

View file

@ -0,0 +1,20 @@
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
func newVersionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Show unarr version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("unarr %s (%s/%s)\n", Version, runtime.GOOS, runtime.GOARCH)
},
}
return cmd
}

80
internal/cmd/watch.go Normal file
View file

@ -0,0 +1,80 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/torrentclaw-cli/internal/ui"
)
func newWatchCmd() *cobra.Command {
var country string
cmd := &cobra.Command{
Use: "watch <query>",
Short: "Find where to watch — streaming + torrents",
Long: `Search for content and show streaming availability alongside torrent options.
Shows legal streaming options first (subscription, free, rent, buy),
then torrent alternatives below. Helps you decide the best way to watch.`,
Example: ` unarr watch "oppenheimer"
unarr watch "breaking bad" --country ES
unarr watch "inception" --json`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client := getClient()
ctx := context.Background()
if country == "" {
country = loadConfig().General.Country
}
// Search for the content with country for streaming info
resp, err := client.Search(ctx, tc.SearchParams{
Query: strings.Join(args, " "),
Limit: 1,
Country: country,
})
if err != nil {
return fmt.Errorf("search failed: %w", err)
}
if len(resp.Results) == 0 {
fmt.Println("No results found.")
return nil
}
result := resp.Results[0]
// Fetch watch providers
providers, err := client.WatchProviders(ctx, result.ID, country)
if err != nil {
// Non-fatal: we can still show torrent results
providers = nil
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"content": result,
"providers": providers,
})
}
year := ui.FormatYear(result.Year)
ui.PrintWatchProviders(result.Title, year, providers, result.Torrents)
return nil
},
}
cmd.Flags().StringVar(&country, "country", "", "country code for streaming availability (e.g. US, ES)")
return cmd
}