Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
212 lines
5.1 KiB
Go
212 lines
5.1 KiB
Go
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
|
|
}
|