feat: initial commit — unarr CLI
Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
This commit is contained in:
commit
29cf0a0126
85 changed files with 10178 additions and 0 deletions
148
internal/agent/client.go
Normal file
148
internal/agent/client.go
Normal 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
|
||||
}
|
||||
285
internal/agent/client_test.go
Normal file
285
internal/agent/client_test.go
Normal 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
154
internal/agent/daemon.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
internal/agent/disk_unix.go
Normal file
17
internal/agent/disk_unix.go
Normal 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
|
||||
}
|
||||
31
internal/agent/disk_windows.go
Normal file
31
internal/agent/disk_windows.go
Normal 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
115
internal/agent/types.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue