feat(downloads): ordered preferred_methods list honored for web tasks
The agent ignored its config.toml method preference for web-driven downloads (only the local `unarr download` command read it), and resolveMethod tried torrent first in auto mode — so a 'debrid only' user still got torrent tasks. - config: preferred_methods (ordered list, e.g. ["debrid","usenet"]) with MethodOrder() resolution; back-compat with the singular preferred_method. Methods absent from the list are disabled (debrid-only never torrents). - resolveMethod/tryFallback honor the config order (gating, no fallback to a method outside the list) over the per-task preference. - report preferred_methods on register so the web honors it (resolves debrid, gates the P2P stream fallback). - enable the usenet downloader when usenet is listed (it was never enabled). - config_menu: ordered presets (debrid-only, debrid→torrent, debrid→usenet…). Tests: resolveMethod gating + fallback within/outside the list.
This commit is contained in:
parent
523ecc724a
commit
c7ee0c0a28
8 changed files with 256 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue