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

148
internal/agent/client.go Normal file
View file

@ -0,0 +1,148 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client communicates with the /api/internal/agent/* endpoints.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
userAgent string
}
// NewClient creates an agent API client.
func NewClient(baseURL, apiKey, userAgent string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
userAgent: userAgent,
}
}
// Register registers the CLI agent with the server and returns user info + features.
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
var resp RegisterResponse
if err := c.doPost(ctx, "/api/internal/agent/register", req, &resp); err != nil {
return nil, fmt.Errorf("register: %w", err)
}
return &resp, nil
}
// Heartbeat sends a periodic keep-alive signal.
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) error {
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/heartbeat", req, &resp); err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
return nil
}
// ClaimTasks polls for pending download tasks and claims them atomically.
func (c *Client) ClaimTasks(ctx context.Context, agentID string) ([]Task, error) {
url := fmt.Sprintf("/api/internal/agent/tasks?agentId=%s", agentID)
var resp TasksResponse
if err := c.doGet(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("claim tasks: %w", err)
}
return resp.Tasks, nil
}
// ReportStatus reports download progress or completion for a task.
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/status", update, &resp); err != nil {
return nil, fmt.Errorf("report status: %w", err)
}
return &resp, nil
}
// doPost sends a JSON POST request and decodes the response.
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
}
// doGet sends a GET request and decodes the response.
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
if c.userAgent != "" {
req.Header.Set("User-Agent", c.userAgent)
}
}
func (c *Client) handleResponse(resp *http.Response, dst any) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if resp.StatusCode >= 400 {
// Try to parse as JSON error
var errResp ErrorResponse
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return fmt.Errorf("API error %d: %s", resp.StatusCode, errResp.Error)
}
// Non-JSON response (e.g. HTML error page) — truncate to something readable
msg := string(body)
if len(msg) > 120 || strings.Contains(msg, "<html") || strings.Contains(msg, "<!DOCTYPE") {
msg = fmt.Sprintf("server returned %s (non-JSON response, likely a server error)", resp.Status)
}
return fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
}
if dst != nil {
if err := json.Unmarshal(body, dst); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,285 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestRegister(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/api/internal/agent/register" {
t.Errorf("path = %s, want /api/internal/agent/register", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth = %q, want Bearer test-key", r.Header.Get("Authorization"))
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("content-type = %q, want application/json", r.Header.Get("Content-Type"))
}
var req RegisterRequest
json.NewDecoder(r.Body).Decode(&req)
if req.AgentID != "agent-123" {
t.Errorf("agentId = %q, want agent-123", req.AgentID)
}
json.NewEncoder(w).Encode(RegisterResponse{
Success: true,
User: UserInfo{Name: "David", Email: "d@test.com", Plan: "pro", IsPro: true},
Features: FeatureFlags{
Debrid: true,
Usenet: false,
Torrent: true,
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Register(context.Background(), RegisterRequest{
AgentID: "agent-123",
Name: "Test Machine",
OS: "linux",
Arch: "amd64",
Version: "0.2.0",
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if !resp.Success {
t.Error("expected Success=true")
}
if resp.User.Name != "David" {
t.Errorf("user.name = %q, want David", resp.User.Name)
}
if !resp.User.IsPro {
t.Error("expected IsPro=true")
}
if !resp.Features.Debrid {
t.Error("expected debrid=true")
}
if !resp.Features.Torrent {
t.Error("expected torrent=true")
}
if resp.Features.Usenet {
t.Error("expected usenet=false")
}
}
func TestHeartbeat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/heartbeat" {
t.Errorf("path = %s, want /api/internal/agent/heartbeat", r.URL.Path)
}
var req HeartbeatRequest
json.NewDecoder(r.Body).Decode(&req)
if req.AgentID != "agent-123" {
t.Errorf("agentId = %q, want agent-123", req.AgentID)
}
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-123"})
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
}
func TestClaimTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Query().Get("agentId") != "agent-123" {
t.Errorf("agentId param = %q, want agent-123", r.URL.Query().Get("agentId"))
}
json.NewEncoder(w).Encode(TasksResponse{
Tasks: []Task{
{
ID: "task-uuid-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix (1999)",
PreferredMethod: "auto",
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
tasks, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("len(tasks) = %d, want 1", len(tasks))
}
if tasks[0].ID != "task-uuid-1" {
t.Errorf("task.ID = %q, want task-uuid-1", tasks[0].ID)
}
if tasks[0].InfoHash != "abc123def456abc123def456abc123def456abc1" {
t.Errorf("task.InfoHash = %q", tasks[0].InfoHash)
}
if tasks[0].PreferredMethod != "auto" {
t.Errorf("task.PreferredMethod = %q, want auto", tasks[0].PreferredMethod)
}
}
func TestReportStatus(t *testing.T) {
var received StatusUpdate
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/status" {
t.Errorf("path = %s, want /api/internal/agent/status", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.ReportStatus(context.Background(), StatusUpdate{
TaskID: "task-uuid-1",
Status: "downloading",
Progress: 42,
DownloadedBytes: 1073741824,
TotalBytes: 2147483648,
SpeedBps: 5242880,
ETA: 120,
ResolvedMethod: "torrent",
FileName: "The.Matrix.1999.1080p.mkv",
})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if received.TaskID != "task-uuid-1" {
t.Errorf("taskId = %q, want task-uuid-1", received.TaskID)
}
if received.Progress != 42 {
t.Errorf("progress = %d, want 42", received.Progress)
}
if received.ResolvedMethod != "torrent" {
t.Errorf("resolvedMethod = %q, want torrent", received.ResolvedMethod)
}
}
func TestClaimTasksEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(TasksResponse{Tasks: []Task{}})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
tasks, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(tasks) != 0 {
t.Errorf("expected empty tasks, got %d", len(tasks))
}
}
func TestAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(ErrorResponse{Error: "Invalid API key"})
}))
defer srv.Close()
c := NewClient(srv.URL, "bad-key", "unarr-test")
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "x"})
if err == nil {
t.Fatal("expected error for 401 response")
}
if got := err.Error(); got == "" {
t.Error("error message should not be empty")
}
}
func TestAPIError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(ErrorResponse{Error: "Task not found"})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "missing"})
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestReportStatusCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Cancelled: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1", Status: "downloading"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Cancelled {
t.Error("expected cancelled=true")
}
}
func TestReportStatusPaused(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Paused: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1", Status: "downloading"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Paused {
t.Error("expected paused=true")
}
if resp.Cancelled {
t.Error("expected cancelled=false")
}
}
func TestReportStatusDeleteFiles(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(StatusResponse{Success: true, Cancelled: true, DeleteFiles: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ReportStatus(context.Background(), StatusUpdate{TaskID: "task-1"})
if err != nil {
t.Fatalf("ReportStatus failed: %v", err)
}
if !resp.Cancelled {
t.Error("expected cancelled=true")
}
if !resp.DeleteFiles {
t.Error("expected deleteFiles=true")
}
}
func TestUserAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") != "unarr/0.2.0" {
t.Errorf("User-Agent = %q, want unarr/0.2.0", r.Header.Get("User-Agent"))
}
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr/0.2.0")
c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "x"})
}

154
internal/agent/daemon.go Normal file
View file

@ -0,0 +1,154 @@
package agent
import (
"context"
"fmt"
"log"
"runtime"
"time"
)
// DaemonConfig holds daemon runtime settings.
type DaemonConfig struct {
AgentID string
AgentName string
Version string
DownloadDir string
PollInterval time.Duration
HeartbeatInterval time.Duration
}
// Daemon manages the main loop: register, heartbeat, poll tasks.
type Daemon struct {
cfg DaemonConfig
client *Client
// Callbacks
OnTasksClaimed func(tasks []Task)
// State
User UserInfo
Features FeatureFlags
Info AgentInfo
}
// NewDaemon creates a daemon with the given config and agent client.
func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
if cfg.PollInterval == 0 {
cfg.PollInterval = 30 * time.Second
}
if cfg.HeartbeatInterval == 0 {
cfg.HeartbeatInterval = 30 * time.Second
}
return &Daemon{
cfg: cfg,
client: client,
}
}
// Register registers the agent and fetches user info + features.
func (d *Daemon) Register(ctx context.Context) error {
req := RegisterRequest{
AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
resp, err := d.client.Register(ctx, req)
if err != nil {
return fmt.Errorf("register: %w", err)
}
d.User = resp.User
d.Features = resp.Features
d.Info = AgentInfo{
ID: d.cfg.AgentID,
Name: d.cfg.AgentName,
User: resp.User,
Features: resp.Features,
StartedAt: time.Now(),
}
return nil
}
// Run starts the main daemon loop. Blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Register
if err := d.Register(ctx); err != nil {
return err
}
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
log.Printf("Polling every %s, heartbeat every %s", d.cfg.PollInterval, d.cfg.HeartbeatInterval)
heartbeatTicker := time.NewTicker(d.cfg.HeartbeatInterval)
defer heartbeatTicker.Stop()
pollTicker := time.NewTicker(d.cfg.PollInterval)
defer pollTicker.Stop()
// Initial poll immediately
d.poll(ctx)
for {
select {
case <-ctx.Done():
log.Println("Daemon shutting down...")
return nil
case <-heartbeatTicker.C:
d.heartbeat(ctx)
case <-pollTicker.C:
d.poll(ctx)
}
}
}
func (d *Daemon) heartbeat(ctx context.Context) {
req := HeartbeatRequest{
AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName,
Version: d.cfg.Version,
OS: runtime.GOOS,
DownloadDir: d.cfg.DownloadDir,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
if err := d.client.Heartbeat(ctx, req); err != nil {
log.Printf("Heartbeat failed: %v", err)
}
}
func (d *Daemon) poll(ctx context.Context) {
tasks, err := d.client.ClaimTasks(ctx, d.cfg.AgentID)
if err != nil {
log.Printf("Poll failed: %v", err)
return
}
d.Info.LastPollAt = time.Now()
if len(tasks) == 0 {
return
}
log.Printf("Claimed %d task(s)", len(tasks))
if d.OnTasksClaimed != nil {
d.OnTasksClaimed(tasks)
}
}

View file

@ -0,0 +1,17 @@
//go:build !windows
package agent
import "syscall"
// DiskInfo returns free and total bytes for the filesystem containing path.
func DiskInfo(path string) (freeBytes, totalBytes int64, err error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
}
// Available blocks * block size
freeBytes = int64(stat.Bavail) * int64(stat.Bsize)
totalBytes = int64(stat.Blocks) * int64(stat.Bsize)
return freeBytes, totalBytes, nil
}

View file

@ -0,0 +1,31 @@
//go:build windows
package agent
import (
"syscall"
"unsafe"
)
// DiskInfo returns free and total bytes for the filesystem containing path.
func DiskInfo(path string) (freeBytes, totalBytes int64, err error) {
kernel32 := syscall.NewLazyDLL("kernel32.dll")
getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW")
pathPtr, err := syscall.UTF16PtrFromString(path)
if err != nil {
return 0, 0, err
}
var freeBytesAvailable, totalNumberOfBytes uint64
r1, _, e1 := getDiskFreeSpaceEx.Call(
uintptr(unsafe.Pointer(pathPtr)),
uintptr(unsafe.Pointer(&freeBytesAvailable)),
uintptr(unsafe.Pointer(&totalNumberOfBytes)),
0,
)
if r1 == 0 {
return 0, 0, e1
}
return int64(freeBytesAvailable), int64(totalNumberOfBytes), nil
}

115
internal/agent/types.go Normal file
View file

@ -0,0 +1,115 @@
package agent
import "time"
// RegisterRequest is sent by the CLI on startup to register itself.
type RegisterRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
// RegisterResponse is returned by the server after registration.
type RegisterResponse struct {
Success bool `json:"success"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
// UserInfo holds the authenticated user's profile.
type UserInfo struct {
Name string `json:"name"`
Email string `json:"email"`
Plan string `json:"plan"`
IsPro bool `json:"isPro"`
}
// FeatureFlags indicates which download methods are available.
type FeatureFlags struct {
Debrid bool `json:"debrid"`
Usenet bool `json:"usenet"`
UsenetServer *UsenetServerInfo `json:"usenetServer,omitempty"`
Torrent bool `json:"torrent"`
}
// UsenetServerInfo holds NNTP connection details.
type UsenetServerInfo struct {
Host string `json:"host"`
Port int `json:"port"`
SSL bool `json:"ssl"`
}
// HeartbeatRequest is sent every 30s to keep the agent alive.
type HeartbeatRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
// Task represents a download task claimed from the server.
type Task struct {
ID string `json:"id"`
InfoHash string `json:"infoHash"`
Title string `json:"title"`
ContentID *int `json:"contentId,omitempty"`
IMDbID string `json:"imdbId,omitempty"`
PreferredMethod string `json:"preferredMethod"` // auto | debrid | usenet | torrent
Mode string `json:"mode,omitempty"` // download | stream
}
// TasksResponse wraps the array of tasks returned by the server.
type TasksResponse struct {
Tasks []Task `json:"tasks"`
}
// StatusUpdate is sent by the CLI to report download progress.
type StatusUpdate struct {
TaskID string `json:"taskId"`
Status string `json:"status,omitempty"` // downloading | completed | failed
Progress int `json:"progress,omitempty"` // 0-100
DownloadedBytes int64 `json:"downloadedBytes,omitempty"`
TotalBytes int64 `json:"totalBytes,omitempty"`
SpeedBps int64 `json:"speedBps,omitempty"`
ETA int `json:"eta,omitempty"` // seconds remaining
ResolvedMethod string `json:"resolvedMethod,omitempty"`
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}
// StatusResponse is returned by the status endpoint.
// Includes flags the CLI must act on.
type StatusResponse struct {
Success bool `json:"success"`
Cancelled bool `json:"cancelled,omitempty"`
Paused bool `json:"paused,omitempty"`
DeleteFiles bool `json:"deleteFiles,omitempty"`
StreamRequested bool `json:"streamRequested,omitempty"`
}
// ErrorResponse is returned on API errors.
type ErrorResponse struct {
Error string `json:"error"`
Details any `json:"details,omitempty"`
}
// AgentInfo holds metadata about the running agent for display.
type AgentInfo struct {
ID string
Name string
User UserInfo
Features FeatureFlags
StartedAt time.Time
LastPollAt time.Time
ActiveTasks int
}

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
}

288
internal/config/config.go Normal file
View file

@ -0,0 +1,288 @@
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// Config holds all persistent CLI configuration.
type Config struct {
Auth AuthConfig `toml:"auth"`
Agent AgentConfig `toml:"agent"`
Download DownloadConfig `toml:"downloads"`
Organize OrganizeConfig `toml:"organize"`
Daemon DaemonConfig `toml:"daemon"`
Notifications NotificationsConfig `toml:"notifications"`
General GeneralConfig `toml:"general"`
}
type AuthConfig struct {
APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"`
}
type AgentConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
}
type DownloadConfig struct {
Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"`
MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
TVShowsDir string `toml:"tv_shows_dir"`
}
type DaemonConfig struct {
PollInterval string `toml:"poll_interval"`
HeartbeatInterval string `toml:"heartbeat_interval"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
type GeneralConfig struct {
Country string `toml:"country"`
Locale string `toml:"locale"`
NoColor bool `toml:"no_color"`
}
// Default returns a Config with sensible defaults.
func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
},
Organize: OrganizeConfig{
Enabled: true,
},
Daemon: DaemonConfig{
PollInterval: "30s",
HeartbeatInterval: "30s",
},
Notifications: NotificationsConfig{
Enabled: true,
},
General: GeneralConfig{
Country: "US",
Locale: "en",
},
}
}
// Load reads config from the default or specified path.
// Falls back to defaults for any missing values.
// If the file does not exist, returns defaults without error.
func Load(path string) (Config, error) {
if path == "" {
path = FilePath()
}
cfg := Default()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, fmt.Errorf("read config: %w", err)
}
if err := toml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config: %w", err)
}
// Re-apply defaults for zero values that should have defaults
if cfg.Auth.APIURL == "" {
cfg.Auth.APIURL = "https://torrentclaw.com"
}
if cfg.Download.PreferredMethod == "" {
cfg.Download.PreferredMethod = "auto"
}
if cfg.Download.MaxConcurrent == 0 {
cfg.Download.MaxConcurrent = 3
}
if cfg.General.Country == "" {
cfg.General.Country = "US"
}
return cfg, nil
}
// Save writes config to the default or specified path using atomic write.
func Save(cfg Config, path string) error {
if path == "" {
path = FilePath()
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
var buf strings.Builder
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(cfg); err != nil {
return fmt.Errorf("encode config: %w", err)
}
// Atomic write: write to temp, then rename
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, []byte(buf.String()), 0o600); err != nil {
return fmt.Errorf("write temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
// ParseSpeed parses a human-readable speed string into bytes/s.
// Supports: "10MB", "500KB", "1GB", "1024", "0" (unlimited).
func ParseSpeed(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return 0, nil
}
s = strings.ToUpper(s)
multiplier := int64(1)
switch {
case strings.HasSuffix(s, "GB"):
multiplier = 1024 * 1024 * 1024
s = strings.TrimSuffix(s, "GB")
case strings.HasSuffix(s, "MB"):
multiplier = 1024 * 1024
s = strings.TrimSuffix(s, "MB")
case strings.HasSuffix(s, "KB"):
multiplier = 1024
s = strings.TrimSuffix(s, "KB")
}
n, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0, fmt.Errorf("invalid speed %q: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("speed cannot be negative: %s", s)
}
return int64(n * float64(multiplier)), nil
}
// ApplyEnvOverrides applies UNARR_* environment variable overrides.
func (c *Config) ApplyEnvOverrides() {
if v := os.Getenv("UNARR_API_KEY"); v != "" {
c.Auth.APIKey = v
}
if v := os.Getenv("UNARR_API_URL"); v != "" {
c.Auth.APIURL = v
}
if v := os.Getenv("UNARR_COUNTRY"); v != "" {
c.General.Country = v
}
if v := os.Getenv("UNARR_DOWNLOAD_DIR"); v != "" {
c.Download.Dir = v
}
}
// dangerousPaths are system-critical directories that should never be used as
// download or organize targets (per platform).
var dangerousPaths = func() map[string]bool {
m := map[string]bool{}
// Unix
for _, p := range []string{
"/", "/bin", "/sbin", "/usr", "/lib", "/lib64", "/boot", "/dev", "/proc", "/sys",
"/etc", "/var", "/tmp", "/root",
// macOS
"/System", "/Library", "/private", "/private/etc", "/private/tmp", "/private/var",
} {
m[p] = true
}
// Windows
if runtime.GOOS == "windows" {
for _, drive := range []string{"C", "D"} {
for _, p := range []string{
drive + `:\`,
drive + `:\Windows`,
drive + `:\Windows\System32`,
drive + `:\Program Files`,
drive + `:\Program Files (x86)`,
} {
m[filepath.Clean(p)] = true
}
}
}
return m
}()
// ValidatePaths checks that configured directories are safe to write to.
// Returns an error if any path points to a system directory or the user's
// home directory root (must use a subdirectory).
func (c *Config) ValidatePaths() error {
home, _ := os.UserHomeDir()
check := func(label, dir string) error {
if dir == "" {
return nil
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("%s: invalid path %q: %w", label, dir, err)
}
clean := filepath.Clean(abs)
if dangerousPaths[clean] {
return fmt.Errorf("%s: refusing to use system directory %q", label, clean)
}
// Block home root — require a subdirectory
if home != "" && clean == filepath.Clean(home) {
return fmt.Errorf("%s: use a subdirectory of your home, not %q itself", label, clean)
}
// Block hidden dirs under home (e.g. ~/.ssh, ~/.gnupg)
if home != "" && strings.HasPrefix(clean, filepath.Clean(home)+string(filepath.Separator)) {
rel, _ := filepath.Rel(home, clean)
first := strings.SplitN(rel, string(filepath.Separator), 2)[0]
if strings.HasPrefix(first, ".") && first != ".local" && first != ".config" {
return fmt.Errorf("%s: refusing to use hidden directory %q", label, clean)
}
}
return nil
}
if err := check("downloads.dir", c.Download.Dir); err != nil {
return err
}
if err := check("organize.movies_dir", c.Organize.MoviesDir); err != nil {
return err
}
if err := check("organize.tv_shows_dir", c.Organize.TVShowsDir); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,202 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefault(t *testing.T) {
cfg := Default()
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("default APIURL = %q, want https://torrentclaw.com", cfg.Auth.APIURL)
}
if cfg.Download.PreferredMethod != "auto" {
t.Errorf("default PreferredMethod = %q, want auto", cfg.Download.PreferredMethod)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("default MaxConcurrent = %d, want 3", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("default Country = %q, want US", cfg.General.Country)
}
if cfg.Daemon.HeartbeatInterval != "30s" {
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval)
}
}
func TestLoadMissingFile(t *testing.T) {
cfg, err := Load("/nonexistent/path/config.toml")
if err != nil {
t.Fatalf("Load nonexistent should return defaults, got err: %v", err)
}
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("missing file should return default APIURL, got %q", cfg.Auth.APIURL)
}
}
func TestSaveAndLoad(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
cfg := Default()
cfg.Auth.APIKey = "tc_test123"
cfg.Auth.APIURL = "https://custom.example.com"
cfg.General.Country = "ES"
cfg.Download.Dir = "/media/downloads"
cfg.Agent.ID = "agent-uuid-123"
cfg.Agent.Name = "Test Machine"
if err := Save(cfg, path); err != nil {
t.Fatalf("Save failed: %v", err)
}
// File should exist
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("config file was not created")
}
// No .tmp file left behind
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Error("temp file was not cleaned up")
}
// Load it back
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if loaded.Auth.APIKey != "tc_test123" {
t.Errorf("APIKey = %q, want tc_test123", loaded.Auth.APIKey)
}
if loaded.Auth.APIURL != "https://custom.example.com" {
t.Errorf("APIURL = %q, want https://custom.example.com", loaded.Auth.APIURL)
}
if loaded.General.Country != "ES" {
t.Errorf("Country = %q, want ES", loaded.General.Country)
}
if loaded.Download.Dir != "/media/downloads" {
t.Errorf("Dir = %q, want /media/downloads", loaded.Download.Dir)
}
if loaded.Agent.ID != "agent-uuid-123" {
t.Errorf("AgentID = %q, want agent-uuid-123", loaded.Agent.ID)
}
if loaded.Agent.Name != "Test Machine" {
t.Errorf("AgentName = %q, want Test Machine", loaded.Agent.Name)
}
}
func TestLoadPreservesDefaults(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// Write partial config (only auth section)
os.WriteFile(path, []byte(`[auth]
api_key = "tc_partial"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Auth.APIKey != "tc_partial" {
t.Errorf("APIKey = %q, want tc_partial", cfg.Auth.APIKey)
}
// Defaults should be preserved for missing sections
if cfg.Auth.APIURL != "https://torrentclaw.com" {
t.Errorf("APIURL should default, got %q", cfg.Auth.APIURL)
}
if cfg.Download.MaxConcurrent != 3 {
t.Errorf("MaxConcurrent should default to 3, got %d", cfg.Download.MaxConcurrent)
}
if cfg.General.Country != "US" {
t.Errorf("Country should default to US, got %q", cfg.General.Country)
}
}
func TestApplyEnvOverrides(t *testing.T) {
cfg := Default()
t.Setenv("UNARR_API_KEY", "tc_env_key")
t.Setenv("UNARR_API_URL", "https://env.example.com")
t.Setenv("UNARR_COUNTRY", "DE")
t.Setenv("UNARR_DOWNLOAD_DIR", "/env/downloads")
cfg.ApplyEnvOverrides()
if cfg.Auth.APIKey != "tc_env_key" {
t.Errorf("APIKey = %q, want tc_env_key", cfg.Auth.APIKey)
}
if cfg.Auth.APIURL != "https://env.example.com" {
t.Errorf("APIURL = %q, want https://env.example.com", cfg.Auth.APIURL)
}
if cfg.General.Country != "DE" {
t.Errorf("Country = %q, want DE", cfg.General.Country)
}
if cfg.Download.Dir != "/env/downloads" {
t.Errorf("Dir = %q, want /env/downloads", cfg.Download.Dir)
}
}
func TestSaveCreatesDirectory(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "nested", "deep", "config.toml")
cfg := Default()
if err := Save(cfg, path); err != nil {
t.Fatalf("Save with nested dir failed: %v", err)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("config file was not created in nested dir")
}
}
func TestParseSpeed(t *testing.T) {
tests := []struct {
input string
want int64
}{
{"0", 0},
{"", 0},
{"10MB", 10 * 1024 * 1024},
{"500KB", 500 * 1024},
{"1GB", 1024 * 1024 * 1024},
{"1.5MB", int64(1.5 * 1024 * 1024)},
{"10mb", 10 * 1024 * 1024},
{"1024", 1024},
}
for _, tt := range tests {
got, err := ParseSpeed(tt.input)
if err != nil {
t.Errorf("ParseSpeed(%q) error: %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("ParseSpeed(%q) = %d, want %d", tt.input, got, tt.want)
}
}
// Error cases
if _, err := ParseSpeed("abc"); err == nil {
t.Error("ParseSpeed(\"abc\") should error")
}
if _, err := ParseSpeed("-5MB"); err == nil {
t.Error("ParseSpeed(\"-5MB\") should error")
}
}
func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
os.WriteFile(path, []byte(`not valid toml [[[`), 0o644)
_, err := Load(path)
if err == nil {
t.Error("expected error for invalid TOML, got nil")
}
}

View file

@ -0,0 +1,100 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestValidatePaths_Dangerous(t *testing.T) {
dangerous := []string{"/", "/etc", "/bin", "/sbin", "/usr", "/lib", "/lib64",
"/boot", "/dev", "/proc", "/sys", "/var", "/tmp", "/root",
"/System", "/Library", "/private"}
for _, d := range dangerous {
// Test all three path fields
for _, field := range []string{"download", "movies", "tvshows"} {
cfg := Default()
switch field {
case "download":
cfg.Download.Dir = d
case "movies":
cfg.Organize.MoviesDir = d
case "tvshows":
cfg.Organize.TVShowsDir = d
}
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject %s=%q", field, d)
}
}
}
}
func TestValidatePaths_HomeRoot(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = home
if err := cfg.ValidatePaths(); err == nil {
t.Errorf("ValidatePaths() should reject home root %q", home)
}
}
func TestValidatePaths_HiddenDir(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
cfg := Default()
cfg.Download.Dir = filepath.Join(home, ".ssh")
if err := cfg.ValidatePaths(); err == nil {
t.Error("ValidatePaths() should reject ~/.ssh")
}
}
func TestValidatePaths_Valid(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
valid := []string{
filepath.Join(home, "Downloads"),
filepath.Join(home, "Media"),
filepath.Join(home, "Media", "Movies"),
"/mnt/storage/downloads",
}
for _, d := range valid {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should accept %q, got: %v", d, err)
}
}
}
func TestValidatePaths_AllowedHiddenDirs(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("no home dir")
}
// .local and .config are whitelisted
allowed := []string{
filepath.Join(home, ".local", "share", "unarr"),
filepath.Join(home, ".config", "unarr"),
}
for _, d := range allowed {
cfg := Default()
cfg.Download.Dir = d
if err := cfg.ValidatePaths(); err != nil {
t.Errorf("ValidatePaths() should allow %q, got: %v", d, err)
}
}
}

58
internal/config/paths.go Normal file
View file

@ -0,0 +1,58 @@
package config
import (
"os"
"path/filepath"
"runtime"
)
const appName = "unarr"
// Dir returns the configuration directory following XDG conventions.
// - Linux: ~/.config/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %APPDATA%/unarr
//
// Overridable via UNARR_CONFIG_DIR env var.
func Dir() string {
if d := os.Getenv("UNARR_CONFIG_DIR"); d != "" {
return d
}
switch runtime.GOOS {
case "darwin":
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Application Support", appName)
case "windows":
return filepath.Join(os.Getenv("APPDATA"), appName)
default: // linux, freebsd, etc.
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", appName)
}
}
// FilePath returns the full path to the config file.
func FilePath() string {
return filepath.Join(Dir(), "config.toml")
}
// DataDir returns the data directory for logs, cache, etc.
// - Linux: ~/.local/share/unarr
// - macOS: ~/Library/Application Support/unarr
// - Windows: %LOCALAPPDATA%/unarr
func DataDir() string {
switch runtime.GOOS {
case "darwin":
return Dir() // macOS uses same dir for config and data
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), appName)
default:
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, appName)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", appName)
}
}

View file

@ -0,0 +1,53 @@
package config
import (
"os"
"strings"
"testing"
)
func TestDir(t *testing.T) {
dir := Dir()
if dir == "" {
t.Error("Dir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("Dir() = %q, should contain 'unarr'", dir)
}
}
func TestFilePath(t *testing.T) {
path := FilePath()
if !strings.HasSuffix(path, "config.toml") {
t.Errorf("FilePath() = %q, should end with config.toml", path)
}
}
func TestDataDir(t *testing.T) {
dir := DataDir()
if dir == "" {
t.Error("DataDir() returned empty string")
}
if !strings.Contains(dir, "unarr") {
t.Errorf("DataDir() = %q, should contain 'unarr'", dir)
}
}
func TestDirOverrideEnv(t *testing.T) {
t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
dir := Dir()
if dir != "/custom/path" {
t.Errorf("Dir() with env = %q, want /custom/path", dir)
}
}
func TestDirXDGOverride(t *testing.T) {
// Clear the custom env so XDG takes effect
os.Unsetenv("UNARR_CONFIG_DIR")
t.Setenv("XDG_CONFIG_HOME", "/xdg/config")
dir := Dir()
if dir != "/xdg/config/unarr" {
t.Errorf("Dir() with XDG = %q, want /xdg/config/unarr", dir)
}
}

41
internal/engine/debrid.go Normal file
View file

@ -0,0 +1,41 @@
package engine
import (
"context"
"fmt"
tc "github.com/torrentclaw/go-client"
)
// DebridDownloader downloads via debrid services (Real-Debrid, AllDebrid, etc.).
// Currently a stub — Available() works, Download() returns not-implemented.
type DebridDownloader struct {
apiClient *tc.Client
}
// NewDebridDownloader creates a debrid downloader stub.
func NewDebridDownloader(apiClient *tc.Client) *DebridDownloader {
return &DebridDownloader{apiClient: apiClient}
}
func (d *DebridDownloader) Method() DownloadMethod { return MethodDebrid }
func (d *DebridDownloader) Available(ctx context.Context, task *Task) (bool, error) {
if d.apiClient == nil {
return false, nil
}
resp, err := d.apiClient.DebridCheckCache(ctx, "", "", []string{task.InfoHash})
if err != nil {
return false, err
}
cached, ok := resp.Cached[task.InfoHash]
return ok && cached, nil
}
func (d *DebridDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return nil, fmt.Errorf("debrid download not implemented yet (coming in a future release)")
}
func (d *DebridDownloader) Pause(_ string) error { return nil }
func (d *DebridDownloader) Cancel(_ string) error { return nil }
func (d *DebridDownloader) Shutdown(_ context.Context) error { return nil }

362
internal/engine/manager.go Normal file
View file

@ -0,0 +1,362 @@
package engine
import (
"context"
"log"
"sync"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ManagerConfig holds download manager settings.
type ManagerConfig struct {
MaxConcurrent int
OutputDir string
Organize OrganizeConfig
Notifications bool // send desktop notifications on complete/fail
}
// Manager orchestrates concurrent downloads with method resolution and fallback.
type Manager struct {
cfg ManagerConfig
reporter *ProgressReporter
downloaders map[DownloadMethod]Downloader
activeMu sync.RWMutex
active map[string]*Task
sem chan struct{}
wg sync.WaitGroup
}
// NewManager creates a download manager.
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
if cfg.MaxConcurrent <= 0 {
cfg.MaxConcurrent = 3
}
dlMap := make(map[DownloadMethod]Downloader)
for _, d := range downloaders {
dlMap[d.Method()] = d
}
return &Manager{
cfg: cfg,
reporter: reporter,
downloaders: dlMap,
active: make(map[string]*Task),
sem: make(chan struct{}, cfg.MaxConcurrent),
}
}
// Submit queues a task for download. Non-blocking if capacity available.
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
task := NewTaskFromAgent(at)
m.activeMu.Lock()
m.active[task.ID] = task
m.activeMu.Unlock()
m.reporter.Track(task)
// Acquire semaphore slot
select {
case m.sem <- struct{}{}:
case <-ctx.Done():
return
}
m.wg.Add(1)
go func() {
defer m.wg.Done()
defer func() { <-m.sem }()
m.processTask(ctx, task)
}()
}
// HasCapacity returns true if there's room for more downloads.
func (m *Manager) HasCapacity() bool {
return len(m.sem) < cap(m.sem)
}
// ActiveCount returns the number of in-progress downloads.
func (m *Manager) ActiveCount() int {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
return len(m.active)
}
// GetTask returns a single active task by ID, or nil.
func (m *Manager) GetTask(taskID string) *Task {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
return m.active[taskID]
}
// ActiveTasks returns a snapshot of all active tasks.
func (m *Manager) ActiveTasks() []*Task {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
tasks := make([]*Task, 0, len(m.active))
for _, t := range m.active {
tasks = append(tasks, t)
}
return tasks
}
// CancelTask cancels an active download by task ID (keeps partial files).
func (m *Manager) CancelTask(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Pause(taskID) // stop download, keep files
}
task.mu.Lock()
task.ErrorMessage = "cancelled by user"
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled: %s", taskID[:8], task.Title)
}
// PauseTask pauses an active download (keeps partial files for resume).
func (m *Manager) PauseTask(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Pause(taskID) // stop download, keep files for resume
}
task.Transition(StatusCancelled) // will be re-created as pending by server
log.Printf("[%s] paused: %s", taskID[:8], task.Title)
}
// CancelAndDeleteFiles cancels a download and removes its files from disk.
func (m *Manager) CancelAndDeleteFiles(taskID string) {
m.activeMu.RLock()
task, ok := m.active[taskID]
m.activeMu.RUnlock()
if !ok {
return
}
if dl, exists := m.downloaders[task.ResolvedMethod]; exists {
dl.Cancel(taskID) // stop download + delete files
}
task.mu.Lock()
task.ErrorMessage = "cancelled by user"
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled + files deleted: %s", taskID[:8], task.Title)
}
// Wait blocks until all active downloads finish.
func (m *Manager) Wait() {
m.wg.Wait()
}
// Shutdown stops accepting tasks and waits for active downloads to finish.
func (m *Manager) Shutdown(ctx context.Context) {
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
log.Println("shutdown timeout, cancelling active downloads")
}
// Shutdown all downloaders
for _, d := range m.downloaders {
if err := d.Shutdown(ctx); err != nil {
log.Printf("downloader shutdown: %v", err)
}
}
// Clean active map
m.activeMu.Lock()
m.active = make(map[string]*Task)
m.activeMu.Unlock()
}
func (m *Manager) processTask(ctx context.Context, task *Task) {
defer func() {
m.activeMu.Lock()
delete(m.active, task.ID)
m.activeMu.Unlock()
}()
// 1. Resolve method
if err := task.Transition(StatusResolving); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
method, err := resolveMethod(ctx, task, m.downloaders)
if err != nil {
m.fail(ctx, task, "no method available: "+err.Error())
return
}
task.ResolvedMethod = method
log.Printf("[%s] resolved method: %s", task.ID[:8], method)
// 2. Download
if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
progressCh := make(chan Progress, 16)
// Drain progress channel (just for logging; reporter reads directly from task)
go func() {
for range progressCh {
// Progress already applied via task.UpdateProgress in the downloader
}
}()
dl := m.downloaders[method]
result, err := dl.Download(ctx, task, m.cfg.OutputDir, progressCh)
close(progressCh)
if err != nil {
// Try fallback
if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", task.ID[:8], method, err)
if err := task.Transition(StatusResolving); err == nil {
m.processTaskRetry(ctx, task)
return
}
}
m.fail(ctx, task, err.Error())
return
}
// 3. Verify
if err := task.Transition(StatusVerifying); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
// 4. Organize
if err := task.Transition(StatusOrganizing); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
finalPath, err := organize(result, task, m.cfg.Organize)
if err != nil {
log.Printf("[%s] organize warning: %v (keeping in download dir)", task.ID[:8], err)
finalPath = result.FilePath
}
task.mu.Lock()
task.FilePath = finalPath
task.mu.Unlock()
// 5. Complete
if method == MethodTorrent && m.cfg.Organize.Enabled {
// Could add seeding here in the future
}
if err := task.Transition(StatusCompleted); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
log.Printf("[%s] completed: %s -> %s", task.ID[:8], task.Title, finalPath)
if m.cfg.Notifications {
desktopNotify("Download complete", task.Title)
}
m.reporter.ReportFinal(ctx, task)
}
// processTaskRetry handles fallback after a method failure.
func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
method, err := resolveMethod(ctx, task, m.downloaders)
if err != nil {
m.fail(ctx, task, "fallback failed: "+err.Error())
return
}
task.ResolvedMethod = method
log.Printf("[%s] fallback to: %s", task.ID[:8], method)
if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
progressCh := make(chan Progress, 16)
go func() {
for range progressCh {
}
}()
dl := m.downloaders[method]
result, err := dl.Download(ctx, task, m.cfg.OutputDir, progressCh)
close(progressCh)
if err != nil {
m.fail(ctx, task, err.Error())
return
}
// Verify + Organize + Complete (same as processTask)
task.Transition(StatusVerifying)
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
task.Transition(StatusOrganizing)
finalPath, _ := organize(result, task, m.cfg.Organize)
if finalPath == "" {
finalPath = result.FilePath
}
task.mu.Lock()
task.FilePath = finalPath
task.mu.Unlock()
task.Transition(StatusCompleted)
log.Printf("[%s] completed (fallback): %s -> %s", task.ID[:8], task.Title, finalPath)
m.reporter.ReportFinal(ctx, task)
}
func (m *Manager) fail(ctx context.Context, task *Task, msg string) {
task.mu.Lock()
task.ErrorMessage = msg
task.mu.Unlock()
task.Transition(StatusFailed)
log.Printf("[%s] FAILED: %s — %s", task.ID[:8], task.Title, msg)
if m.cfg.Notifications {
desktopNotify("Download failed", task.Title+": "+msg)
}
m.reporter.ReportFinal(ctx, task)
}

View file

@ -0,0 +1,85 @@
package engine
import (
"context"
"testing"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
func TestManagerSubmitAndWait(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &mockDownloader{method: MethodTorrent, available: true}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go reporter.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "test-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Test Movie",
PreferredMethod: "torrent",
})
mgr.Wait()
// Task should have been processed (completed or failed depending on verify)
// Since mock returns a file that doesn't exist, it may fail at verify
// This is expected — we're testing the pipeline works
}
func TestManagerHasCapacity(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 2}, reporter)
if !mgr.HasCapacity() {
t.Error("new manager should have capacity")
}
}
func TestManagerActiveCount(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 3}, reporter)
if mgr.ActiveCount() != 0 {
t.Errorf("ActiveCount = %d, want 0", mgr.ActiveCount())
}
}
func TestManagerShutdown(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &mockDownloader{method: MethodTorrent, available: true}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
mgr.Shutdown(ctx)
// Should not hang
}

58
internal/engine/method.go Normal file
View file

@ -0,0 +1,58 @@
package engine
import "context"
// DownloadMethod identifies a download strategy.
type DownloadMethod string
const (
MethodTorrent DownloadMethod = "torrent"
MethodDebrid DownloadMethod = "debrid"
MethodUsenet DownloadMethod = "usenet"
)
// Progress is emitted by downloaders during a download.
type Progress struct {
DownloadedBytes int64
TotalBytes int64
SpeedBps int64 // bytes per second
ETA int // seconds remaining
Peers int // connected peers (torrent only)
Seeds int // connected seeds (torrent only)
FileName string
}
// Result is returned when a download completes successfully.
type Result struct {
FilePath string
FileName string
Method DownloadMethod
Size int64
}
// Downloader is the interface every download method must implement.
type Downloader interface {
// Method returns which method this downloader implements.
Method() DownloadMethod
// Available reports whether this method can handle the given task.
// For torrent: always true if infoHash is set.
// For debrid: checks if cached on debrid service.
// For usenet: checks if NZB is available.
Available(ctx context.Context, task *Task) (bool, error)
// Download starts the download. It blocks until completion or error.
// Progress is reported via progressCh at regular intervals.
// outputDir is where files should be written.
Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error)
// Pause suspends an in-progress download but keeps partial files on disk
// so the download can be resumed later.
Pause(taskID string) error
// Cancel aborts an in-progress download and removes partial files.
Cancel(taskID string) error
// Shutdown gracefully shuts down the downloader.
Shutdown(ctx context.Context) error
}

30
internal/engine/notify.go Normal file
View file

@ -0,0 +1,30 @@
package engine
import (
"os/exec"
"runtime"
)
// desktopNotify sends a best-effort desktop notification.
// Silent failure — never blocks or errors.
func desktopNotify(title, body string) {
switch runtime.GOOS {
case "linux":
exec.Command("notify-send", title, body, "--icon=dialog-information", "--app-name=unarr").Start()
case "darwin":
script := `display notification "` + escapeAppleScript(body) + `" with title "` + escapeAppleScript(title) + `"`
exec.Command("osascript", "-e", script).Start()
}
// Windows: no-op for now
}
func escapeAppleScript(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
if s[i] == '"' || s[i] == '\\' {
out = append(out, '\\')
}
out = append(out, s[i])
}
return string(out)
}

129
internal/engine/organize.go Normal file
View file

@ -0,0 +1,129 @@
package engine
import (
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
)
var (
yearRegex = regexp.MustCompile(`\b(19|20)\d{2}\b`)
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
)
// OrganizeConfig holds file organization settings.
type OrganizeConfig struct {
Enabled bool
MoviesDir string
TVShowsDir string
}
// organize moves a downloaded file into the proper directory structure.
// Movies: MoviesDir/Title (Year)/filename.ext
// TV: TVShowsDir/Title/Season XX/filename.ext
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" {
return result.FilePath, nil
}
title := task.Title
if title == "" {
title = result.FileName
}
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
seasonRegex.MatchString(result.FileName)
// Detect season for TV
var season string
if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
season = m[1]
isTV = true
}
var destDir string
if isTV && cfg.TVShowsDir != "" {
showName := cleanTitle(title)
destDir = filepath.Join(cfg.TVShowsDir, showName)
if season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if cfg.MoviesDir != "" {
movieName := cleanTitle(title)
year := yearRegex.FindString(title)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", movieName, year))
} else {
destDir = filepath.Join(cfg.MoviesDir, movieName)
}
} else {
return result.FilePath, nil // no organize dirs configured
}
// Validate destination is within the expected base directory
var baseDir string
if isTV && cfg.TVShowsDir != "" {
baseDir = cfg.TVShowsDir
} else {
baseDir = cfg.MoviesDir
}
if !isWithinDir(baseDir, destDir) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err)
}
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err)
}
os.Remove(result.FilePath)
}
return destPath, nil
}
// cleanTitle extracts a clean title from a torrent title string.
func cleanTitle(title string) string {
// Remove year and everything after common separators
t := title
if idx := strings.Index(t, " ("); idx > 0 {
t = t[:idx]
}
// Remove resolution and codec markers
for _, pattern := range []string{"1080p", "720p", "2160p", "480p", "BluRay", "WEB-DL", "HDTV", "x264", "x265", "HEVC"} {
if idx := strings.Index(strings.ToLower(t), strings.ToLower(pattern)); idx > 0 {
t = t[:idx]
}
}
t = strings.TrimRight(t, " .-_")
if t == "" {
return title
}
return t
}
func copyFile(src, dst string) error {
s, err := os.Open(src)
if err != nil {
return err
}
defer s.Close()
d, err := os.Create(dst)
if err != nil {
return err
}
defer d.Close()
_, err = io.Copy(d, s)
return err
}

View file

@ -0,0 +1,92 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestOrganizeDisabled(t *testing.T) {
r := &Result{FilePath: "/tmp/file.mkv", FileName: "file.mkv"}
task := &Task{Title: "Movie"}
path, err := organize(r, task, OrganizeConfig{Enabled: false})
if err != nil {
t.Fatal(err)
}
if path != "/tmp/file.mkv" {
t.Errorf("path = %q, want original path when disabled", path)
}
}
func TestOrganizeMovie(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "src")
os.MkdirAll(srcDir, 0o755)
srcFile := filepath.Join(srcDir, "Movie.2023.1080p.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcFile, FileName: "Movie.2023.1080p.mkv"}
task := &Task{Title: "Movie 2023"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
// Should be in Movies/Movie (2023)/
if path == srcFile {
t.Error("file should have moved")
}
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s: %v", path, err)
}
}
func TestOrganizeTVShow(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Show.S02E05.1080p.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "Show.S02E05.1080p.mkv"}
task := &Task{Title: "Show S02E05"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatal(err)
}
// Should detect season from filename S02
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s: %v", path, err)
}
}
func TestCleanTitle(t *testing.T) {
tests := []struct {
input string
want string
}{
{"The Matrix (1999)", "The Matrix"},
{"Oppenheimer 2023 1080p BluRay", "Oppenheimer 2023"},
{"Movie", "Movie"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := cleanTitle(tt.input)
if got != tt.want {
t.Errorf("cleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

137
internal/engine/progress.go Normal file
View file

@ -0,0 +1,137 @@
package engine
import (
"context"
"log"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ActionFunc is called when the server signals an action on a task.
type ActionFunc func(taskID string)
// ProgressReporter aggregates progress from downloads and reports to the API.
// It batches updates to avoid flooding the server.
type ProgressReporter struct {
agentClient *agent.Client
interval time.Duration
onCancel ActionFunc
onPause ActionFunc
onDeleteFiles ActionFunc
onStreamRequested ActionFunc
mu sync.Mutex
latest map[string]*Task // taskID -> task with latest progress
}
// NewProgressReporter creates a reporter that flushes every interval.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
agentClient: ac,
interval: interval,
latest: make(map[string]*Task),
}
}
// SetCancelHandler sets the callback invoked when the server says a task is cancelled.
func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn }
// SetPauseHandler sets the callback invoked when the server says a task is paused.
func (r *ProgressReporter) SetPauseHandler(fn ActionFunc) { r.onPause = fn }
// SetDeleteFilesHandler sets the callback for cancel+delete files.
func (r *ProgressReporter) SetDeleteFilesHandler(fn ActionFunc) { r.onDeleteFiles = fn }
// SetStreamRequestedHandler sets the callback for stream activation.
func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStreamRequested = fn }
// Track registers a task for progress tracking.
func (r *ProgressReporter) Track(task *Task) {
r.mu.Lock()
defer r.mu.Unlock()
r.latest[task.ID] = task
}
// Untrack removes a task from progress tracking.
func (r *ProgressReporter) Untrack(taskID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.latest, taskID)
}
// Run starts the periodic flush loop. Blocks until ctx is cancelled.
func (r *ProgressReporter) Run(ctx context.Context) error {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
r.flush(context.Background())
return nil
case <-ticker.C:
r.flush(ctx)
}
}
}
func (r *ProgressReporter) flush(ctx context.Context) {
r.mu.Lock()
tasks := make([]*Task, 0, len(r.latest))
for _, t := range r.latest {
tasks = append(tasks, t)
}
r.mu.Unlock()
for _, task := range tasks {
status := task.GetStatus()
if status != StatusDownloading && status != StatusVerifying &&
status != StatusOrganizing && status != StatusSeeding &&
status != StatusCompleted && status != StatusFailed {
continue
}
update := task.ToStatusUpdate()
resp, err := r.agentClient.ReportStatus(ctx, update)
if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue
}
// Handle server-side signals
if resp.Cancelled {
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
r.Untrack(task.ID)
if resp.DeleteFiles && r.onDeleteFiles != nil {
r.onDeleteFiles(task.ID)
} else if r.onCancel != nil {
r.onCancel(task.ID)
}
} else if resp.Paused {
log.Printf("[%s] paused by user (via web)", task.ID[:8])
r.Untrack(task.ID)
if r.onPause != nil {
r.onPause(task.ID)
}
}
if resp.StreamRequested && task.GetStreamURL() == "" {
log.Printf("[%s] stream requested by user (via web)", task.ID[:8])
if r.onStreamRequested != nil {
r.onStreamRequested(task.ID)
}
}
}
}
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
update := task.ToStatusUpdate()
if _, err := r.agentClient.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
}
r.Untrack(task.ID)
}

View file

@ -0,0 +1,75 @@
package engine
import (
"context"
"fmt"
"log"
)
// resolveMethod determines which download method to use for a task.
// For "auto": tries available methods in priority order (torrent > debrid > usenet).
// For specific method: uses only that method.
func resolveMethod(ctx context.Context, task *Task, downloaders map[DownloadMethod]Downloader) (DownloadMethod, error) {
var order []DownloadMethod
switch task.PreferredMethod {
case "torrent":
order = []DownloadMethod{MethodTorrent}
case "debrid":
order = []DownloadMethod{MethodDebrid}
case "usenet":
order = []DownloadMethod{MethodUsenet}
default: // "auto"
order = []DownloadMethod{MethodTorrent, MethodDebrid, MethodUsenet}
}
for _, method := range order {
// Skip already-tried methods
tried := false
for _, tm := range task.TriedMethods {
if tm == method {
tried = true
break
}
}
if tried {
continue
}
dl, ok := downloaders[method]
if !ok {
continue // downloader not registered
}
available, err := dl.Available(ctx, task)
if err != nil {
taskID := task.ID
if len(taskID) > 8 {
taskID = taskID[:8]
}
log.Printf("[%s] %s availability check failed: %v", taskID, method, err)
continue
}
if available {
return method, nil
}
}
return "", fmt.Errorf("no download method available (tried: %v)", task.TriedMethods)
}
// tryFallback attempts to fall back to the next untried download method.
// Returns true if fallback was initiated, false if no more methods.
func tryFallback(task *Task, downloaders map[DownloadMethod]Downloader) bool {
if task.PreferredMethod != "auto" {
return false // specific method requested, no fallback
}
task.TriedMethods = append(task.TriedMethods, task.ResolvedMethod)
available := make([]DownloadMethod, 0, len(downloaders))
for m := range downloaders {
available = append(available, m)
}
return task.HasUntried(available)
}

View file

@ -0,0 +1,141 @@
package engine
import (
"context"
"fmt"
"testing"
)
// mockDownloader implements Downloader for testing.
type mockDownloader struct {
method DownloadMethod
available bool
err error
}
func (m *mockDownloader) Method() DownloadMethod { return m.method }
func (m *mockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return m.available, m.err
}
func (m *mockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return &Result{Method: m.method, FileName: "test.mkv", FilePath: "/tmp/test.mkv"}, nil
}
func (m *mockDownloader) Pause(_ string) error { return nil }
func (m *mockDownloader) Cancel(_ string) error { return nil }
func (m *mockDownloader) Shutdown(_ context.Context) error { return nil }
func TestResolveMethodAuto(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
// Torrent is first in auto order
if method != MethodTorrent {
t.Errorf("method = %q, want torrent (first in auto order)", method)
}
}
func TestResolveMethodSpecific(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{PreferredMethod: "debrid"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid", method)
}
}
func TestResolveMethodSkipsTried(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
TriedMethods: []DownloadMethod{MethodTorrent},
}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent already tried)", method)
}
}
func TestResolveMethodNoneAvailable(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false},
}
task := &Task{PreferredMethod: "auto"}
_, err := resolveMethod(context.Background(), task, downloaders)
if err == nil {
t.Error("expected error when no method available")
}
}
func TestResolveMethodAvailabilityError(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: false, err: fmt.Errorf("network error")},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{ID: "test-resolve-err", PreferredMethod: "auto"}
method, err := resolveMethod(context.Background(), task, downloaders)
if err != nil {
t.Fatal(err)
}
// Should fallback to debrid when torrent has error
if method != MethodDebrid {
t.Errorf("method = %q, want debrid (torrent errored)", method)
}
}
func TestTryFallbackAutoMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "auto",
ResolvedMethod: MethodTorrent,
}
if !tryFallback(task, downloaders) {
t.Error("should have fallback available")
}
if len(task.TriedMethods) != 1 || task.TriedMethods[0] != MethodTorrent {
t.Error("torrent should be in tried methods")
}
}
func TestTryFallbackSpecificMode(t *testing.T) {
downloaders := map[DownloadMethod]Downloader{
MethodTorrent: &mockDownloader{method: MethodTorrent, available: true},
MethodDebrid: &mockDownloader{method: MethodDebrid, available: true},
}
task := &Task{
PreferredMethod: "torrent",
ResolvedMethod: MethodTorrent,
}
if tryFallback(task, downloaders) {
t.Error("should not fallback in specific mode")
}
}

View file

@ -0,0 +1,37 @@
package engine
import (
"fmt"
"path/filepath"
"strings"
)
// isWithinDir checks that resolved is a child of baseDir (prevents path traversal).
// Both paths must be absolute and clean.
func isWithinDir(baseDir, resolved string) bool {
base := filepath.Clean(baseDir)
target := filepath.Clean(resolved)
return target == base || strings.HasPrefix(target, base+string(filepath.Separator))
}
// safePath constructs a path under baseDir and validates it doesn't escape.
// Returns an error if the resulting path is outside baseDir.
// If the resulting path exists and is a symlink that resolves outside baseDir,
// it is also rejected.
func safePath(baseDir, untrusted string) (string, error) {
resolved := filepath.Join(baseDir, untrusted) // Join already cleans
if !isWithinDir(baseDir, resolved) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", untrusted, baseDir)
}
// Resolve symlinks if the path already exists on disk
if real, err := filepath.EvalSymlinks(resolved); err == nil {
if !isWithinDir(baseDir, real) {
return "", fmt.Errorf("path traversal blocked: %q resolves outside %q via symlink", untrusted, baseDir)
}
return real, nil
}
return resolved, nil
}

View file

@ -0,0 +1,47 @@
package engine
import "testing"
func TestIsWithinDir(t *testing.T) {
tests := []struct {
base string
target string
want bool
}{
{"/data", "/data/file.txt", true},
{"/data", "/data/sub/file.txt", true},
{"/data", "/data", true},
{"/data", "/data/../etc/passwd", false},
{"/data", "/etc/passwd", false},
{"/data", "/", false},
{"/data", "/datafoo", false}, // not a child, just a prefix
}
for _, tt := range tests {
got := isWithinDir(tt.base, tt.target)
if got != tt.want {
t.Errorf("isWithinDir(%q, %q) = %v, want %v", tt.base, tt.target, got, tt.want)
}
}
}
func TestSafePath(t *testing.T) {
tests := []struct {
base string
untrusted string
wantErr bool
}{
{"/data", "movie.mkv", false},
{"/data", "sub/file.mkv", false},
{"/data", "../etc/passwd", true},
{"/data", "../../root/.ssh", true},
{"/data", "normal/../still-ok", false},
}
for _, tt := range tests {
_, err := safePath(tt.base, tt.untrusted)
if (err != nil) != tt.wantErr {
t.Errorf("safePath(%q, %q) error = %v, wantErr %v", tt.base, tt.untrusted, err, tt.wantErr)
}
}
}

316
internal/engine/stream.go Normal file
View file

@ -0,0 +1,316 @@
package engine
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
)
// StreamConfig holds settings for the streaming engine.
type StreamConfig struct {
DataDir string
Port int
BufferBytes int64
MetaTimeout time.Duration
NoOpen bool
PlayerCmd string
}
// StreamStatus represents the current state of the streaming session.
type StreamStatus int
const (
StreamStatusMetadata StreamStatus = iota
StreamStatusBuffering
StreamStatusReady
StreamStatusError
)
// StreamProgress is a snapshot of current streaming stats.
type StreamProgress struct {
Status StreamStatus
DownloadedBytes int64
TotalBytes int64
SpeedBps int64
Peers int
Seeds int
FileName string
}
// StreamEngine manages a single streaming torrent session.
type StreamEngine struct {
client *torrent.Client
cfg StreamConfig
tor *torrent.Torrent
file *torrent.File
bufferTarget int64
totalBytes int64
fileName string
mu sync.RWMutex
status StreamStatus
lastBytes int64
lastTime time.Time
speedBps int64
}
// NewStreamEngine creates a streaming engine with its own torrent client.
func NewStreamEngine(cfg StreamConfig) (*StreamEngine, error) {
if cfg.MetaTimeout == 0 {
cfg.MetaTimeout = 60 * time.Second
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = false
tcfg.NoUpload = true
tcfg.ListenPort = 0
tcfg.Logger = alog.Default.FilterLevel(alog.Disabled)
client, err := torrent.NewClient(tcfg)
if err != nil {
return nil, fmt.Errorf("create torrent client: %w", err)
}
return &StreamEngine{
client: client,
cfg: cfg,
status: StreamStatusMetadata,
}, nil
}
// Start adds the torrent, waits for metadata, selects the video file,
// and prepares for streaming.
func (s *StreamEngine) Start(ctx context.Context, magnetOrHash string) error {
magnet := magnetOrHash
if !strings.HasPrefix(magnet, "magnet:") {
magnet = buildMagnet(strings.TrimSpace(magnetOrHash))
}
t, err := s.client.AddMagnet(magnet)
if err != nil {
return fmt.Errorf("add magnet: %w", err)
}
s.tor = t
metaCtx, metaCancel := context.WithTimeout(ctx, s.cfg.MetaTimeout)
defer metaCancel()
select {
case <-t.GotInfo():
case <-metaCtx.Done():
return fmt.Errorf("metadata timeout after %s: no peers found", s.cfg.MetaTimeout)
}
if err := s.selectFile(); err != nil {
return err
}
s.totalBytes = s.file.Length()
s.fileName = filepath.Base(s.file.DisplayPath())
s.bufferTarget = s.calculateBufferTarget()
s.lastTime = time.Now()
s.mu.Lock()
s.status = StreamStatusBuffering
s.mu.Unlock()
return nil
}
// selectFile picks the best video file from the torrent.
// Falls back to the largest file if no video is found.
func (s *StreamEngine) selectFile() error {
files := s.tor.Files()
if len(files) == 0 {
return fmt.Errorf("torrent has no files")
}
var bestVideo *torrent.File
var bestAny *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] {
if bestVideo == nil || f.Length() > bestVideo.Length() {
bestVideo = f
}
}
if bestAny == nil || f.Length() > bestAny.Length() {
bestAny = f
}
}
if bestVideo != nil {
s.file = bestVideo
} else {
s.file = bestAny
}
// Cancel all other files, download only the selected one
for _, f := range files {
if f == s.file {
f.Download()
} else {
f.SetPriority(torrent.PiecePriorityNone)
}
}
return nil
}
// IsVideoFile returns true if the selected file has a video extension.
func (s *StreamEngine) IsVideoFile() bool {
ext := strings.ToLower(filepath.Ext(s.fileName))
return VideoExts[ext]
}
func (s *StreamEngine) calculateBufferTarget() int64 {
if s.cfg.BufferBytes > 0 {
return s.cfg.BufferBytes
}
fivePercent := s.totalBytes / 20
tenMB := int64(10 * 1024 * 1024)
if fivePercent < tenMB {
return fivePercent
}
return tenMB
}
// contiguousBytes returns the number of bytes completed contiguously
// from the start of the file.
func (s *StreamEngine) contiguousBytes() int64 {
states := s.file.State()
var total int64
for _, ps := range states {
if ps.Complete {
total += ps.Bytes
} else {
break
}
}
return total
}
// WaitBuffer blocks until enough contiguous bytes from the file start
// are downloaded, or the context is cancelled.
func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered, target int64)) error {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
buffered := s.contiguousBytes()
if progressFn != nil {
progressFn(buffered, s.bufferTarget)
}
if buffered >= s.bufferTarget {
s.mu.Lock()
s.status = StreamStatusReady
s.mu.Unlock()
return nil
}
}
}
}
// NewFileReader creates a new reader for the selected file.
// Each HTTP request should get its own reader (not safe for concurrent use).
func (s *StreamEngine) NewFileReader(ctx context.Context) torrent.Reader {
reader := s.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
reader.SetContext(ctx)
return reader
}
// StartProgressLoop starts a goroutine that updates speed/peer stats every second.
// It stops when the context is cancelled.
func (s *StreamEngine) StartProgressLoop(ctx context.Context) {
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now()
downloaded := s.file.BytesCompleted()
s.mu.Lock()
elapsed := now.Sub(s.lastTime).Seconds()
if elapsed > 0 {
s.speedBps = int64(float64(downloaded-s.lastBytes) / elapsed)
if s.speedBps < 0 {
s.speedBps = 0
}
}
s.lastBytes = downloaded
s.lastTime = now
s.mu.Unlock()
}
}
}()
}
// Progress returns a snapshot of the current streaming stats.
func (s *StreamEngine) Progress() StreamProgress {
s.mu.RLock()
status := s.status
speed := s.speedBps
s.mu.RUnlock()
stats := s.tor.Stats()
return StreamProgress{
Status: status,
DownloadedBytes: s.file.BytesCompleted(),
TotalBytes: s.totalBytes,
SpeedBps: speed,
Peers: stats.ActivePeers,
Seeds: stats.ConnectedSeeders,
FileName: s.fileName,
}
}
// FileName returns the name of the selected file.
func (s *StreamEngine) FileName() string { return s.fileName }
// FileLength returns the total size of the selected file in bytes.
func (s *StreamEngine) FileLength() int64 { return s.totalBytes }
// BufferTarget returns the buffer threshold in bytes.
func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget }
// Shutdown gracefully closes the torrent and client.
func (s *StreamEngine) Shutdown(_ context.Context) error {
if s.tor != nil {
s.tor.Drop()
}
if s.client != nil {
errs := s.client.Close()
if len(errs) > 0 {
return fmt.Errorf("close client: %v", errs[0])
}
}
return nil
}

View file

@ -0,0 +1,74 @@
package engine
import (
"fmt"
"os/exec"
"runtime"
)
// OpenPlayer attempts to open a media player with the given stream URL.
// Returns the player name and the running command.
// If override is set, it uses that command directly.
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
if override != "" {
cmd := exec.Command(override, url)
if err := cmd.Start(); err != nil {
return override, nil, fmt.Errorf("start %s: %w", override, err)
}
return override, cmd, nil
}
// Try mpv first (best streaming support)
if path, err := exec.LookPath("mpv"); err == nil {
cmd := exec.Command(path, "--no-terminal", url)
if err := cmd.Start(); err == nil {
return "mpv", cmd, nil
}
}
// Try VLC
if path, err := exec.LookPath("vlc"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "vlc", cmd, nil
}
}
// Try cvlc (VLC headless)
if path, err := exec.LookPath("cvlc"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "vlc (headless)", cmd, nil
}
}
// Browser fallback
name, cmd, err := openBrowser(url)
if err != nil {
return "", nil, fmt.Errorf("no player found: install mpv or vlc, or open %s manually", url)
}
return name, cmd, nil
}
func openBrowser(url string) (string, *exec.Cmd, error) {
switch runtime.GOOS {
case "linux":
if path, err := exec.LookPath("xdg-open"); err == nil {
cmd := exec.Command(path, url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
}
case "darwin":
cmd := exec.Command("/usr/bin/open", url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
case "windows":
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
}
return "", nil, fmt.Errorf("no browser opener found")
}

View file

@ -0,0 +1,142 @@
package engine
import (
"context"
"fmt"
"log"
"net"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/anacrolix/torrent"
)
// fileProvider abstracts where to get a file reader for streaming.
type fileProvider interface {
NewFileReader(ctx context.Context) torrent.Reader
FileName() string
}
// StreamServer serves a torrent file over HTTP with Range request support.
type StreamServer struct {
provider fileProvider
server *http.Server
port int
url string
}
// NewStreamServer creates a new HTTP server for streaming via StreamEngine.
func NewStreamServer(engine *StreamEngine, port int) *StreamServer {
return &StreamServer{
provider: engine,
port: port,
}
}
// NewStreamServerFromFile creates a server that streams directly from a torrent.File.
// Used for streaming an active download without a separate StreamEngine.
func NewStreamServerFromFile(file *torrent.File, port int) *StreamServer {
return &StreamServer{
provider: &torrentFileProvider{file: file},
port: port,
}
}
// torrentFileProvider wraps a torrent.File to implement fileProvider.
type torrentFileProvider struct {
file *torrent.File
}
func (p *torrentFileProvider) NewFileReader(ctx context.Context) torrent.Reader {
reader := p.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024)
reader.SetContext(ctx)
return reader
}
func (p *torrentFileProvider) FileName() string {
return filepath.Base(p.file.DisplayPath())
}
// Start begins serving the file on localhost. Returns the full URL.
func (ss *StreamServer) Start(ctx context.Context) (string, error) {
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
addr := fmt.Sprintf("127.0.0.1:%d", ss.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return "", fmt.Errorf("listen on %s: %w", addr, err)
}
// Extract actual port (important when port=0)
ss.port = listener.Addr().(*net.TCPAddr).Port
ss.url = fmt.Sprintf("http://127.0.0.1:%d/stream", ss.port)
ss.server = &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := ss.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("stream server error: %v", err)
}
}()
return ss.url, nil
}
// URL returns the full stream URL.
func (ss *StreamServer) URL() string { return ss.url }
// Port returns the bound port.
func (ss *StreamServer) Port() int { return ss.port }
// Shutdown gracefully stops the HTTP server.
func (ss *StreamServer) Shutdown(ctx context.Context) error {
if ss.server != nil {
return ss.server.Shutdown(ctx)
}
return nil
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
reader := ss.provider.NewFileReader(r.Context())
defer reader.Close()
w.Header().Set("Content-Type", mimeTypeFromExt(ss.provider.FileName()))
http.ServeContent(w, r, ss.provider.FileName(), time.Time{}, reader)
}
func mimeTypeFromExt(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".mp4", ".m4v":
return "video/mp4"
case ".mkv":
return "video/x-matroska"
case ".avi":
return "video/x-msvideo"
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
case ".ts":
return "video/mp2t"
case ".flv":
return "video/x-flv"
case ".mpg", ".mpeg":
return "video/mpeg"
case ".wmv":
return "video/x-ms-wmv"
case ".vob":
return "video/x-ms-vob"
default:
return "application/octet-stream"
}
}

View file

@ -0,0 +1,370 @@
package engine
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// ---------------------------------------------------------------------------
// StreamEngine unit tests (no network)
// ---------------------------------------------------------------------------
func TestStreamBuildMagnet(t *testing.T) {
hash := "abc123def456abc123def456abc123def456abc1"
magnet := buildMagnet(hash)
if !strings.HasPrefix(magnet, "magnet:?xt=urn:btih:"+hash) {
t.Errorf("magnet should start with btih, got: %s", magnet[:60])
}
// Should contain trackers
for _, tracker := range defaultTrackers {
if !strings.Contains(magnet, "tr=") {
t.Errorf("magnet should contain tracker param for %s", tracker)
}
}
}
func TestStreamBuildMagnetPassthrough(t *testing.T) {
// If input already is a magnet, Start should use it directly
// Here we test that buildMagnet produces a valid magnet from a hash
hash := "0000000000000000000000000000000000000000"
magnet := buildMagnet(hash)
if !strings.Contains(magnet, hash) {
t.Error("magnet should contain the info hash")
}
}
func TestVideoExtensions(t *testing.T) {
exts := []string{".mkv", ".mp4", ".avi", ".webm", ".mov", ".ts", ".flv", ".m4v", ".mpg", ".mpeg", ".vob", ".wmv"}
for _, ext := range exts {
if !VideoExts[ext] {
t.Errorf("expected %s to be a video extension", ext)
}
}
nonVideo := []string{".txt", ".zip", ".nfo", ".srt", ".jpg", ".exe"}
for _, ext := range nonVideo {
if VideoExts[ext] {
t.Errorf("expected %s to NOT be a video extension", ext)
}
}
}
func TestCalculateBufferTarget(t *testing.T) {
tests := []struct {
name string
totalBytes int64
bufferBytes int64
want int64
}{
{"small file (<200MB) uses 5%", 100 * 1024 * 1024, 0, 100 * 1024 * 1024 / 20},
{"large file (10GB) caps at 10MB", 10 * 1024 * 1024 * 1024, 0, 10 * 1024 * 1024},
{"medium file (500MB) caps at 10MB", 500 * 1024 * 1024, 0, 10 * 1024 * 1024}, // 5% of 500MB = 25MB > 10MB cap
{"override takes precedence", 10 * 1024 * 1024 * 1024, 5 * 1024 * 1024, 5 * 1024 * 1024},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StreamEngine{
totalBytes: tt.totalBytes,
cfg: StreamConfig{BufferBytes: tt.bufferBytes},
}
got := s.calculateBufferTarget()
if got != tt.want {
t.Errorf("calculateBufferTarget() = %d, want %d", got, tt.want)
}
})
}
}
func TestIsVideoFile(t *testing.T) {
tests := []struct {
name string
fileName string
want bool
}{
{"mp4", "movie.mp4", true},
{"mkv", "movie.mkv", true},
{"avi", "movie.avi", true},
{"nfo", "movie.nfo", false},
{"txt", "readme.txt", false},
{"srt", "subtitles.srt", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &StreamEngine{fileName: tt.fileName}
if got := s.IsVideoFile(); got != tt.want {
t.Errorf("IsVideoFile(%q) = %v, want %v", tt.fileName, got, tt.want)
}
})
}
}
func TestStreamStatusConstants(t *testing.T) {
// Verify status constants are distinct
statuses := []StreamStatus{
StreamStatusMetadata,
StreamStatusBuffering,
StreamStatusReady,
StreamStatusError,
}
seen := map[StreamStatus]bool{}
for _, s := range statuses {
if seen[s] {
t.Errorf("duplicate status value: %d", s)
}
seen[s] = true
}
}
func TestStreamEngineGetters(t *testing.T) {
s := &StreamEngine{
fileName: "movie.mkv",
totalBytes: 4 * 1024 * 1024 * 1024,
bufferTarget: 10 * 1024 * 1024,
}
if s.FileName() != "movie.mkv" {
t.Errorf("FileName() = %q", s.FileName())
}
if s.FileLength() != 4*1024*1024*1024 {
t.Errorf("FileLength() = %d", s.FileLength())
}
if s.BufferTarget() != 10*1024*1024 {
t.Errorf("BufferTarget() = %d", s.BufferTarget())
}
}
// ---------------------------------------------------------------------------
// StreamServer unit tests
// ---------------------------------------------------------------------------
func TestMimeTypeFromExt(t *testing.T) {
tests := []struct {
filename string
want string
}{
{"movie.mp4", "video/mp4"},
{"movie.m4v", "video/mp4"},
{"movie.mkv", "video/x-matroska"},
{"movie.avi", "video/x-msvideo"},
{"movie.webm", "video/webm"},
{"movie.mov", "video/quicktime"},
{"movie.ts", "video/mp2t"},
{"movie.flv", "video/x-flv"},
{"movie.mpg", "video/mpeg"},
{"movie.mpeg", "video/mpeg"},
{"movie.wmv", "video/x-ms-wmv"},
{"movie.vob", "video/x-ms-vob"},
{"unknown.xyz", "application/octet-stream"},
{"file.MP4", "video/mp4"}, // case insensitive
{"FILE.MKV", "video/x-matroska"},
}
for _, tt := range tests {
t.Run(tt.filename, func(t *testing.T) {
got := mimeTypeFromExt(tt.filename)
if got != tt.want {
t.Errorf("mimeTypeFromExt(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}
func TestStreamServerStartShutdown(t *testing.T) {
// Test server lifecycle without a real StreamEngine
// We can't test actual streaming, but we can test the HTTP server mechanics
// Create a minimal engine with just enough state for the server
s := &StreamEngine{
fileName: "test.mp4",
totalBytes: 1024,
}
srv := NewStreamServer(s, 0)
if srv.Port() != 0 {
t.Errorf("initial port should be 0, got %d", srv.Port())
}
// We can't Start() because NewFileReader needs a real torrent File
// But we can test that Shutdown on an un-started server doesn't panic
if err := srv.Shutdown(context.Background()); err != nil {
t.Errorf("shutdown of un-started server should not error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Task integration with stream fields
// ---------------------------------------------------------------------------
func TestNewTaskFromAgentWithMode(t *testing.T) {
at := agent.Task{
ID: "stream-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Movie (2024)",
PreferredMethod: "auto",
Mode: "stream",
}
task := NewTaskFromAgent(at)
if task.Mode != "stream" {
t.Errorf("Mode = %q, want stream", task.Mode)
}
if task.Status != StatusClaimed {
t.Errorf("Status = %q, want claimed", task.Status)
}
}
func TestNewTaskFromAgentDefaultMode(t *testing.T) {
at := agent.Task{
ID: "download-task-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
PreferredMethod: "auto",
// Mode not set
}
task := NewTaskFromAgent(at)
if task.Mode != "download" {
t.Errorf("Mode = %q, want download (default)", task.Mode)
}
}
func TestToStatusUpdateIncludesStreamURL(t *testing.T) {
task := &Task{
ID: "stream-task-2",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
Mode: "stream",
StreamURL: "http://127.0.0.1:43210/stream",
DownloadedBytes: 500,
TotalBytes: 1000,
SpeedBps: 100,
FileName: "movie.mkv",
}
update := task.ToStatusUpdate()
if update.StreamURL != "http://127.0.0.1:43210/stream" {
t.Errorf("StreamURL = %q, want http://127.0.0.1:43210/stream", update.StreamURL)
}
if update.Status != "downloading" {
t.Errorf("Status = %q", update.Status)
}
}
func TestToStatusUpdateNoStreamURL(t *testing.T) {
task := &Task{
ID: "download-task-2",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
Mode: "download",
}
update := task.ToStatusUpdate()
if update.StreamURL != "" {
t.Errorf("StreamURL should be empty for download tasks, got %q", update.StreamURL)
}
}
// ---------------------------------------------------------------------------
// StreamServer HTTP test (with mock ReadSeeker)
// ---------------------------------------------------------------------------
func TestStreamHTTPHandler(t *testing.T) {
// We create an HTTP handler manually to test Range request support
// This simulates what StreamServer.handler does, but with a string reader
content := strings.Repeat("X", 1000) // 1KB of data
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reader := strings.NewReader(content)
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, "test.mp4", time.Time{}, reader)
})
// Test full content request
t.Run("full request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusOK {
t.Errorf("status = %d, want 200", rr.statusCode)
}
if ct := rr.headers.Get("Content-Type"); ct != "video/mp4" {
t.Errorf("Content-Type = %q, want video/mp4", ct)
}
if rr.body.Len() != 1000 {
t.Errorf("body length = %d, want 1000", rr.body.Len())
}
})
// Test Range request
t.Run("range request", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
req.Header.Set("Range", "bytes=0-99")
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusPartialContent {
t.Errorf("status = %d, want 206 Partial Content", rr.statusCode)
}
if rr.body.Len() != 100 {
t.Errorf("body length = %d, want 100", rr.body.Len())
}
})
// Test Range request middle
t.Run("range request middle", func(t *testing.T) {
req, _ := http.NewRequest("GET", "/stream", nil)
req.Header.Set("Range", "bytes=500-599")
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusPartialContent {
t.Errorf("status = %d, want 206", rr.statusCode)
}
if rr.body.Len() != 100 {
t.Errorf("body length = %d, want 100", rr.body.Len())
}
})
// Test HEAD request
t.Run("HEAD request", func(t *testing.T) {
req, _ := http.NewRequest("HEAD", "/stream", nil)
rr := &responseRecorder{headers: http.Header{}, body: &strings.Builder{}}
handler.ServeHTTP(rr, req)
if rr.statusCode != http.StatusOK {
t.Errorf("status = %d, want 200", rr.statusCode)
}
})
}
// responseRecorder is a minimal http.ResponseWriter for testing
type responseRecorder struct {
statusCode int
headers http.Header
body *strings.Builder
}
func (r *responseRecorder) Header() http.Header { return r.headers }
func (r *responseRecorder) WriteHeader(code int) { r.statusCode = code }
func (r *responseRecorder) Write(b []byte) (int, error) {
if r.statusCode == 0 {
r.statusCode = http.StatusOK
}
return r.body.Write(b)
}
// Ensure responseRecorder implements ReadSeeker expectations
func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) {
n, err := io.Copy(r.body, src)
return n, err
}

212
internal/engine/task.go Normal file
View file

@ -0,0 +1,212 @@
package engine
import (
"fmt"
"sync"
"time"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
// TaskStatus represents the current state of a download task.
type TaskStatus string
const (
StatusPending TaskStatus = "pending"
StatusClaimed TaskStatus = "claimed"
StatusResolving TaskStatus = "resolving"
StatusDownloading TaskStatus = "downloading"
StatusVerifying TaskStatus = "verifying"
StatusOrganizing TaskStatus = "organizing"
StatusSeeding TaskStatus = "seeding"
StatusCompleted TaskStatus = "completed"
StatusFailed TaskStatus = "failed"
StatusCancelled TaskStatus = "cancelled"
)
// validTransitions defines allowed state changes.
var validTransitions = map[TaskStatus][]TaskStatus{
StatusPending: {StatusClaimed},
StatusClaimed: {StatusResolving, StatusCancelled},
StatusResolving: {StatusDownloading, StatusFailed, StatusCancelled},
StatusDownloading: {StatusVerifying, StatusFailed, StatusResolving, StatusCancelled},
StatusVerifying: {StatusOrganizing, StatusFailed},
StatusOrganizing: {StatusSeeding, StatusCompleted},
StatusSeeding: {StatusCompleted},
}
// Task represents a download task with its full lifecycle state.
type Task struct {
mu sync.RWMutex
// From server
ID string
InfoHash string
Title string
ContentID *int
IMDbID string
PreferredMethod string // auto | torrent | debrid | usenet
// Runtime state
Status TaskStatus
Mode string // download | stream
ResolvedMethod DownloadMethod
TriedMethods []DownloadMethod
DownloadedBytes int64
TotalBytes int64
SpeedBps int64
ETA int
FileName string
FilePath string
StreamURL string
ErrorMessage string
// Timestamps
ClaimedAt time.Time
StartedAt time.Time
CompletedAt time.Time
}
// NewTaskFromAgent creates a Task from a server-claimed agent.Task.
func NewTaskFromAgent(at agent.Task) *Task {
mode := at.Mode
if mode == "" {
mode = "download"
}
return &Task{
ID: at.ID,
InfoHash: at.InfoHash,
Title: at.Title,
ContentID: at.ContentID,
IMDbID: at.IMDbID,
PreferredMethod: at.PreferredMethod,
Mode: mode,
Status: StatusClaimed,
ClaimedAt: time.Now(),
}
}
// Transition validates and performs a state transition.
func (t *Task) Transition(to TaskStatus) error {
t.mu.Lock()
defer t.mu.Unlock()
allowed, ok := validTransitions[t.Status]
if !ok {
return fmt.Errorf("no transitions from %s", t.Status)
}
for _, a := range allowed {
if a == to {
t.Status = to
if to == StatusDownloading {
t.StartedAt = time.Now()
}
if to == StatusCompleted || to == StatusFailed {
t.CompletedAt = time.Now()
}
return nil
}
}
return fmt.Errorf("invalid transition: %s -> %s", t.Status, to)
}
// GetStatus returns current status thread-safely.
func (t *Task) GetStatus() TaskStatus {
t.mu.RLock()
defer t.mu.RUnlock()
return t.Status
}
// SetStreamURL sets the stream URL thread-safely.
func (t *Task) SetStreamURL(url string) {
t.mu.Lock()
defer t.mu.Unlock()
t.StreamURL = url
}
// GetStreamURL returns the stream URL thread-safely.
func (t *Task) GetStreamURL() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.StreamURL
}
// UpdateProgress updates download metrics thread-safely.
func (t *Task) UpdateProgress(p Progress) {
t.mu.Lock()
defer t.mu.Unlock()
t.DownloadedBytes = p.DownloadedBytes
t.TotalBytes = p.TotalBytes
t.SpeedBps = p.SpeedBps
t.ETA = p.ETA
if p.FileName != "" {
t.FileName = p.FileName
}
}
// Percent returns download progress as 0-100.
func (t *Task) Percent() int {
t.mu.RLock()
defer t.mu.RUnlock()
if t.TotalBytes <= 0 {
return 0
}
p := int(float64(t.DownloadedBytes) / float64(t.TotalBytes) * 100)
if p > 100 {
return 100
}
return p
}
// ToStatusUpdate converts task state to an API status update.
func (t *Task) ToStatusUpdate() agent.StatusUpdate {
t.mu.RLock()
defer t.mu.RUnlock()
apiStatus := ""
switch t.Status {
case StatusResolving, StatusDownloading, StatusVerifying, StatusOrganizing, StatusSeeding:
apiStatus = "downloading"
case StatusCompleted:
apiStatus = "completed"
case StatusFailed:
apiStatus = "failed"
}
return agent.StatusUpdate{
TaskID: t.ID,
Status: apiStatus,
Progress: t.Percent(),
DownloadedBytes: t.DownloadedBytes,
TotalBytes: t.TotalBytes,
SpeedBps: t.SpeedBps,
ETA: t.ETA,
ResolvedMethod: string(t.ResolvedMethod),
FileName: t.FileName,
FilePath: t.FilePath,
StreamURL: t.StreamURL,
ErrorMessage: t.ErrorMessage,
}
}
// MagnetURI builds a magnet link from the info hash.
func (t *Task) MagnetURI() string {
return "magnet:?xt=urn:btih:" + t.InfoHash
}
// HasUntried returns true if there are download methods not yet attempted.
func (t *Task) HasUntried(available []DownloadMethod) bool {
for _, m := range available {
tried := false
for _, tm := range t.TriedMethods {
if tm == m {
tried = true
break
}
}
if !tried {
return true
}
}
return false
}

View file

@ -0,0 +1,190 @@
package engine
import (
"testing"
"github.com/torrentclaw/torrentclaw-cli/internal/agent"
)
func TestNewTaskFromAgent(t *testing.T) {
at := agent.Task{
ID: "uuid-123",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix (1999)",
PreferredMethod: "auto",
}
task := NewTaskFromAgent(at)
if task.ID != "uuid-123" {
t.Errorf("ID = %q, want uuid-123", task.ID)
}
if task.Status != StatusClaimed {
t.Errorf("Status = %q, want claimed", task.Status)
}
if task.ClaimedAt.IsZero() {
t.Error("ClaimedAt should be set")
}
}
func TestTransitionValid(t *testing.T) {
transitions := []struct {
from TaskStatus
to TaskStatus
}{
{StatusClaimed, StatusResolving},
{StatusResolving, StatusDownloading},
{StatusDownloading, StatusVerifying},
{StatusVerifying, StatusOrganizing},
{StatusOrganizing, StatusCompleted},
}
for _, tt := range transitions {
t.Run(string(tt.from)+"->"+string(tt.to), func(t *testing.T) {
task := &Task{Status: tt.from}
if err := task.Transition(tt.to); err != nil {
t.Errorf("valid transition %s -> %s failed: %v", tt.from, tt.to, err)
}
if task.Status != tt.to {
t.Errorf("Status = %q, want %q", task.Status, tt.to)
}
})
}
}
func TestTransitionInvalid(t *testing.T) {
invalid := []struct {
from TaskStatus
to TaskStatus
}{
{StatusPending, StatusDownloading},
{StatusClaimed, StatusCompleted},
{StatusCompleted, StatusDownloading},
{StatusFailed, StatusCompleted},
{StatusVerifying, StatusResolving},
}
for _, tt := range invalid {
t.Run(string(tt.from)+"->"+string(tt.to), func(t *testing.T) {
task := &Task{Status: tt.from}
if err := task.Transition(tt.to); err == nil {
t.Errorf("invalid transition %s -> %s should fail", tt.from, tt.to)
}
})
}
}
func TestTransitionDownloadingSetsStartedAt(t *testing.T) {
task := &Task{Status: StatusResolving}
task.Transition(StatusDownloading)
if task.StartedAt.IsZero() {
t.Error("StartedAt should be set on downloading transition")
}
}
func TestTransitionCompletedSetsCompletedAt(t *testing.T) {
task := &Task{Status: StatusOrganizing}
task.Transition(StatusCompleted)
if task.CompletedAt.IsZero() {
t.Error("CompletedAt should be set")
}
}
func TestTransitionFailedSetsCompletedAt(t *testing.T) {
task := &Task{Status: StatusResolving}
task.Transition(StatusFailed)
if task.CompletedAt.IsZero() {
t.Error("CompletedAt should be set on failure")
}
}
func TestFallbackTransition(t *testing.T) {
// downloading -> resolving (fallback)
task := &Task{Status: StatusDownloading}
if err := task.Transition(StatusResolving); err != nil {
t.Errorf("fallback transition should work: %v", err)
}
}
func TestCancelFromMultipleStates(t *testing.T) {
for _, from := range []TaskStatus{StatusClaimed, StatusResolving, StatusDownloading} {
t.Run(string(from), func(t *testing.T) {
task := &Task{Status: from}
if err := task.Transition(StatusCancelled); err != nil {
t.Errorf("cancel from %s should work: %v", from, err)
}
})
}
}
func TestPercent(t *testing.T) {
task := &Task{DownloadedBytes: 500, TotalBytes: 1000}
if p := task.Percent(); p != 50 {
t.Errorf("Percent = %d, want 50", p)
}
task2 := &Task{DownloadedBytes: 0, TotalBytes: 0}
if p := task2.Percent(); p != 0 {
t.Errorf("Percent = %d, want 0 for zero total", p)
}
}
func TestUpdateProgress(t *testing.T) {
task := &Task{}
task.UpdateProgress(Progress{
DownloadedBytes: 1024,
TotalBytes: 2048,
SpeedBps: 512,
ETA: 2,
FileName: "movie.mkv",
})
if task.DownloadedBytes != 1024 {
t.Errorf("DownloadedBytes = %d", task.DownloadedBytes)
}
if task.FileName != "movie.mkv" {
t.Errorf("FileName = %q", task.FileName)
}
}
func TestToStatusUpdate(t *testing.T) {
task := &Task{
ID: "task-123",
Status: StatusDownloading,
ResolvedMethod: MethodTorrent,
DownloadedBytes: 500,
TotalBytes: 1000,
SpeedBps: 100,
ETA: 5,
FileName: "file.mkv",
}
update := task.ToStatusUpdate()
if update.TaskID != "task-123" {
t.Errorf("TaskID = %q", update.TaskID)
}
if update.Status != "downloading" {
t.Errorf("Status = %q, want downloading", update.Status)
}
if update.Progress != 50 {
t.Errorf("Progress = %d, want 50", update.Progress)
}
if update.ResolvedMethod != "torrent" {
t.Errorf("ResolvedMethod = %q", update.ResolvedMethod)
}
}
func TestMagnetURI(t *testing.T) {
task := &Task{InfoHash: "abc123"}
m := task.MagnetURI()
if m != "magnet:?xt=urn:btih:abc123" {
t.Errorf("MagnetURI = %q", m)
}
}
func TestHasUntried(t *testing.T) {
task := &Task{TriedMethods: []DownloadMethod{MethodTorrent}}
if !task.HasUntried([]DownloadMethod{MethodTorrent, MethodDebrid}) {
t.Error("should have untried (debrid)")
}
if task.HasUntried([]DownloadMethod{MethodTorrent}) {
t.Error("all methods tried")
}
}

433
internal/engine/torrent.go Normal file
View file

@ -0,0 +1,433 @@
package engine
import (
"context"
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"golang.org/x/time/rate"
)
var defaultTrackers = []string{
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://open.demonii.com:1337/announce",
"udp://exodus.desync.com:6969/announce",
}
// TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct {
DataDir string
StallTimeout time.Duration // no progress for this long = stall (default 90s)
MaxTimeout time.Duration // absolute maximum per torrent (default 30m)
MaxDownloadRate int64 // bytes/s, 0 = unlimited
MaxUploadRate int64 // bytes/s, 0 = unlimited
SeedEnabled bool
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0)
}
// TorrentDownloader downloads torrents via BitTorrent P2P.
type TorrentDownloader struct {
client *torrent.Client
cfg TorrentConfig
activeMu sync.Mutex
active map[string]*torrent.Torrent // taskID -> torrent handle
}
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
if cfg.StallTimeout == 0 {
cfg.StallTimeout = 90 * time.Second
}
if cfg.MaxTimeout == 0 {
cfg.MaxTimeout = 30 * time.Minute
}
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.ListenPort = 0
tcfg.Logger = alog.Default.FilterLevel(alog.Disabled)
if cfg.MaxDownloadRate > 0 {
burst := int(cfg.MaxDownloadRate)
if burst < 256*1024 {
burst = 256 * 1024
}
tcfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxDownloadRate), burst)
}
if cfg.MaxUploadRate > 0 {
burst := int(cfg.MaxUploadRate)
if burst < 256*1024 {
burst = 256 * 1024
}
tcfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(cfg.MaxUploadRate), burst)
}
client, err := torrent.NewClient(tcfg)
if err != nil {
return nil, fmt.Errorf("create torrent client: %w", err)
}
return &TorrentDownloader{
client: client,
cfg: cfg,
active: make(map[string]*torrent.Torrent),
}, nil
}
func (d *TorrentDownloader) Method() DownloadMethod { return MethodTorrent }
func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, error) {
return task.InfoHash != "", nil
}
func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) {
magnet := buildMagnet(task.InfoHash)
t, err := d.client.AddMagnet(magnet)
if err != nil {
return nil, fmt.Errorf("add magnet: %w", err)
}
// Track active torrent
d.activeMu.Lock()
d.active[task.ID] = t
d.activeMu.Unlock()
cleanup := func() {
d.activeMu.Lock()
delete(d.active, task.ID)
d.activeMu.Unlock()
if !d.cfg.SeedEnabled {
t.Drop()
}
}
// 1. Wait for metadata
log.Printf("[%s] waiting for metadata...", task.ID[:8])
metaCtx, metaCancel := context.WithTimeout(ctx, d.cfg.StallTimeout)
defer metaCancel()
select {
case <-t.GotInfo():
log.Printf("[%s] metadata received: %s (%d files)", task.ID[:8], t.Name(), len(t.Files()))
case <-metaCtx.Done():
cleanup()
return nil, fmt.Errorf("metadata timeout after %s", d.cfg.StallTimeout)
}
// 2. Select files to download (prefer largest video + matching subs)
totalBytes, fileName := d.selectFiles(t, task.ID)
log.Printf("[%s] downloading %s (%s)", task.ID[:8], fileName, formatBytes(totalBytes))
// 3. Poll progress with stall detection
result, err := d.pollDownload(ctx, t, task, totalBytes, fileName, progressCh)
if err != nil {
cleanup()
return nil, err
}
// 4. Determine file path
filePath := filepath.Join(d.cfg.DataDir, fileName)
if _, statErr := os.Stat(filePath); statErr != nil {
filePath = filepath.Join(d.cfg.DataDir, t.Name())
}
result.FilePath = filePath
result.FileName = fileName
result.Method = MethodTorrent
result.Size = totalBytes
// If seeding enabled, keep alive (don't cleanup).
// The manager handles seeding lifecycle.
if !d.cfg.SeedEnabled {
cleanup()
}
return result, nil
}
func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent, task *Task, totalBytes int64, fileName string, progressCh chan<- Progress) (*Result, error) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
deadline := time.After(d.cfg.MaxTimeout)
lastBytesAt := time.Now()
lastBytes := int64(0)
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("cancelled")
case <-deadline:
return nil, fmt.Errorf("max timeout %s exceeded", d.cfg.MaxTimeout)
case <-ticker.C:
downloaded := t.BytesCompleted()
now := time.Now()
// Speed calculation
speed := downloaded - lastBytes
if speed < 0 {
speed = 0
}
// Stall detection (dual-level like TrueSpec)
if downloaded > lastBytes {
lastBytesAt = now
lastBytes = downloaded
} else if now.Sub(lastBytesAt) > d.cfg.StallTimeout {
stats := t.Stats()
return nil, fmt.Errorf("stalled: no progress for %s (peers: %d, seeds: %d)",
d.cfg.StallTimeout, stats.ActivePeers, stats.ConnectedSeeders)
}
// ETA
var eta int
if speed > 0 {
remaining := totalBytes - downloaded
eta = int(remaining / speed)
}
// Peer stats
stats := t.Stats()
// Report progress
p := Progress{
DownloadedBytes: downloaded,
TotalBytes: totalBytes,
SpeedBps: speed,
ETA: eta,
Peers: stats.ActivePeers,
Seeds: stats.ConnectedSeeders,
FileName: fileName,
}
task.UpdateProgress(p)
select {
case progressCh <- p:
default: // don't block if channel full
}
// Check completion
if downloaded >= totalBytes {
log.Printf("[%s] download complete: %s", task.ID[:8], fileName)
return &Result{}, nil
}
}
}
}
// Pause drops the torrent handle but keeps partial files on disk for resume.
func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock()
t, ok := d.active[taskID]
delete(d.active, taskID)
d.activeMu.Unlock()
if !ok {
return nil
}
t.Drop()
log.Printf("[%s] paused (files kept for resume)", taskID[:8])
return nil
}
// Cancel drops the torrent handle and removes partial files from disk.
func (d *TorrentDownloader) Cancel(taskID string) error {
d.activeMu.Lock()
t, ok := d.active[taskID]
delete(d.active, taskID)
d.activeMu.Unlock()
if !ok {
return nil
}
name := t.Name()
t.Drop()
if name != "" {
path, err := safePath(d.cfg.DataDir, name)
if err != nil {
log.Printf("[%s] cancel blocked: %v", taskID[:8], err)
return nil
}
if fi, statErr := os.Stat(path); statErr == nil {
if fi.IsDir() {
os.RemoveAll(path)
} else {
os.Remove(path)
}
log.Printf("[%s] cleaned up partial download: %s", taskID[:8], name)
}
}
return nil
}
func (d *TorrentDownloader) Shutdown(ctx context.Context) error {
d.activeMu.Lock()
for id, t := range d.active {
t.Drop()
delete(d.active, id)
}
d.activeMu.Unlock()
errs := d.client.Close()
if len(errs) > 0 {
return fmt.Errorf("close client: %v", errs[0])
}
return nil
}
// StartStream starts an HTTP server for an active torrent download.
// It selects the largest video file and serves it via HTTP Range requests.
// Returns the running server (caller is responsible for shutdown).
func (d *TorrentDownloader) StartStream(taskID string) (*StreamServer, error) {
d.activeMu.Lock()
t, ok := d.active[taskID]
d.activeMu.Unlock()
if !ok {
return nil, fmt.Errorf("no active torrent for task %s", taskID[:8])
}
// Select largest video file
files := t.Files()
var video *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] && (video == nil || f.Length() > video.Length()) {
video = f
}
}
if video == nil {
// No video — use largest file
for _, f := range files {
if video == nil || f.Length() > video.Length() {
video = f
}
}
}
if video == nil {
return nil, fmt.Errorf("torrent has no files")
}
srv := NewStreamServerFromFile(video, 0)
url, err := srv.Start(context.Background())
if err != nil {
return nil, fmt.Errorf("start stream server: %w", err)
}
log.Printf("[%s] stream started: %s → %s", taskID[:8], filepath.Base(video.DisplayPath()), url)
return srv, nil
}
// VideoExts is the canonical set of video file extensions used for file selection.
var VideoExts = map[string]bool{
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true,
".wmv": true, ".ts": true, ".webm": true, ".mov": true,
".mpg": true, ".mpeg": true, ".vob": true, ".flv": true,
}
var subExts = map[string]bool{
".srt": true, ".ass": true, ".sub": true, ".ssa": true, ".vtt": true,
}
// selectFiles picks the largest video file + matching subtitles.
// Falls back to downloading everything if no video file is found.
// Returns the total bytes to download and the primary file name.
func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (totalBytes int64, fileName string) {
files := t.Files()
if len(files) <= 1 {
t.DownloadAll()
return t.Length(), t.Name()
}
// Find largest video file
var video *torrent.File
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if VideoExts[ext] && (video == nil || f.Length() > video.Length()) {
video = f
}
}
if video == nil {
// No video (music, software, etc.) — download everything
t.DownloadAll()
return t.Length(), t.Name()
}
// Download only the video
video.Download()
totalBytes = video.Length()
fileName = video.DisplayPath()
// Also download matching subtitles
videoBase := strings.TrimSuffix(video.DisplayPath(), filepath.Ext(video.DisplayPath()))
var subCount int
for _, f := range files {
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
if subExts[ext] {
fBase := strings.TrimSuffix(f.DisplayPath(), filepath.Ext(f.DisplayPath()))
// Match by prefix (handles Movie.en.srt, Movie.es.srt)
if strings.HasPrefix(fBase, videoBase) || filepath.Dir(f.DisplayPath()) == filepath.Dir(video.DisplayPath()) {
f.Download()
totalBytes += f.Length()
subCount++
}
}
}
skipped := len(files) - 1 - subCount
if skipped > 0 {
log.Printf("[%s] selected: %s (%s) + %d subs, skipped %d files",
taskID[:8], filepath.Base(fileName), formatBytes(video.Length()), subCount, skipped)
}
return totalBytes, fileName
}
func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash}
for _, tracker := range defaultTrackers {
params = append(params, "tr="+url.QueryEscape(tracker))
}
return "magnet:?" + strings.Join(params, "&")
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
}

26
internal/engine/usenet.go Normal file
View file

@ -0,0 +1,26 @@
package engine
import (
"context"
"fmt"
)
// UsenetDownloader downloads via Usenet/NZB protocol.
// Currently a stub — not implemented.
type UsenetDownloader struct{}
func NewUsenetDownloader() *UsenetDownloader { return &UsenetDownloader{} }
func (u *UsenetDownloader) Method() DownloadMethod { return MethodUsenet }
func (u *UsenetDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return false, nil // always unavailable until implemented
}
func (u *UsenetDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return nil, fmt.Errorf("usenet download not implemented yet (coming in a future release)")
}
func (u *UsenetDownloader) Pause(_ string) error { return nil }
func (u *UsenetDownloader) Cancel(_ string) error { return nil }
func (u *UsenetDownloader) Shutdown(_ context.Context) error { return nil }

59
internal/engine/verify.go Normal file
View file

@ -0,0 +1,59 @@
package engine
import (
"fmt"
"os"
"path/filepath"
)
// verify checks that a downloaded file or directory is valid.
func verify(result *Result) error {
if result == nil || result.FilePath == "" {
return fmt.Errorf("no file path in result")
}
fi, err := os.Stat(result.FilePath)
if err != nil {
return fmt.Errorf("file not found: %w", err)
}
// Get actual size — handle both files and directories (multi-file torrents)
var actualSize int64
if fi.IsDir() {
actualSize, err = dirSize(result.FilePath)
if err != nil {
return fmt.Errorf("could not calculate dir size: %w", err)
}
} else {
actualSize = fi.Size()
}
if actualSize == 0 {
return fmt.Errorf("download is empty: %s", result.FilePath)
}
// If we know the expected size, check within 2% tolerance
if result.Size > 0 {
tolerance := int64(float64(result.Size) * 0.02)
if actualSize < result.Size-tolerance {
return fmt.Errorf("size mismatch: expected %d, got %d", result.Size, actualSize)
}
}
return nil
}
// dirSize returns total size of all files in a directory.
func dirSize(path string) (int64, error) {
var total int64
err := filepath.Walk(path, func(_ string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if !fi.IsDir() {
total += fi.Size()
}
return nil
})
return total, err
}

View file

@ -0,0 +1,71 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestVerifyNilResult(t *testing.T) {
if err := verify(nil); err == nil {
t.Error("expected error for nil result")
}
}
func TestVerifyEmptyPath(t *testing.T) {
if err := verify(&Result{}); err == nil {
t.Error("expected error for empty path")
}
}
func TestVerifyMissingFile(t *testing.T) {
err := verify(&Result{FilePath: "/nonexistent/file.mkv"})
if err == nil {
t.Error("expected error for missing file")
}
}
func TestVerifyEmptyFile(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.mkv")
os.WriteFile(path, []byte{}, 0o644)
err := verify(&Result{FilePath: path})
if err == nil {
t.Error("expected error for empty file")
}
}
func TestVerifyValidFile(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 1024), 0o644)
err := verify(&Result{FilePath: path, Size: 1024})
if err != nil {
t.Errorf("valid file should pass: %v", err)
}
}
func TestVerifySizeMismatch(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 500), 0o644)
err := verify(&Result{FilePath: path, Size: 1000})
if err == nil {
t.Error("expected error for size mismatch")
}
}
func TestVerifyNoExpectedSize(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.mkv")
os.WriteFile(path, make([]byte, 1024), 0o644)
// Size=0 means unknown, should pass
err := verify(&Result{FilePath: path, Size: 0})
if err != nil {
t.Errorf("no expected size should pass: %v", err)
}
}

114
internal/parser/torrent.go Normal file
View file

@ -0,0 +1,114 @@
package parser
import (
"net/url"
"regexp"
"strings"
)
// ParsedTorrent contains information extracted from a magnet URI, hash, or torrent name.
type ParsedTorrent struct {
InfoHash string
Name string
Quality string
Codec string
Year string
IsMagnet bool
}
var (
hashRegex = regexp.MustCompile(`^[a-fA-F0-9]{40}$`)
qualityRegex = regexp.MustCompile(`(?i)(2160p|1080p|720p|480p|4K|UHD)`)
codecRegex = regexp.MustCompile(`(?i)(x264|x265|h\.?264|h\.?265|HEVC|AVC|AV1|VP9|XviD|DivX)`)
yearRegex = regexp.MustCompile(`(?:^|[\s.(])((?:19|20)\d{2})(?:[\s.)]|$)`)
artifactsRegex = regexp.MustCompile(`(?i)(BluRay|BDRip|HDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|CAM|TS|TC|PROPER|REPACK|REMASTERED|REMUX|EXTENDED|UNRATED|IMAX|DUAL|MULTi|AAC|DTS|DD5\.1|AC3|Atmos|FLAC|EAC3|10bit|HDR10?\+?|DV|DoVi|SDR|YTS|YIFY|RARBG|NTG|SPARKS|AMIABLE|FGT|\[.*?\]|\(.*?\))`)
whitespaceRegex = regexp.MustCompile(`\s+`)
)
// Parse parses a magnet URI, info hash, or torrent name.
func Parse(input string) ParsedTorrent {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "magnet:") {
return parseMagnet(input)
}
if hashRegex.MatchString(input) {
return ParsedTorrent{
InfoHash: strings.ToLower(input),
}
}
// Treat as a torrent name/filename
return parseName(input)
}
func parseMagnet(uri string) ParsedTorrent {
result := ParsedTorrent{IsMagnet: true}
u, err := url.Parse(uri)
if err != nil {
return result
}
xt := u.Query().Get("xt")
if strings.HasPrefix(xt, "urn:btih:") {
result.InfoHash = strings.ToLower(strings.TrimPrefix(xt, "urn:btih:"))
}
dn := u.Query().Get("dn")
if dn != "" {
result.Name = dn
parsed := parseName(dn)
result.Quality = parsed.Quality
result.Codec = parsed.Codec
result.Year = parsed.Year
}
return result
}
func parseName(name string) ParsedTorrent {
result := ParsedTorrent{Name: name}
if m := qualityRegex.FindString(name); m != "" {
result.Quality = strings.ToLower(m)
if result.Quality == "4k" || result.Quality == "uhd" {
result.Quality = "2160p"
}
}
if m := codecRegex.FindString(name); m != "" {
result.Codec = m
}
if m := yearRegex.FindStringSubmatch(name); len(m) > 1 {
result.Year = m[1]
}
return result
}
// ExtractSearchQuery cleans a torrent name to use as a search query.
func ExtractSearchQuery(name string) string {
q := name
// Remove common release artifacts
for _, re := range []*regexp.Regexp{qualityRegex, codecRegex} {
q = re.ReplaceAllString(q, "")
}
q = artifactsRegex.ReplaceAllString(q, "")
// Replace dots and underscores with spaces
q = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(q)
// Remove year
q = yearRegex.ReplaceAllString(q, " ")
// Collapse whitespace
q = whitespaceRegex.ReplaceAllString(q, " ")
q = strings.TrimSpace(q)
return q
}

View file

@ -0,0 +1,98 @@
package parser
import (
"strings"
"testing"
)
func TestParseMagnet(t *testing.T) {
magnet := "magnet:?xt=urn:btih:ABC123DEF456ABC123DEF456ABC123DEF456ABC1&dn=Oppenheimer.2023.1080p.BluRay.x265"
p := Parse(magnet)
if !p.IsMagnet {
t.Error("expected IsMagnet=true")
}
if p.InfoHash != "abc123def456abc123def456abc123def456abc1" {
t.Errorf("InfoHash = %q, want lowercase 40-char hash", p.InfoHash)
}
if p.Quality != "1080p" {
t.Errorf("Quality = %q, want 1080p", p.Quality)
}
if p.Codec != "x265" {
t.Errorf("Codec = %q, want x265", p.Codec)
}
if p.Year != "2023" {
t.Errorf("Year = %q, want 2023", p.Year)
}
}
func TestParseInfoHash(t *testing.T) {
hash := "abc123def456abc123def456abc123def456abc1" // exactly 40 hex chars
p := Parse(hash)
if p.IsMagnet {
t.Error("expected IsMagnet=false for plain hash")
}
if p.InfoHash != hash {
t.Errorf("InfoHash = %q, want %q", p.InfoHash, hash)
}
}
func TestParseName(t *testing.T) {
tests := []struct {
input string
quality string
codec string
year string
}{
{"The.Matrix.1999.1080p.BluRay.x264", "1080p", "x264", "1999"},
{"Oppenheimer.2023.2160p.UHD.BluRay.x265", "2160p", "x265", "2023"},
{"Movie.720p.HDTV.HEVC", "720p", "HEVC", ""},
{"Show.480p.WEB.AV1", "480p", "AV1", ""},
{"No.Quality.Info.Here", "", "", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p := Parse(tt.input)
if p.Quality != tt.quality {
t.Errorf("Quality = %q, want %q", p.Quality, tt.quality)
}
if p.Codec != tt.codec {
t.Errorf("Codec = %q, want %q", p.Codec, tt.codec)
}
if p.Year != tt.year {
t.Errorf("Year = %q, want %q", p.Year, tt.year)
}
})
}
}
func TestExtractSearchQuery(t *testing.T) {
tests := []struct {
input string
}{
{"The.Matrix.1999.1080p.BluRay.x264-GROUP"},
{"Oppenheimer.2023.2160p.UHD.BluRay.x265.DTS-HD"},
{"Breaking.Bad.S01E01.720p.WEB-DL"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := ExtractSearchQuery(tt.input)
if got == "" {
t.Errorf("ExtractSearchQuery(%q) returned empty string", tt.input)
}
// Should not contain quality/codec artifacts
if strings.Contains(got, "1080p") || strings.Contains(got, "2160p") || strings.Contains(got, "720p") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain resolution", tt.input, got)
}
if strings.Contains(got, "x264") || strings.Contains(got, "x265") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain codec", tt.input, got)
}
if strings.Contains(got, "BluRay") {
t.Errorf("ExtractSearchQuery(%q) = %q, should not contain source", tt.input, got)
}
})
}
}

191
internal/ui/format.go Normal file
View file

@ -0,0 +1,191 @@
package ui
import (
"fmt"
"math"
"strconv"
"strings"
"time"
)
// FormatSize converts bytes to human-readable format.
func FormatSize(sizeBytes *int64) string {
if sizeBytes == nil {
return "?"
}
return FormatBytes(*sizeBytes)
}
// FormatBytes converts bytes to human-readable format.
func FormatBytes(b int64) string {
if b == 0 {
return "0 B"
}
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
val := float64(b) / float64(div)
units := []string{"KB", "MB", "GB", "TB"}
if exp >= len(units) {
exp = len(units) - 1
}
return fmt.Sprintf("%.1f %s", val, units[exp])
}
// QualityIndicator returns a colored emoji for quality score (0-100 scale).
func QualityIndicator(score *int) string {
if score == nil {
return " "
}
s := *score
switch {
case s >= 70:
return "🟢"
case s >= 40:
return "🟡"
default:
return "🔴"
}
}
// SeedHealthIndicator returns a colored emoji for seed count.
func SeedHealthIndicator(seeds int) string {
switch {
case seeds > 100:
return "🟢"
case seeds >= 10:
return "🟡"
default:
return "🔴"
}
}
// FormatRating returns a display string for a rating.
func FormatRating(rating *string) string {
if rating == nil {
return "-"
}
return *rating
}
// FormatYear returns a display string for a year.
func FormatYear(year *int) string {
if year == nil {
return "-"
}
return strconv.Itoa(*year)
}
// FormatContentType returns a short display for content type.
func FormatContentType(ct string) string {
switch strings.ToLower(ct) {
case "movie":
return "Movie"
case "show":
return "Show"
default:
return ct
}
}
// Ptr returns a pointer to a value. Useful for tests.
func Ptr[T any](v T) *T {
return &v
}
// TruncateString truncates a string to maxLen with ellipsis.
func TruncateString(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
if maxLen <= 3 {
return string(runes[:maxLen])
}
return string(runes[:maxLen-3]) + "..."
}
// FormatLanguages joins language codes.
func FormatLanguages(langs []string) string {
if len(langs) == 0 {
return "-"
}
return strings.Join(langs, ", ")
}
// FormatSeedRatio returns a display for seed/leech ratio.
func FormatSeedRatio(seeders, leechers int) string {
if leechers == 0 {
if seeders == 0 {
return "0:0"
}
return fmt.Sprintf("%d:0", seeders)
}
ratio := float64(seeders) / float64(leechers)
return fmt.Sprintf("%.0f:1", math.Round(ratio))
}
// FormatTimeAgo returns a human-readable "time ago" string.
func FormatTimeAgo(t string) string {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return t
}
diff := time.Since(parsed)
switch {
case diff < time.Minute:
return "just now"
case diff < time.Hour:
m := int(diff.Minutes())
return fmt.Sprintf("%dm ago", m)
case diff < 24*time.Hour:
h := int(diff.Hours())
return fmt.Sprintf("%dh ago", h)
case diff < 30*24*time.Hour:
d := int(diff.Hours() / 24)
return fmt.Sprintf("%dd ago", d)
default:
m := int(diff.Hours() / 24 / 30)
return fmt.Sprintf("%dmo ago", m)
}
}
// FormatNumber formats a number with thousands separator.
func FormatNumber(n int) string {
negative := n < 0
if negative {
n = -n
}
s := strconv.Itoa(n)
if len(s) <= 3 {
if negative {
return "-" + s
}
return s
}
var result []byte
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result = append(result, ',')
}
result = append(result, byte(c))
}
if negative {
return "-" + string(result)
}
return string(result)
}
// StringOrDash returns the string value or "-" if nil.
func StringOrDash(s *string) string {
if s == nil {
return "-"
}
return *s
}

165
internal/ui/format_test.go Normal file
View file

@ -0,0 +1,165 @@
package ui
import (
"testing"
)
func TestFormatSize(t *testing.T) {
tests := []struct {
name string
input *int64
want string
}{
{"nil", nil, "?"},
{"zero", ptr(int64(0)), "0 B"},
{"bytes", ptr(int64(500)), "500 B"},
{"kilobytes", ptr(int64(1024)), "1.0 KB"},
{"megabytes", ptr(int64(52428800)), "50.0 MB"},
{"gigabytes", ptr(int64(4294967296)), "4.0 GB"},
{"terabyte", ptr(int64(1099511627776)), "1.0 TB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatSize(tt.input)
if got != tt.want {
t.Errorf("FormatSize(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatBytes(t *testing.T) {
tests := []struct {
input int64
want string
}{
{0, "0 B"},
{100, "100 B"},
{1024, "1.0 KB"},
{1048576, "1.0 MB"},
{1073741824, "1.0 GB"},
{1099511627776, "1.0 TB"},
{3221225472, "3.0 GB"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatBytes(tt.input)
if got != tt.want {
t.Errorf("FormatBytes(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatYear(t *testing.T) {
tests := []struct {
input *int
want string
}{
{nil, "-"},
{intPtr(2023), "2023"},
{intPtr(1999), "1999"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatYear(tt.input)
if got != tt.want {
t.Errorf("FormatYear(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFormatNumber(t *testing.T) {
tests := []struct {
input int
want string
}{
{0, "0"},
{999, "999"},
{1000, "1,000"},
{1234567, "1,234,567"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := FormatNumber(tt.input)
if got != tt.want {
t.Errorf("FormatNumber(%d) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestTruncateString(t *testing.T) {
tests := []struct {
input string
maxLen int
want string
}{
{"short", 10, "short"},
{"exactly10!", 10, "exactly10!"},
{"this is too long", 10, "this is..."},
{"ab", 5, "ab"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := TruncateString(tt.input, tt.maxLen)
if got != tt.want {
t.Errorf("TruncateString(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
}
})
}
}
func TestQualityIndicator(t *testing.T) {
tests := []struct {
name string
input *int
want string
}{
{"nil", nil, " "},
{"low", intPtr(30), "🔴"},
{"medium", intPtr(60), "🟡"},
{"high", intPtr(80), "🟢"},
{"perfect", intPtr(100), "🟢"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := QualityIndicator(tt.input)
if got != tt.want {
t.Errorf("QualityIndicator(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestStringOrDash(t *testing.T) {
s := "hello"
if got := StringOrDash(&s); got != "hello" {
t.Errorf("StringOrDash(&hello) = %q, want hello", got)
}
if got := StringOrDash(nil); got != "-" {
t.Errorf("StringOrDash(nil) = %q, want -", got)
}
}
func TestFormatContentType(t *testing.T) {
if got := FormatContentType("movie"); got != "Movie" {
t.Errorf("FormatContentType(movie) = %q, want Movie", got)
}
if got := FormatContentType("show"); got != "Show" {
t.Errorf("FormatContentType(show) = %q, want Show", got)
}
if got := FormatContentType("other"); got != "other" {
t.Errorf("FormatContentType(other) = %q, want other", got)
}
}
func ptr[T any](v T) *T { return &v }
func intPtr(v int) *int { return &v }

424
internal/ui/table.go Normal file
View file

@ -0,0 +1,424 @@
package ui
import (
"fmt"
"io"
"os"
"strings"
"github.com/fatih/color"
"github.com/olekukonko/tablewriter"
tc "github.com/torrentclaw/go-client"
)
var (
titleColor = color.New(color.FgCyan, color.Bold)
headerColor = color.New(color.FgWhite, color.Bold)
successColor = color.New(color.FgGreen)
warnColor = color.New(color.FgYellow)
errorColor = color.New(color.FgRed)
dimColor = color.New(color.FgHiBlack)
boldColor = color.New(color.Bold)
)
// PrintSearchResults renders search results as a colored table.
func PrintSearchResults(resp *tc.SearchResponse) {
if len(resp.Results) == 0 {
warnColor.Println("No results found.")
return
}
fmt.Printf("\n")
dimColor.Printf(" %d results found (page %d)\n\n", resp.Total, resp.Page)
for _, r := range resp.Results {
printSearchResultEntry(os.Stdout, r)
}
}
func printSearchResultEntry(w io.Writer, r tc.SearchResult) {
year := FormatYear(r.Year)
titleColor.Fprintf(w, " %s (%s)", r.Title, year)
dimColor.Fprintf(w, " [%s]", FormatContentType(r.ContentType))
if r.RatingIMDb != nil {
fmt.Fprintf(w, " ⭐ %s", *r.RatingIMDb)
}
if len(r.Genres) > 0 {
dimColor.Fprintf(w, " %s", strings.Join(r.Genres, ", "))
}
fmt.Fprintln(w)
if len(r.Torrents) == 0 {
dimColor.Fprintln(w, " No torrents available")
fmt.Fprintln(w)
return
}
table := tablewriter.NewWriter(w)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Codec", "Lang", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, t := range r.Torrents {
quality := StringOrDash(t.Quality)
size := FormatSize(t.SizeBytes)
seeds := fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders)
source := t.Source
codec := StringOrDash(t.Codec)
langs := FormatLanguages(t.Languages)
score := ""
if t.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
}
table.Append([]string{" ", quality, size, seeds, source, codec, TruncateString(langs, 12), score})
}
table.Render()
fmt.Fprintln(w)
}
// PrintPopularItems renders popular items as a colored table.
func PrintPopularItems(items []tc.PopularItem) {
if len(items) == 0 {
warnColor.Println("No popular items found.")
return
}
fmt.Println()
headerColor.Println(" 🔥 Popular on unarr")
fmt.Println()
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Seeds"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, item := range items {
table.Append([]string{
fmt.Sprintf(" %d", i+1),
TruncateString(item.Title, 40),
FormatYear(item.Year),
FormatContentType(item.ContentType),
FormatRating(item.RatingIMDb),
FormatNumber(item.MaxSeeders),
})
}
table.Render()
fmt.Println()
}
// PrintRecentItems renders recent items as a colored table.
func PrintRecentItems(items []tc.RecentItem) {
if len(items) == 0 {
warnColor.Println("No recent items found.")
return
}
fmt.Println()
headerColor.Println(" 🆕 Recently Added")
fmt.Println()
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"#", "Title", "Year", "Type", "IMDb", "Added"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, item := range items {
table.Append([]string{
fmt.Sprintf(" %d", i+1),
TruncateString(item.Title, 40),
FormatYear(item.Year),
FormatContentType(item.ContentType),
FormatRating(item.RatingIMDb),
FormatTimeAgo(item.CreatedAt),
})
}
table.Render()
fmt.Println()
}
// PrintStats renders system statistics.
func PrintStats(stats *tc.StatsResponse) {
fmt.Println()
headerColor.Println(" 📊 unarr Statistics")
fmt.Println()
boldColor.Print(" Content: ")
fmt.Printf("%s movies, %s shows\n", FormatNumber(stats.Content.Movies), FormatNumber(stats.Content.Shows))
boldColor.Print(" Enriched: ")
fmt.Printf("%s with TMDb metadata\n", FormatNumber(stats.Content.TMDbEnriched))
boldColor.Print(" Torrents: ")
fmt.Printf("%s total, %s with seeders\n", FormatNumber(stats.Torrents.Total), FormatNumber(stats.Torrents.WithSeeders))
if len(stats.Torrents.BySource) > 0 {
fmt.Println()
boldColor.Println(" Sources:")
for source, count := range stats.Torrents.BySource {
fmt.Printf(" %-20s %s\n", source, FormatNumber(count))
}
}
if len(stats.RecentIngestions) > 0 {
fmt.Println()
boldColor.Println(" Recent Ingestions:")
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Source", "Status", "Fetched", "New", "Updated"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, ing := range stats.RecentIngestions {
status := ing.Status
switch status {
case "completed":
status = successColor.Sprint("✓ done")
case "running":
status = warnColor.Sprint("⟳ running")
case "failed":
status = errorColor.Sprint("✗ failed")
}
table.Append([]string{
" ",
ing.Source,
status,
FormatNumber(ing.Fetched),
FormatNumber(ing.New),
FormatNumber(ing.Updated),
})
}
table.Render()
}
fmt.Println()
}
// PrintInspect renders the TrueSpec inspection output for a torrent.
func PrintInspect(title string, year string, torrents []tc.TorrentInfo, magnetURI string) {
fmt.Println()
titleColor.Printf(" 📋 %s", title)
if year != "" && year != "-" {
titleColor.Printf(" (%s)", year)
}
fmt.Println()
dimColor.Println(" " + strings.Repeat("─", len(title)+10))
if len(torrents) == 0 {
warnColor.Println(" No torrent details found.")
fmt.Println()
if magnetURI != "" {
dimColor.Println(" Magnet:")
fmt.Printf(" %s\n\n", magnetURI)
}
return
}
t := torrents[0]
printField := func(label, value string) {
boldColor.Printf(" %-12s", label+":")
fmt.Println(value)
}
printField("Quality", StringOrDash(t.Quality)+" "+StringOrDash(t.SourceType))
codecStr := StringOrDash(t.Codec)
if t.AudioCodec != nil {
codecStr += " / " + *t.AudioCodec
}
printField("Codec", codecStr)
printField("Size", FormatSize(t.SizeBytes))
printField("Seeds", fmt.Sprintf("%s %d | Leechers: %d", SeedHealthIndicator(t.Seeders), t.Seeders, t.Leechers))
printField("Languages", FormatLanguages(t.Languages))
printField("Source", t.Source)
if t.QualityScore != nil {
printField("Score", fmt.Sprintf("%s %d/100 (Quality Score)", QualityIndicator(t.QualityScore), *t.QualityScore))
}
printField("Health", fmt.Sprintf("%s (%s)", SeedHealthIndicator(t.Seeders), FormatSeedRatio(t.Seeders, t.Leechers)))
if t.HDRType != nil {
printField("HDR", *t.HDRType)
}
if t.ReleaseGroup != nil {
printField("Group", *t.ReleaseGroup)
}
var flags []string
if t.IsProper != nil && *t.IsProper {
flags = append(flags, "PROPER")
}
if t.IsRepack != nil && *t.IsRepack {
flags = append(flags, "REPACK")
}
if t.IsRemastered != nil && *t.IsRemastered {
flags = append(flags, "REMASTERED")
}
if len(flags) > 0 {
printField("Flags", strings.Join(flags, ", "))
}
fmt.Println()
if len(torrents) > 1 {
dimColor.Printf(" + %d more torrents available\n\n", len(torrents)-1)
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for i, tt := range torrents[1:] {
score := ""
if tt.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(tt.QualityScore), *tt.QualityScore)
}
table.Append([]string{
fmt.Sprintf(" %d", i+2),
StringOrDash(tt.Quality),
FormatSize(tt.SizeBytes),
fmt.Sprintf("%s %d", SeedHealthIndicator(tt.Seeders), tt.Seeders),
tt.Source,
score,
})
}
table.Render()
fmt.Println()
}
if magnetURI != "" {
dimColor.Println(" Magnet:")
fmt.Printf(" %s\n\n", magnetURI)
}
}
// PrintWatchProviders renders streaming and torrent options.
func PrintWatchProviders(title string, year string, providers *tc.WatchProvidersResponse, torrents []tc.TorrentInfo) {
fmt.Println()
titleColor.Printf(" 🎬 %s", title)
if year != "" && year != "-" {
titleColor.Printf(" (%s)", year)
}
fmt.Printf(" — Where to watch:\n\n")
hasStreaming := false
if providers != nil {
if len(providers.Providers.Flatrate) > 0 {
hasStreaming = true
successColor.Println(" 📺 SUBSCRIPTION (included):")
for _, p := range providers.Providers.Flatrate {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Free) > 0 {
hasStreaming = true
successColor.Println(" 🆓 FREE:")
for _, p := range providers.Providers.Free {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Rent) > 0 {
hasStreaming = true
warnColor.Println(" 💰 RENT:")
for _, p := range providers.Providers.Rent {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
if len(providers.Providers.Buy) > 0 {
hasStreaming = true
warnColor.Println(" 🛒 BUY:")
for _, p := range providers.Providers.Buy {
fmt.Printf(" • %s\n", p.Name)
}
fmt.Println()
}
}
if !hasStreaming {
dimColor.Println(" 📺 No streaming options found for your country.")
fmt.Println()
}
if len(torrents) > 0 {
headerColor.Println(" 🏴‍☠️ TORRENT:")
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"", "Quality", "Size", "Seeds", "Source", "Score"})
table.SetBorder(false)
table.SetColumnSeparator("")
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetRowSeparator("")
table.SetTablePadding(" ")
table.SetNoWhiteSpace(true)
for _, t := range torrents {
score := ""
if t.QualityScore != nil {
score = fmt.Sprintf("%s %d", QualityIndicator(t.QualityScore), *t.QualityScore)
}
table.Append([]string{
" ",
StringOrDash(t.Quality),
FormatSize(t.SizeBytes),
fmt.Sprintf("%s %d", SeedHealthIndicator(t.Seeders), t.Seeders),
t.Source,
score,
})
}
table.Render()
fmt.Println()
}
if hasStreaming {
successColor.Println(" 💡 Available on streaming services above.")
}
fmt.Println()
}