feat: initial commit — unarr CLI
Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
This commit is contained in:
commit
29cf0a0126
85 changed files with 10178 additions and 0 deletions
109
internal/cmd/config.go
Normal file
109
internal/cmd/config.go
Normal 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
280
internal/cmd/daemon.go
Normal 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
211
internal/cmd/doctor.go
Normal 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
149
internal/cmd/download.go
Normal 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
102
internal/cmd/inspect.go
Normal 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
51
internal/cmd/popular.go
Normal 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
51
internal/cmd/recent.go
Normal 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
126
internal/cmd/root.go
Normal 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
89
internal/cmd/search.go
Normal 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
281
internal/cmd/setup.go
Normal 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
40
internal/cmd/stats.go
Normal 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
47
internal/cmd/status.go
Normal 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
206
internal/cmd/stream.go
Normal 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
|
||||
}
|
||||
140
internal/cmd/stream_handler.go
Normal file
140
internal/cmd/stream_handler.go
Normal 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
22
internal/cmd/stubs.go
Normal 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
4
internal/cmd/version.go
Normal 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"
|
||||
20
internal/cmd/version_cmd.go
Normal file
20
internal/cmd/version_cmd.go
Normal 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
80
internal/cmd/watch.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue