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 = ; if (field.type === 'number') inputNode = ; + if (field.type === 'boolean') { + inputNode = ; + valuePropName = 'checked'; + } return ( {inputNode}