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
212
internal/engine/task.go
Normal file
212
internal/engine/task.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue