diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index ae7395b..045805d 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -41,6 +41,9 @@ type DaemonConfig struct { HWDevices []string // device files + driver bins detected at probe time AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true) Downlink string // realtime downlink transport: "auto" (SSE+long-poll fallback) | "sse" | "poll" + // PreferredMethods is the ordered download-method preference from config.toml + // (e.g. ["debrid","usenet"]). Reported to the web so it honours the gating. + PreferredMethods []string } // Daemon manages agent registration and the sync loop. @@ -165,6 +168,7 @@ func (d *Daemon) Register(ctx context.Context) error { VPNServer: d.vpnServer, FunnelURL: d.funnelURL, IsDocker: RunningInDocker(), + PreferredMethods: d.cfg.PreferredMethods, } if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil { req.DiskFreeBytes = free diff --git a/internal/agent/types.go b/internal/agent/types.go index 1667793..8f0e8fb 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -64,6 +64,10 @@ type RegisterRequest struct { // self-update refuses to run in Docker). No omitempty: false (a binary // install) is a meaningful state the server must see to keep the button. IsDocker bool `json:"isDocker"` + // PreferredMethods is the agent's ordered download-method preference from + // config.toml (e.g. ["debrid","usenet"]). The web honours it so a "debrid + // only" agent is never handed a torrent task. Empty/["auto"] → web decides. + PreferredMethods []string `json:"preferredMethods,omitempty"` } // RegisterResponse is returned by the server after registration. diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go index ecd81dc..0504050 100644 --- a/internal/cmd/config_menu.go +++ b/internal/cmd/config_menu.go @@ -157,9 +157,21 @@ func configDownloads(cfg *config.Config) error { concurrent = "3" } - validMethods := map[string]bool{"auto": true, "torrent": true, "debrid": true, "usenet": true} - if !validMethods[cfg.Download.PreferredMethod] { - cfg.Download.PreferredMethod = "auto" + // Method preference is an ordered list (PreferredMethods). The menu exposes + // the common presets as a single choice; custom orders can still be hand-set + // in config.toml. Derive the current preset from the effective order. + methodPreset := "auto" + switch strings.Join(cfg.Download.MethodOrder(), ",") { + case "torrent": + methodPreset = "torrent" + case "debrid": + methodPreset = "debrid" + case "usenet": + methodPreset = "usenet" + case "debrid,torrent": + methodPreset = "debrid,torrent" + case "debrid,usenet": + methodPreset = "debrid,usenet" } validQualities := map[string]bool{"": true, "720p": true, "1080p": true, "2160p": true} @@ -174,13 +186,16 @@ func configDownloads(cfg *config.Config) error { Value(&cfg.Download.Dir), huh.NewSelect[string](). Title("Preferred method"). + Description("Methods not listed are disabled (e.g. debrid-only never uses torrent)"). Options( - huh.NewOption("Auto (torrent + debrid when available)", "auto"), + huh.NewOption("Auto (web decides — torrent + debrid when available)", "auto"), huh.NewOption("Torrent only (BitTorrent P2P)", "torrent"), huh.NewOption("Debrid only (Real-Debrid, AllDebrid...)", "debrid"), huh.NewOption("Usenet only (requires Pro)", "usenet"), + huh.NewOption("Debrid, then torrent", "debrid,torrent"), + huh.NewOption("Debrid, then usenet (requires Pro)", "debrid,usenet"), ). - Value(&cfg.Download.PreferredMethod), + Value(&methodPreset), huh.NewSelect[string](). Title("Preferred quality"). Description("Hint for automatic torrent selection"). @@ -225,6 +240,15 @@ func configDownloads(cfg *config.Config) error { if n > 0 { cfg.Download.MaxConcurrent = n } + // Persist the preset as the ordered list (source of truth). "auto" clears the + // list; legacy PreferredMethod is kept in sync so an old reader still works. + if methodPreset == "auto" { + cfg.Download.PreferredMethods = nil + cfg.Download.PreferredMethod = "auto" + } else { + cfg.Download.PreferredMethods = strings.Split(methodPreset, ",") + cfg.Download.PreferredMethod = cfg.Download.PreferredMethods[0] + } return nil } diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index b45ae7f..440307f 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -248,6 +248,7 @@ func runDaemonStart() error { HWDevices: hwDiag.Devices, AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(), Downlink: cfg.Daemon.Downlink, + PreferredMethods: cfg.Download.MethodOrder(), } // Create HTTP client with mirror failover so a `.com` block-out rolls @@ -381,6 +382,17 @@ func runDaemonStart() error { // Create debrid downloader debridDl := engine.NewDebridDownloader() usenetDl := engine.NewUsenetDownloader(agentClient) + // Enable usenet when the user explicitly lists it in preferred_methods — the + // downloader gates on this flag, so without it a "usenet" preference would + // resolve to nothing. (Auto users keep the historical behaviour.) + methodOrder := cfg.Download.MethodOrder() + for _, m := range methodOrder { + if m == "usenet" { + usenetDl.SetEnabled(true) + log.Printf("[usenet] enabled via preferred_methods") + break + } + } // Pre-flight disk reserve: refuse a download that would leave less than this // many bytes free, so a download never fills the filesystem to 0 mid-write. @@ -392,9 +404,10 @@ func runDaemonStart() error { // Create download manager manager := engine.NewManager(engine.ManagerConfig{ - MaxConcurrent: cfg.Download.MaxConcurrent, - OutputDir: cfg.Download.Dir, - Notifications: cfg.Notifications.Enabled, + MaxConcurrent: cfg.Download.MaxConcurrent, + OutputDir: cfg.Download.Dir, + Notifications: cfg.Notifications.Enabled, + PreferredMethods: methodOrder, Organize: engine.OrganizeConfig{ Enabled: cfg.Organize.Enabled, MoviesDir: cfg.Organize.MoviesDir, diff --git a/internal/config/config.go b/internal/config/config.go index 93f82cc..5e44132 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,13 +45,21 @@ type AgentConfig struct { } type DownloadConfig struct { - Dir string `toml:"dir"` - PreferredMethod string `toml:"preferred_method"` - PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection - MaxConcurrent int `toml:"max_concurrent"` - MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable - MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited - MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited + Dir string `toml:"dir"` + // PreferredMethod (singular, legacy) — kept for back-compat. A single + // "auto"|"torrent"|"debrid"|"usenet". Superseded by PreferredMethods. + PreferredMethod string `toml:"preferred_method"` + // PreferredMethods (ordered list) is the source of truth when set, e.g. + // ["debrid","usenet"] = try debrid, then usenet, and DISABLE torrent (it's + // not in the list). ["auto"] or empty → defer to the web policy. The web + // honours this (reported on register) so a "debrid only" agent never gets a + // torrent task it didn't ask for. See MethodOrder() for resolution. + PreferredMethods []string `toml:"preferred_methods"` + PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection + MaxConcurrent int `toml:"max_concurrent"` + MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable + MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited + MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited // Seeding lifecycle (BitTorrent only). Off by default — the daemon leeches // then drops the torrent. Enable to keep uploading after a download finishes; // seeding stops at whichever target is hit first, or never if both are unset. @@ -256,6 +264,46 @@ func (t TrickplayConfig) IntervalSeconds() float64 { return 10 } +// validMethod reports whether s is a known download backend. +func validMethod(s string) bool { + return s == "torrent" || s == "debrid" || s == "usenet" +} + +// MethodOrder returns the effective ordered download-method preference, or nil +// for "auto" (defer to the web policy / torrent-first fallback). PreferredMethods +// (the list) wins; the legacy singular PreferredMethod is the fallback. "auto" +// anywhere collapses to nil. Unknown entries are dropped, dupes removed, order +// preserved. A nil/empty result means "no explicit preference". +func (c DownloadConfig) MethodOrder() []string { + src := c.PreferredMethods + if len(src) == 0 && c.PreferredMethod != "" { + src = []string{c.PreferredMethod} + } + out := make([]string, 0, len(src)) + for _, m := range src { + m = strings.ToLower(strings.TrimSpace(m)) + if m == "auto" { + return nil // auto anywhere → defer + } + if validMethod(m) && !contains(out, m) { + out = append(out, m) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + // Default returns a Config with sensible defaults. Used both for fresh // installs (no config file yet) and as the baseline for Load — fields not // present in the user's TOML keep their Default() value. diff --git a/internal/engine/manager.go b/internal/engine/manager.go index 66585cd..2b6d47f 100644 --- a/internal/engine/manager.go +++ b/internal/engine/manager.go @@ -15,6 +15,11 @@ type ManagerConfig struct { OutputDir string Organize OrganizeConfig Notifications bool // send desktop notifications on complete/fail + // PreferredMethods is the agent's ordered download-method preference from + // config.toml (e.g. ["debrid","usenet"]). Non-empty → it gates which methods + // resolveMethod will try, ignoring the per-task preference. Empty/nil → defer + // to the task's web-sent preference (legacy auto/torrent-first). + PreferredMethods []string } // Manager orchestrates concurrent downloads with method resolution and fallback. @@ -380,7 +385,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) { return } - method, err := resolveMethod(ctx, task, m.downloaders) + method, err := resolveMethod(ctx, task, m.downloaders, m.cfg.PreferredMethods) if err != nil { m.fail(ctx, task, "no method available: "+err.Error()) return @@ -416,7 +421,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) { return } // Try fallback - if tryFallback(task, m.downloaders) { + if tryFallback(task, m.downloaders, m.cfg.PreferredMethods) { log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err) if err := task.Transition(StatusResolving); err == nil { m.processTaskRetry(ctx, task) @@ -432,7 +437,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) { // processTaskRetry handles fallback after a method failure. func (m *Manager) processTaskRetry(ctx context.Context, task *Task) { - method, err := resolveMethod(ctx, task, m.downloaders) + method, err := resolveMethod(ctx, task, m.downloaders, m.cfg.PreferredMethods) if err != nil { m.fail(ctx, task, "fallback failed: "+err.Error()) return diff --git a/internal/engine/resolve.go b/internal/engine/resolve.go index 2c83352..0ee3792 100644 --- a/internal/engine/resolve.go +++ b/internal/engine/resolve.go @@ -6,21 +6,47 @@ import ( "log" ) -// resolveMethod determines which download method to use for a task. -// For "auto": tries available methods in priority order (torrent > debrid > usenet). -// For specific method: uses only that method. -func resolveMethod(ctx context.Context, task *Task, downloaders map[DownloadMethod]Downloader) (DownloadMethod, error) { - var order []DownloadMethod +// effectiveOrder returns the ordered methods to try for a task. +// +// The agent's local config (configMethods, from config.toml `preferred_methods`) +// WINS and gates: only the listed methods are eligible, in that order — so a +// "debrid only" agent never tries torrent even if the web's task says otherwise. +// When the config has no explicit preference (nil), we fall back to the per-task +// preference the web sent: a specific method runs alone; "auto" tries all three +// torrent-first (the historical default). +func effectiveOrder(task *Task, configMethods []string) []DownloadMethod { + if len(configMethods) > 0 { + order := make([]DownloadMethod, 0, len(configMethods)) + for _, m := range configMethods { + switch m { + case "torrent": + order = append(order, MethodTorrent) + case "debrid": + order = append(order, MethodDebrid) + case "usenet": + order = append(order, MethodUsenet) + } + } + if len(order) > 0 { + return order + } + } switch task.PreferredMethod { case "torrent": - order = []DownloadMethod{MethodTorrent} + return []DownloadMethod{MethodTorrent} case "debrid": - order = []DownloadMethod{MethodDebrid} + return []DownloadMethod{MethodDebrid} case "usenet": - order = []DownloadMethod{MethodUsenet} + return []DownloadMethod{MethodUsenet} default: // "auto" - order = []DownloadMethod{MethodTorrent, MethodDebrid, MethodUsenet} + return []DownloadMethod{MethodTorrent, MethodDebrid, MethodUsenet} } +} + +// resolveMethod determines which download method to use for a task, honouring the +// agent's configured method order (gating) over the per-task preference. +func resolveMethod(ctx context.Context, task *Task, downloaders map[DownloadMethod]Downloader, configMethods []string) (DownloadMethod, error) { + order := effectiveOrder(task, configMethods) for _, method := range order { // Skip already-tried methods @@ -54,22 +80,34 @@ func resolveMethod(ctx context.Context, task *Task, downloaders map[DownloadMeth } } - return "", fmt.Errorf("no download method available (tried: %v)", task.TriedMethods) + return "", fmt.Errorf("no download method available (order: %v, tried: %v)", order, task.TriedMethods) } -// tryFallback attempts to fall back to the next untried download method. -// Returns true if fallback was initiated, false if no more methods. -func tryFallback(task *Task, downloaders map[DownloadMethod]Downloader) bool { - if task.PreferredMethod != "auto" { - return false // specific method requested, no fallback +// tryFallback attempts to fall back to the next untried download method WITHIN +// the effective order. A single-method order (e.g. "debrid only") has no +// fallback — failing over to torrent would defeat the whole preference. +func tryFallback(task *Task, downloaders map[DownloadMethod]Downloader, configMethods []string) bool { + order := effectiveOrder(task, configMethods) + if len(order) <= 1 { + return false // single method requested, no fallback } task.TriedMethods = append(task.TriedMethods, task.ResolvedMethod) - available := make([]DownloadMethod, 0, len(downloaders)) - for m := range downloaders { - available = append(available, m) + for _, m := range order { + tried := false + for _, tm := range task.TriedMethods { + if tm == m { + tried = true + break + } + } + if tried { + continue + } + if _, ok := downloaders[m]; ok { + return true + } } - - return task.HasUntried(available) + return false } diff --git a/internal/engine/resolve_test.go b/internal/engine/resolve_test.go index 6e0171c..ae6f8ea 100644 --- a/internal/engine/resolve_test.go +++ b/internal/engine/resolve_test.go @@ -31,7 +31,7 @@ func TestResolveMethodAuto(t *testing.T) { } task := &Task{PreferredMethod: "auto"} - method, err := resolveMethod(context.Background(), task, downloaders) + method, err := resolveMethod(context.Background(), task, downloaders, nil) if err != nil { t.Fatal(err) } @@ -48,7 +48,7 @@ func TestResolveMethodSpecific(t *testing.T) { } task := &Task{PreferredMethod: "debrid"} - method, err := resolveMethod(context.Background(), task, downloaders) + method, err := resolveMethod(context.Background(), task, downloaders, nil) if err != nil { t.Fatal(err) } @@ -67,7 +67,7 @@ func TestResolveMethodSkipsTried(t *testing.T) { PreferredMethod: "auto", TriedMethods: []DownloadMethod{MethodTorrent}, } - method, err := resolveMethod(context.Background(), task, downloaders) + method, err := resolveMethod(context.Background(), task, downloaders, nil) if err != nil { t.Fatal(err) } @@ -82,7 +82,7 @@ func TestResolveMethodNoneAvailable(t *testing.T) { } task := &Task{PreferredMethod: "auto"} - _, err := resolveMethod(context.Background(), task, downloaders) + _, err := resolveMethod(context.Background(), task, downloaders, nil) if err == nil { t.Error("expected error when no method available") } @@ -95,7 +95,7 @@ func TestResolveMethodAvailabilityError(t *testing.T) { } task := &Task{ID: "test-resolve-err", PreferredMethod: "auto"} - method, err := resolveMethod(context.Background(), task, downloaders) + method, err := resolveMethod(context.Background(), task, downloaders, nil) if err != nil { t.Fatal(err) } @@ -116,7 +116,7 @@ func TestTryFallbackAutoMode(t *testing.T) { ResolvedMethod: MethodTorrent, } - if !tryFallback(task, downloaders) { + if !tryFallback(task, downloaders, nil) { t.Error("should have fallback available") } if len(task.TriedMethods) != 1 || task.TriedMethods[0] != MethodTorrent { @@ -135,7 +135,82 @@ func TestTryFallbackSpecificMode(t *testing.T) { ResolvedMethod: MethodTorrent, } - if tryFallback(task, downloaders) { + if tryFallback(task, downloaders, nil) { t.Error("should not fallback in specific mode") } } + +// ── config.toml preferred_methods gating ────────────────────────────────── + +// Config list wins over the per-task preference: a "debrid only" agent picks +// debrid even when the task says auto AND torrent is available (the old bug: +// torrent-first auto stole every download from debrid-only users). +func TestResolveMethodConfigListWins(t *testing.T) { + downloaders := map[DownloadMethod]Downloader{ + MethodTorrent: &mockDownloader{method: MethodTorrent, available: true}, + MethodDebrid: &mockDownloader{method: MethodDebrid, available: true}, + } + task := &Task{PreferredMethod: "auto"} // web said auto + method, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid"}) + if err != nil { + t.Fatal(err) + } + if method != MethodDebrid { + t.Errorf("method = %q, want debrid (config list debrid-only wins)", method) + } +} + +// A method not in the config list is NEVER used, even if it's the only one +// available — failing closed is the whole point of "debrid only". +func TestResolveMethodConfigListGatesOutTorrent(t *testing.T) { + downloaders := map[DownloadMethod]Downloader{ + MethodTorrent: &mockDownloader{method: MethodTorrent, available: true}, + MethodDebrid: &mockDownloader{method: MethodDebrid, available: false}, + } + task := &Task{PreferredMethod: "auto"} + _, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid"}) + if err == nil { + t.Error("expected error: torrent is available but not in the debrid-only list") + } +} + +// Ordered list honours order: debrid first, then usenet. +func TestResolveMethodConfigListOrder(t *testing.T) { + downloaders := map[DownloadMethod]Downloader{ + MethodTorrent: &mockDownloader{method: MethodTorrent, available: true}, + MethodDebrid: &mockDownloader{method: MethodDebrid, available: false}, + MethodUsenet: &mockDownloader{method: MethodUsenet, available: true}, + } + task := &Task{PreferredMethod: "auto"} + method, err := resolveMethod(context.Background(), task, downloaders, []string{"debrid", "usenet"}) + if err != nil { + t.Fatal(err) + } + if method != MethodUsenet { + t.Errorf("method = %q, want usenet (debrid unavailable, next in list)", method) + } +} + +// A multi-method config list allows fallback within the list... +func TestTryFallbackConfigListMulti(t *testing.T) { + downloaders := map[DownloadMethod]Downloader{ + MethodTorrent: &mockDownloader{method: MethodTorrent, available: true}, + MethodDebrid: &mockDownloader{method: MethodDebrid, available: true}, + } + task := &Task{PreferredMethod: "auto", ResolvedMethod: MethodDebrid} + if !tryFallback(task, downloaders, []string{"debrid", "torrent"}) { + t.Error("should fall back to torrent (in the list)") + } +} + +// ...but a single-method config list has no fallback (debrid-only never torrents). +func TestTryFallbackConfigListSingle(t *testing.T) { + downloaders := map[DownloadMethod]Downloader{ + MethodTorrent: &mockDownloader{method: MethodTorrent, available: true}, + MethodDebrid: &mockDownloader{method: MethodDebrid, available: true}, + } + task := &Task{PreferredMethod: "auto", ResolvedMethod: MethodDebrid} + if tryFallback(task, downloaders, []string{"debrid"}) { + t.Error("debrid-only must not fall back to torrent") + } +}