feat: add 307 redirect download support

This commit is contained in:
shiyu
2025-09-25 14:56:17 +08:00
parent 00462f2259
commit 0399011406
6 changed files with 79 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = <Input placeholder={field.placeholder} />;
let valuePropName: string | undefined;
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
if (field.type === 'boolean') {
inputNode = <Switch />;
valuePropName = 'checked';
}
return (
<Form.Item
key={field.key}
name={['config', field.key]}
label={t(field.label)}
rules={rules}
valuePropName={valuePropName}
>
{inputNode}
</Form.Item>