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