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>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 1914fcbcdf
commit f3fa77f318

@ -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",

@ -573,6 +573,8 @@
"remoteDownloadHttpUser": "HTTP 用户名(可选)",
"remoteDownloadHttpPassword": "HTTP 密码(可选)",
"remoteDownloadHeaders": "请求头(可选)",
"remoteDownloadProvider": "下载器",
"remoteDownloadProviderAuto": "自动(任意节点)",
"remoteDownloadHeadersDescription": "每行一个请求头,例如 Cookie: sid=...",
"processNode": "处理节点",
"remoteDownloadNodeAuto": "自动分配",

@ -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;
}

@ -105,6 +105,7 @@ export interface DownloadWorkflowService {
username?: string;
password?: string;
headers?: string[];
provider?: string;
}
export interface ImportWorkflowService {

@ -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<string, string> = {
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 (
<DraggableDialog
@ -137,6 +148,28 @@ const CreateRemoteDownload = () => {
fullWidth
/>
</Stack>
{providers && providers.length > 1 && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<FormControl variant="outlined" fullWidth>
<InputLabel>{t("application:modals.remoteDownloadProvider")}</InputLabel>
<Select
variant="outlined"
label={t("application:modals.remoteDownloadProvider")}
value={provider}
onChange={(e) => setProvider(e.target.value as string)}
>
<MenuItem value="">
<em>{t("application:modals.remoteDownloadProviderAuto")}</em>
</MenuItem>
{providers.map((p) => (
<MenuItem key={p} value={p}>
{providerDisplayName(p)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
)}
{!target && (
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
<OutlineIconTextField

@ -48,6 +48,9 @@ type (
SrcUri string `json:"src_uri,omitempty"`
Dst string `json:"dst,omitempty"`
FileName string `json:"file_name,omitempty"`
// Provider pins the task to a node offering this downloader provider
// (e.g. "aria2"/"qbittorrent"). Empty = pool picks any capable node.
Provider string `json:"provider,omitempty"`
HTTPUsername string `json:"http_username,omitempty"`
HTTPPassword string `json:"http_password,omitempty"`
HTTPHeaders []string `json:"http_headers,omitempty"`
@ -96,6 +99,7 @@ func init() {
// plain HTTP(S) sources.
type RemoteDownloadTaskOption struct {
FileName string
Provider string
HTTPUsername string
HTTPPassword string
HTTPHeaders []string
@ -111,6 +115,7 @@ func NewRemoteDownloadTask(ctx context.Context, src string, srcFile, dst string,
}
if opts != nil {
state.FileName = sanitizeFileName(opts.FileName)
state.Provider = opts.Provider
state.HTTPUsername = opts.HTTPUsername
state.HTTPPassword = opts.HTTPPassword
state.HTTPHeaders = opts.HTTPHeaders
@ -153,6 +158,12 @@ func (m *RemoteDownloadTask) Do(ctx context.Context) (task.Status, error) {
}
m.state = state
// Resolve a user-picked downloader provider to a preferred node. Runs only
// until a node is locked in; falls back to any capable node if no match.
if m.state.NodeID == 0 && m.state.Provider != "" {
m.state.NodeID = providerNodeID(ctx, dep, m.state.Provider)
}
// select node
node, err := allocateNode(ctx, dep, &m.state.NodeState, types.NodeCapabilityRemoteDownload)
if err != nil {
@ -825,3 +836,28 @@ func sanitizeFileName(name string) string {
r := strings.NewReplacer("\\", "_", "/", "_", ":", "_", "*", "_", "?", "_", "\"", "_", "<", "_", ">", "_", "|", "_")
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
}

@ -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)))
}

@ -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
}

@ -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)
}

Loading…
Cancel
Save