diff --git a/api/routes/virtual_fs.py b/api/routes/virtual_fs.py
index 59fcfef..adcfaa6 100644
--- a/api/routes/virtual_fs.py
+++ b/api/routes/virtual_fs.py
@@ -15,6 +15,7 @@ from services.virtual_fs import (
stream_file,
generate_temp_link_token,
verify_temp_link_token,
+ maybe_redirect_download,
)
from services.thumbnail import is_image_filename, get_or_create_thumb, is_raw_filename
from schemas import MkdirRequest, MoveRequest
@@ -50,6 +51,12 @@ async def get_file(
except Exception as e:
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
+ adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(full_path)
+
+ redirect_response = await maybe_redirect_download(adapter_instance, adapter_model, root, rel)
+ if redirect_response is not None:
+ return redirect_response
+
try:
content = await read_file(full_path)
except FileNotFoundError:
diff --git a/services/adapters/onedrive.py b/services/adapters/onedrive.py
index e574ef8..e23b3a4 100644
--- a/services/adapters/onedrive.py
+++ b/services/adapters/onedrive.py
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone, timedelta
from typing import List, Dict, Tuple, AsyncIterator
import httpx
-from fastapi.responses import StreamingResponse
+from fastapi.responses import StreamingResponse, Response
from fastapi import HTTPException
from models import StorageAdapter
@@ -20,6 +20,7 @@ class OneDriveAdapter:
self.client_secret = cfg.get("client_secret")
self.refresh_token = cfg.get("refresh_token")
self.root = cfg.get("root", "/").strip("/")
+ self.enable_redirect_307 = bool(cfg.get("enable_direct_download_307"))
if not all([self.client_id, self.client_secret, self.refresh_token]):
raise ValueError(
@@ -380,6 +381,26 @@ class OneDriveAdapter:
return StreamingResponse(file_iterator(), status_code=status, headers=headers, media_type=content_type)
+ async def get_direct_download_response(self, root: str, rel: str):
+ if not self.enable_redirect_307:
+ return None
+
+ api_path = self._get_api_path(rel)
+ if not api_path:
+ raise IsADirectoryError("不能对目录进行直链重定向")
+
+ resp = await self._request("GET", api_path_segment=api_path)
+ if resp.status_code == 404:
+ raise FileNotFoundError(rel)
+ resp.raise_for_status()
+
+ item_data = resp.json()
+ download_url = item_data.get("@microsoft.graph.downloadUrl")
+ if not download_url:
+ return None
+
+ return Response(status_code=307, headers={"Location": download_url})
+
async def get_thumbnail(self, root: str, rel: str, size: str = "medium"):
"""
获取文件的缩略图。
@@ -434,6 +455,7 @@ CONFIG_SCHEMA = [
"required": True, "help_text": "可以通过运行 'python -m services.adapters.onedrive' 获取"},
{"key": "root", "label": "根目录 (Root Path)", "type": "string",
"required": False, "placeholder": "默认为根目录 /"},
+ {"key": "enable_direct_download_307", "label": "Enable 307 redirect download", "type": "boolean", "default": False},
]
diff --git a/services/adapters/quark.py b/services/adapters/quark.py
index 6c1c9d5..ad2c978 100644
--- a/services/adapters/quark.py
+++ b/services/adapters/quark.py
@@ -34,8 +34,15 @@ class QuarkAdapter:
cfg = record.config or {}
self.cookie: str = cfg.get("cookie") or cfg.get("Cookie")
self.root_fid: str = cfg.get("root_fid", "0")
- self.use_transcoding_address: bool = bool(cfg.get("use_transcoding_address", False))
- self.only_list_video_file: bool = bool(cfg.get("only_list_video_file", False))
+ def _as_bool(value: Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+ self.use_transcoding_address: bool = _as_bool(cfg.get("use_transcoding_address", False))
+ self.only_list_video_file: bool = _as_bool(cfg.get("only_list_video_file", False))
if not self.cookie:
raise ValueError("Quark 适配器需要 cookie 配置")
@@ -716,8 +723,8 @@ ADAPTER_TYPE = "Quark"
CONFIG_SCHEMA = [
{"key": "cookie", "label": "Cookie", "type": "password", "required": True, "placeholder": "从 pan.quark.cn 复制"},
{"key": "root_fid", "label": "根 FID", "type": "string", "required": False, "default": "0"},
- {"key": "use_transcoding_address", "label": "视频转码直链", "type": "checkbox", "required": False, "default": False},
- {"key": "only_list_video_file", "label": "仅列出视频文件", "type": "checkbox", "required": False, "default": False},
+ {"key": "use_transcoding_address", "label": "视频转码直链", "type": "boolean", "required": False, "default": False},
+ {"key": "only_list_video_file", "label": "仅列出视频文件", "type": "boolean", "required": False, "default": False},
]
def ADAPTER_FACTORY(rec: StorageAdapter) -> BaseAdapter:
diff --git a/services/virtual_fs.py b/services/virtual_fs.py
index faed713..cdde974 100644
--- a/services/virtual_fs.py
+++ b/services/virtual_fs.py
@@ -23,6 +23,7 @@ from services.config import ConfigCenter
CROSS_TRANSFER_TEMP_ROOT = Path("data/tmp/cross_transfer")
+DIRECT_REDIRECT_CONFIG_KEY = "enable_direct_download_307"
if TYPE_CHECKING:
from services.task_queue import Task
@@ -87,6 +88,31 @@ async def resolve_adapter_and_rel(path: str):
return adapter_instance, adapter_model, effective_root, rel
+async def maybe_redirect_download(adapter_instance, adapter_model, root: str, rel: str):
+ """若适配器启用了 307 直链,尝试构造重定向响应。"""
+ if not rel or rel.endswith('/'):
+ return None
+
+ config = getattr(adapter_model, "config", {}) or {}
+ if not config.get(DIRECT_REDIRECT_CONFIG_KEY):
+ return None
+
+ handler = getattr(adapter_instance, "get_direct_download_response", None)
+ if not callable(handler):
+ return None
+
+ try:
+ response = await handler(root, rel)
+ except FileNotFoundError:
+ raise
+ except Exception:
+ return None
+
+ if isinstance(response, Response):
+ return response
+ return None
+
+
async def _ensure_method(adapter: Any, method: str):
func = getattr(adapter, method, None)
if not callable(func):
@@ -437,7 +463,7 @@ async def rename_path(src: str, dst: str, overwrite: bool = False, return_debug:
async def stream_file(path: str, range_header: str | None):
- adapter_instance, _, root, rel = await resolve_adapter_and_rel(path)
+ adapter_instance, adapter_model, root, rel = await resolve_adapter_and_rel(path)
if not rel or rel.endswith('/'):
raise HTTPException(400, detail="Path is a directory")
if is_raw_filename(rel):
@@ -470,6 +496,10 @@ async def stream_file(path: str, range_header: str | None):
except Exception as e:
raise HTTPException(500, detail=f"RAW file processing failed: {e}")
+ redirect_response = await maybe_redirect_download(adapter_instance, adapter_model, root, rel)
+ if redirect_response is not None:
+ return redirect_response
+
stream_impl = getattr(adapter_instance, "stream_file", None)
if callable(stream_impl):
return await stream_impl(root, rel, range_header)
diff --git a/web/src/api/adapters.ts b/web/src/api/adapters.ts
index 7bcf1e4..d1861d9 100644
--- a/web/src/api/adapters.ts
+++ b/web/src/api/adapters.ts
@@ -13,7 +13,7 @@ export interface AdapterItem {
export interface AdapterTypeField {
key: string;
label: string;
- type: 'string' | 'password' | 'number';
+ type: 'string' | 'password' | 'number' | 'boolean';
required?: boolean;
placeholder?: string;
default?: any;
diff --git a/web/src/pages/AdaptersPage.tsx b/web/src/pages/AdaptersPage.tsx
index 57e4649..5a42ef5 100644
--- a/web/src/pages/AdaptersPage.tsx
+++ b/web/src/pages/AdaptersPage.tsx
@@ -185,14 +185,20 @@ const AdaptersPage = memo(function AdaptersPage() {
return currentTypeMeta.config_schema.map(field => {
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
let inputNode: any = ;
+ let valuePropName: string | undefined;
if (field.type === 'password') inputNode =