unarr/internal/agent/active_tasks.go

106 lines
3 KiB
Go
Raw Normal View History

package agent
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/torrentclaw/unarr/internal/config"
)
// activeTasksFilePathFn is overridable for testing.
var activeTasksFilePathFn = func() string {
return filepath.Join(config.DataDir(), "active-tasks.json")
}
// ActiveTaskStore persists the dispatch payloads (agent.Task) of in-flight
// DOWNLOAD tasks so the daemon can re-submit them after a restart and have the
// downloaders resume the partial data — torrent via the persisted
// piece-completion DB, debrid via HTTP Range, usenet via its segment tracker.
//
// Distinct from LocalState (tasks.json), which holds transient status/progress
// for syncing to the web; this holds the re-dispatch payload needed to restart
// the work. An entry is added when a download starts and removed when it
// reaches a genuine terminal state (completed / failed / cancelled) — but NOT
// when the daemon is shutting down, so an interrupted download survives the
// restart and resumes.
type ActiveTaskStore struct {
mu sync.Mutex
tasks map[string]Task
}
// NewActiveTaskStore creates an empty store. Call Load() to hydrate it from disk.
func NewActiveTaskStore() *ActiveTaskStore {
return &ActiveTaskStore{tasks: make(map[string]Task)}
}
// Add records (or replaces) a task and persists the set.
func (s *ActiveTaskStore) Add(t Task) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[t.ID] = t
s.flushLocked()
}
// Remove drops a task and persists the set. No-op if absent.
func (s *ActiveTaskStore) Remove(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[taskID]; !ok {
return
}
delete(s.tasks, taskID)
s.flushLocked()
}
// Load reads the persisted tasks from disk into the store and returns them.
// Returns nil on a missing or unreadable file (a fresh daemon has nothing to
// resume). Safe to call once at startup before any Add/Remove.
func (s *ActiveTaskStore) Load() []Task {
data, err := os.ReadFile(activeTasksFilePathFn())
if err != nil {
return nil
}
var tasks []Task
if json.Unmarshal(data, &tasks) != nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
s.tasks = make(map[string]Task, len(tasks))
for _, t := range tasks {
if t.ID != "" {
s.tasks[t.ID] = t
}
}
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
// flushLocked atomically writes the current set to disk. Caller holds s.mu.
// Best-effort: a write failure is non-fatal (the in-memory set stays correct;
// at worst a crash before the next flush loses one resume entry).
func (s *ActiveTaskStore) flushLocked() {
tasks := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
tasks = append(tasks, t)
}
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return
}
path := activeTasksFilePathFn()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, path)
}