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
}