From f3fa77f31893a178e41767267c0847cc5ceda986 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sat, 19 Sep 2026 02:28:58 +0200 Subject: [PATCH] feat: per-download downloader provider selection (#36) Users can now pick which downloader (Aria2/qBittorrent) handles a remote download when multiple providers are available: - Site config exposes remote_download_providers, computed from active RemoteDownload-capable nodes. - DownloadWorkflowService accepts provider and validates it against the live pool. - RemoteDownloadTaskState carries provider; the task resolves it to a preferred node ID on first run (lowest-ID match, pool fallback when unmatched). - Dialog shows a provider picker only when 2+ providers exist. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../public/locales/en-US/application.json | 2 + .../public/locales/zh-CN/application.json | 2 + frontend/src/api/site.ts | 1 + frontend/src/api/workflow.ts | 1 + .../Dialogs/CreateRemoteDownload.tsx | 37 ++++++++++++++++++- pkg/filemanager/workflows/remote_download.go | 36 ++++++++++++++++++ .../workflows/remote_download_test.go | 20 ++++++++++ service/basic/site.go | 26 +++++++++++++ service/explorer/workflows.go | 18 +++++++++ 9 files changed, 141 insertions(+), 2 deletions(-) diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index 607fcffa..5395a3d5 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -573,6 +573,8 @@ "remoteDownloadHttpUser": "HTTP username (optional)", "remoteDownloadHttpPassword": "HTTP password (optional)", "remoteDownloadHeaders": "Request headers (optional)", + "remoteDownloadProvider": "Downloader", + "remoteDownloadProviderAuto": "Auto (any node)", "remoteDownloadHeadersDescription": "One header per line, e.g. Cookie: sid=...", "processNode": "Target node", "remoteDownloadNodeAuto": "Auto dispatch", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index b4e2a969..3a5d3fa4 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -573,6 +573,8 @@ "remoteDownloadHttpUser": "HTTP 用户名(可选)", "remoteDownloadHttpPassword": "HTTP 密码(可选)", "remoteDownloadHeaders": "请求头(可选)", + "remoteDownloadProvider": "下载器", + "remoteDownloadProviderAuto": "自动(任意节点)", "remoteDownloadHeadersDescription": "每行一个请求头,例如 Cookie: sid=...", "processNode": "处理节点", "remoteDownloadNodeAuto": "自动分配", diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts index 47c140b3..70ce8214 100644 --- a/frontend/src/api/site.ts +++ b/frontend/src/api/site.ts @@ -56,6 +56,7 @@ export interface SiteConfig { thumb_exts?: string[]; show_encryption_status?: boolean; full_text_search?: boolean; + remote_download_providers?: string[]; share_default_private?: boolean; default_share_links_in_profile?: string; } diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 601c3908..5fd805d9 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -105,6 +105,7 @@ export interface DownloadWorkflowService { username?: string; password?: string; headers?: string[]; + provider?: string; } export interface ImportWorkflowService { diff --git a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx index dcda903f..00845748 100644 --- a/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx +++ b/frontend/src/component/FileManager/Dialogs/CreateRemoteDownload.tsx @@ -1,4 +1,4 @@ -import { DialogContent, Stack, useMediaQuery, useTheme } from "@mui/material"; +import { DialogContent, FormControl, InputLabel, MenuItem, Select, Stack, useMediaQuery, useTheme } from "@mui/material"; import { useSnackbar } from "notistack"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -19,6 +19,13 @@ import LockClosedKey from "../../Icons/LockClosedKey.tsx"; import PersonOutlined from "../../Icons/PersonOutlined.tsx"; import { FileManagerIndex } from "../FileManager.tsx"; +const providerNames: Record = { + aria2: "Aria2", + qbittorrent: "qBittorrent", +}; + +const providerDisplayName = (p: string) => providerNames[p] ?? p; + const CreateRemoteDownload = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -33,7 +40,9 @@ const CreateRemoteDownload = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [headers, setHeaders] = useState(""); + const [provider, setProvider] = useState(""); + const providers = useAppSelector((state) => state.siteConfig.explorer?.config?.remote_download_providers); const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen); const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); @@ -48,6 +57,7 @@ const CreateRemoteDownload = () => { setUsername(""); setPassword(""); setHeaders(""); + setProvider(""); } }, [open]); @@ -70,6 +80,7 @@ const CreateRemoteDownload = () => { username: username || undefined, password: password || undefined, headers: headers ? headers.split("\n").filter((h) => h.trim()) : undefined, + provider: provider || undefined, }), ) .then(() => { @@ -83,7 +94,7 @@ const CreateRemoteDownload = () => { .finally(() => { setLoading(false); }); - }, [target, url, path, fileName, username, password, headers]); + }, [target, url, path, fileName, username, password, headers, provider]); return ( { fullWidth /> + {providers && providers.length > 1 && ( + + + {t("application:modals.remoteDownloadProvider")} + + + + )} {!target && ( ", "_", "|", "_") return r.Replace(name) } + +// providerNodeID resolves a downloader provider name to the lowest-ID active +// node offering it for remote download. Returns 0 when no node matches, which +// makes the pool fall back to any capable node. +func providerNodeID(ctx context.Context, dep dependency.Dep, provider string) int { + nodes, err := dep.NodeClient().ListActiveNodes(ctx, nil) + if err != nil { + return 0 + } + return PickProviderNode(nodes, provider) +} + +func PickProviderNode(nodes []*ent.Node, provider string) int { + best := 0 + for _, n := range nodes { + if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) || + n.Settings == nil || string(n.Settings.Provider) != provider { + continue + } + if best == 0 || n.ID < best { + best = n.ID + } + } + return best +} diff --git a/pkg/filemanager/workflows/remote_download_test.go b/pkg/filemanager/workflows/remote_download_test.go index 90a814ef..d867ae46 100644 --- a/pkg/filemanager/workflows/remote_download_test.go +++ b/pkg/filemanager/workflows/remote_download_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "testing" + "github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" "github.com/cloudreve/Cloudreve/v4/pkg/cluster" "github.com/cloudreve/Cloudreve/v4/pkg/downloader" "github.com/stretchr/testify/assert" @@ -159,3 +161,21 @@ func TestNewRemoteDownloadTaskSanitizesFileName(t *testing.T) { a.NoError(json.Unmarshal([]byte(tsk.(*RemoteDownloadTask).Task.PrivateState), state)) a.Equal(".._.._etc_passwd", state.FileName) } + +func TestPickProviderNode(t *testing.T) { + caps := &boolset.BooleanSet{} + boolset.Set(types.NodeCapabilityRemoteDownload, true, caps) + + nodes := []*ent.Node{ + {ID: 3, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderQBittorrent}}, + {ID: 1, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 2, Capabilities: caps, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 4, Capabilities: nil, Settings: &types.NodeSetting{Provider: types.DownloaderProviderAria2}}, + {ID: 5, Capabilities: caps, Settings: nil}, + } + + assert.Equal(t, 1, PickProviderNode(nodes, string(types.DownloaderProviderAria2))) + assert.Equal(t, 3, PickProviderNode(nodes, string(types.DownloaderProviderQBittorrent))) + assert.Equal(t, 0, PickProviderNode(nodes, "deluge")) + assert.Equal(t, 0, PickProviderNode(nil, string(types.DownloaderProviderAria2))) +} diff --git a/service/basic/site.go b/service/basic/site.go index 5f126700..1796e8f7 100644 --- a/service/basic/site.go +++ b/service/basic/site.go @@ -67,6 +67,10 @@ type SiteConfig struct { CustomProps []types.CustomProps `json:"custom_props,omitempty"` ShowEncryptionStatus bool `json:"show_encryption_status,omitempty"` FullTextSearch bool `json:"full_text_search,omitempty"` + // RemoteDownloadProviders lists distinct downloader providers offered by + // active remote-download-capable nodes, so clients can let users pick + // between e.g. Aria2 and qBittorrent per download. + RemoteDownloadProviders []string `json:"remote_download_providers,omitempty"` // Thumbnail section ThumbExts []string `json:"thumb_exts,omitempty"` @@ -139,6 +143,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) { CustomProps: customProps, ShowEncryptionStatus: showEncryptionStatus, FullTextSearch: settings.FTSEnabled(c), + RemoteDownloadProviders: remoteDownloadProviders(c, dep), }, nil case "emojis": emojis := settings.EmojiPresets(c) @@ -270,3 +275,24 @@ func GetCaptchaImage(c *gin.Context) *CaptchaResponse { Ticket: idKeyD, } } + +// remoteDownloadProviders returns the distinct downloader providers offered by +// active nodes with remote-download capability, in stable sorted order. +func remoteDownloadProviders(c *gin.Context, dep dependency.Dep) []string { + nodes, err := dep.NodeClient().ListActiveNodes(c, nil) + if err != nil { + return nil + } + seen := map[string]bool{} + var providers []string + for _, n := range nodes { + if n.Capabilities == nil || !n.Capabilities.Enabled(int(types.NodeCapabilityRemoteDownload)) || + n.Settings == nil || n.Settings.Provider == "" || seen[string(n.Settings.Provider)] { + continue + } + seen[string(n.Settings.Provider)] = true + providers = append(providers, string(n.Settings.Provider)) + } + sort.Strings(providers) + return providers +} diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index 4f8da763..7c7de4f2 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -81,6 +81,7 @@ type ( Username string `json:"username" binding:"omitempty,max=255"` Password string `json:"password" binding:"omitempty,max=255"` Headers []string `json:"headers" binding:"omitempty,max=32,dive,max=2048"` + Provider string `json:"provider" binding:"omitempty,max=64"` } CreateDownloadParamCtx struct{} ) @@ -144,9 +145,16 @@ func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*T } } + // Validate a requested downloader provider against what the node pool + // actually offers. + if service.Provider != "" && providerNodeAvailable(c, dep, service.Provider) == 0 { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid downloader provider", nil) + } + // Custom file name only applies to single-source tasks; HTTP credentials // and headers only apply to plain HTTP(S) source URLs. taskOpts := &workflows.RemoteDownloadTaskOption{ + Provider: service.Provider, HTTPUsername: service.Username, HTTPPassword: service.Password, HTTPHeaders: service.Headers, @@ -679,3 +687,13 @@ func (service *BlobAuditWorkflowService) CreateBlobAuditTask(c *gin.Context) (*T return BuildTaskResponse(t, nil, hasher), nil } + +// providerNodeAvailable returns the ID of an active remote-download node +// offering the given provider, or 0 when none does. +func providerNodeAvailable(c *gin.Context, dep dependency.Dep, provider string) int { + nodes, err := dep.NodeClient().ListActiveNodes(c, nil) + if err != nil { + return 0 + } + return workflows.PickProviderNode(nodes, provider) +}