802 lines
24 KiB
Python
802 lines
24 KiB
Python
"""通用 HTTP Range 代理。
|
||
供浏览器、WebDAV、STRM、文件下载这些需要本地代理的场景共用。
|
||
"""
|
||
|
||
import asyncio
|
||
import mimetypes
|
||
from dataclasses import replace
|
||
from datetime import datetime
|
||
from email.utils import format_datetime, parsedate_to_datetime
|
||
from typing import Optional, Tuple
|
||
from urllib.parse import quote
|
||
|
||
import aiohttp
|
||
from fastapi import Request
|
||
from fastapi.responses import Response, StreamingResponse
|
||
|
||
from core.base import FileItem
|
||
from core.driver_service import (
|
||
build_upstream_download_headers,
|
||
get_driver_proxy_part_size,
|
||
resolve_download,
|
||
)
|
||
|
||
|
||
|
||
_proxy_session: Optional[aiohttp.ClientSession] = None
|
||
|
||
|
||
def get_range_proxy_session() -> aiohttp.ClientSession:
|
||
global _proxy_session
|
||
if _proxy_session is None or _proxy_session.closed:
|
||
timeout = aiohttp.ClientTimeout(total=None, connect=30, sock_read=300)
|
||
connector = aiohttp.TCPConnector(limit=1000, limit_per_host=100, keepalive_timeout=60)
|
||
cookie_jar = aiohttp.DummyCookieJar()
|
||
_proxy_session = aiohttp.ClientSession(timeout=timeout, connector=connector, cookie_jar=cookie_jar)
|
||
return _proxy_session
|
||
|
||
|
||
async def close_range_proxy_session() -> None:
|
||
global _proxy_session
|
||
session = _proxy_session
|
||
_proxy_session = None
|
||
if session and not session.closed:
|
||
await session.close()
|
||
|
||
|
||
# ---- MIME / Content-Type ----
|
||
|
||
_FALLBACK_MIME_TYPES = {
|
||
"mp4": "video/mp4",
|
||
"m4v": "video/x-m4v",
|
||
"iso": "application/x-iso9660-image",
|
||
"mkv": "video/x-matroska",
|
||
"ts": "video/mp2t",
|
||
"m2ts": "video/mp2t",
|
||
"mts": "video/mp2t",
|
||
"flv": "video/x-flv",
|
||
"webm": "video/webm",
|
||
"mov": "video/quicktime",
|
||
"avi": "video/x-msvideo",
|
||
"wmv": "video/x-ms-wmv",
|
||
"rmvb": "video/vnd.rn-realmedia-vbr",
|
||
"rm": "application/vnd.rn-realmedia",
|
||
"3gp": "video/3gpp",
|
||
"3g2": "video/3gpp2",
|
||
"asf": "video/x-ms-asf",
|
||
"vob": "video/dvd",
|
||
"f4v": "video/mp4",
|
||
"mpd": "application/dash+xml",
|
||
"m3u8": "application/vnd.apple.mpegurl",
|
||
"mp3": "audio/mpeg",
|
||
"aac": "audio/aac",
|
||
"flac": "audio/flac",
|
||
"wav": "audio/wav",
|
||
"ogg": "audio/ogg",
|
||
"oga": "audio/ogg",
|
||
"opus": "audio/ogg",
|
||
"m4a": "audio/mp4",
|
||
"wma": "audio/x-ms-wma",
|
||
"ape": "audio/x-ape",
|
||
"alac": "audio/alac",
|
||
}
|
||
|
||
|
||
def guess_content_type(file_name: str) -> str:
|
||
name = file_name or ""
|
||
mime_type, _ = mimetypes.guess_type(name)
|
||
if mime_type:
|
||
return mime_type
|
||
if "." in name:
|
||
ext = name.rsplit(".", 1)[-1].strip().lower()
|
||
if ext in _FALLBACK_MIME_TYPES:
|
||
return _FALLBACK_MIME_TYPES[ext]
|
||
return "application/octet-stream"
|
||
|
||
|
||
def is_stream_preview_type(file_name: str) -> bool:
|
||
content_type = guess_content_type(file_name)
|
||
return content_type.startswith("video/") or content_type.startswith("audio/")
|
||
|
||
|
||
|
||
def build_stable_etag(file_info: FileItem) -> str:
|
||
modified_part = ""
|
||
if file_info.modified:
|
||
try:
|
||
modified_part = f"-{int(file_info.modified.timestamp())}"
|
||
except Exception:
|
||
modified_part = ""
|
||
safe_id = quote(str(file_info.id), safe="")
|
||
return f"\"{safe_id}-{file_info.size}{modified_part}\""
|
||
|
||
|
||
def build_proxy_file_info(
|
||
file_id: str,
|
||
*,
|
||
file_name: str,
|
||
file_size: int,
|
||
template: Optional[FileItem] = None,
|
||
) -> FileItem:
|
||
modified = getattr(template, "modified", None) if template else None
|
||
created = getattr(template, "created", None) if template else None
|
||
name = file_name or (getattr(template, "name", None) if template else None) or f"file_{file_id}"
|
||
return FileItem(
|
||
id=file_id,
|
||
name=name,
|
||
path=getattr(template, "path", "") if template else "",
|
||
size=int(file_size or 0),
|
||
is_dir=False,
|
||
modified=modified,
|
||
created=created,
|
||
)
|
||
|
||
|
||
def build_proxy_file_info_from_download(file_id: str, download) -> Tuple[Optional[FileItem], str, int]:
|
||
raw_file_info = getattr(download, "file_info", None)
|
||
file_size = int(getattr(download, "file_size", 0) or 0)
|
||
raw_size = int(getattr(raw_file_info, "size", 0) or 0) if raw_file_info else 0
|
||
|
||
if raw_file_info and not raw_file_info.is_dir and raw_size > 0:
|
||
return raw_file_info, download.download_url, raw_size
|
||
|
||
effective_size = file_size if file_size > 0 else raw_size
|
||
if effective_size <= 0:
|
||
return raw_file_info, download.download_url, 0
|
||
|
||
file_info = build_proxy_file_info(
|
||
file_id,
|
||
file_name=getattr(download, "file_name", "") or f"file_{file_id}",
|
||
file_size=effective_size,
|
||
template=raw_file_info,
|
||
)
|
||
return file_info, download.download_url, effective_size
|
||
|
||
|
||
# ---- Range 解析 / 条件请求 ----
|
||
|
||
def parse_single_range(range_header: str, total_size: int) -> Tuple[int, int]:
|
||
if total_size <= 0:
|
||
raise ValueError("empty")
|
||
header_value = (range_header or "").strip()
|
||
if not header_value.startswith("bytes="):
|
||
raise ValueError("unit")
|
||
ranges_str = header_value[len("bytes="):].strip()
|
||
parts = [part.strip() for part in ranges_str.split(",") if part.strip()]
|
||
if len(parts) != 1:
|
||
raise ValueError("multiple")
|
||
part = parts[0]
|
||
if "-" not in part:
|
||
raise ValueError("format")
|
||
start_str, end_str = part.split("-", 1)
|
||
if start_str == "":
|
||
suffix_length = int(end_str) if end_str else 0
|
||
if suffix_length <= 0:
|
||
raise ValueError("suffix")
|
||
if suffix_length >= total_size:
|
||
return 0, total_size - 1
|
||
return total_size - suffix_length, total_size - 1
|
||
start = int(start_str)
|
||
if start < 0 or start >= total_size:
|
||
raise ValueError("start")
|
||
if end_str == "":
|
||
return start, total_size - 1
|
||
end = int(end_str)
|
||
if end < start:
|
||
raise ValueError("order")
|
||
if end >= total_size:
|
||
end = total_size - 1
|
||
return start, end
|
||
|
||
|
||
def evaluate_if_range(request: Request, etag: str, last_modified: Optional[datetime]) -> bool:
|
||
value = (request.headers.get("If-Range") or "").strip()
|
||
if not value:
|
||
return True
|
||
if value.startswith('"'):
|
||
return value == etag
|
||
try:
|
||
if_range_dt = parsedate_to_datetime(value)
|
||
if if_range_dt.tzinfo:
|
||
if_range_dt = if_range_dt.astimezone(tz=None).replace(tzinfo=None)
|
||
if last_modified and last_modified.replace(microsecond=0) <= if_range_dt.replace(microsecond=0):
|
||
return True
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def match_if_none_match(request: Request, etag: str) -> bool:
|
||
value = (request.headers.get("If-None-Match") or "").strip()
|
||
if not value:
|
||
return False
|
||
if value == "*":
|
||
return True
|
||
candidates = [item.strip() for item in value.split(",") if item.strip()]
|
||
return etag in candidates
|
||
|
||
|
||
def match_if_modified_since(request: Request, last_modified: Optional[datetime]) -> bool:
|
||
value = (request.headers.get("If-Modified-Since") or "").strip()
|
||
if not value or not last_modified:
|
||
return False
|
||
try:
|
||
since = parsedate_to_datetime(value)
|
||
if since.tzinfo:
|
||
since = since.astimezone(tz=None).replace(tzinfo=None)
|
||
return last_modified.replace(microsecond=0) <= since.replace(microsecond=0)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def format_http_datetime(value: Optional[datetime]) -> Optional[str]:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return format_datetime(value, usegmt=True)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
DEFAULT_PROXY_PART_SIZE = 8 * 1024 * 1024
|
||
|
||
|
||
def max_slice_size(file_size: Optional[int] = None) -> int:
|
||
return DEFAULT_PROXY_PART_SIZE
|
||
|
||
|
||
def resolve_chunk_cap(driver) -> int:
|
||
declared = get_driver_proxy_part_size(driver)
|
||
return declared if declared > 0 else DEFAULT_PROXY_PART_SIZE
|
||
|
||
|
||
def stream_chunk_size(file_size: Optional[int]) -> int:
|
||
if not file_size:
|
||
return 262144
|
||
if file_size > 42949672960:
|
||
return 2097152
|
||
if file_size > 10737418240:
|
||
return 1048576
|
||
if file_size > 2147483648:
|
||
return 524288
|
||
return 262144
|
||
|
||
_CLIENT_FORWARD_HEADERS = (
|
||
"origin",
|
||
"accept-language",
|
||
"sec-fetch-site",
|
||
"sec-fetch-mode",
|
||
"sec-fetch-dest",
|
||
"sec-ch-ua",
|
||
"sec-ch-ua-mobile",
|
||
"sec-ch-ua-platform",
|
||
)
|
||
|
||
|
||
async def open_upstream_get(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
user_agent: str,
|
||
initial_url: str,
|
||
file_info: Optional[FileItem],
|
||
range_header: Optional[str],
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> aiohttp.ClientResponse:
|
||
session = get_range_proxy_session()
|
||
current_url = initial_url
|
||
current_headers_override = upstream_headers_override
|
||
resp: Optional[aiohttp.ClientResponse] = None
|
||
for attempt in range(2):
|
||
headers = await build_upstream_download_headers(
|
||
driver,
|
||
file_id,
|
||
user_agent,
|
||
range_header=range_header,
|
||
prefer_identity=True,
|
||
headers_override=current_headers_override,
|
||
)
|
||
if client_headers:
|
||
for key in _CLIENT_FORWARD_HEADERS:
|
||
value = client_headers.get(key, "")
|
||
if value:
|
||
headers.setdefault(key, value)
|
||
headers.pop("Cache-Control", None)
|
||
resp = await session.get(current_url, headers=headers)
|
||
if resp.status in {401, 403} and attempt == 0:
|
||
resp.release()
|
||
refreshed = await resolve_download(driver, file_id, user_agent, file_info=file_info)
|
||
if refreshed.download_url:
|
||
current_url = refreshed.download_url
|
||
current_headers_override = refreshed.headers
|
||
continue
|
||
return resp
|
||
assert resp is not None
|
||
return resp
|
||
|
||
|
||
def _base_headers(
|
||
file_info: FileItem,
|
||
content_type: str,
|
||
etag: str,
|
||
content_disposition: str = "",
|
||
) -> dict:
|
||
headers = {
|
||
"Content-Type": content_type,
|
||
"Accept-Ranges": "bytes",
|
||
"ETag": etag,
|
||
}
|
||
last_modified = format_http_datetime(file_info.modified)
|
||
if last_modified:
|
||
headers["Last-Modified"] = last_modified
|
||
if content_disposition:
|
||
headers["Content-Disposition"] = content_disposition
|
||
return headers
|
||
|
||
|
||
async def _stream_chunked_range(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
user_agent: str,
|
||
initial_url: str,
|
||
file_info: Optional[FileItem],
|
||
range_start: int,
|
||
range_end: int,
|
||
chunk_cap: int,
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
):
|
||
|
||
cur = range_start
|
||
while cur <= range_end:
|
||
slice_end = min(cur + chunk_cap - 1, range_end)
|
||
upstream_range = f"bytes={cur}-{slice_end}"
|
||
resp = await open_upstream_get(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_header=upstream_range,
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
if resp.status >= 400:
|
||
try:
|
||
resp.release()
|
||
except Exception:
|
||
pass
|
||
return
|
||
try:
|
||
async for chunk in resp.content.iter_chunked(1048576):
|
||
yield chunk
|
||
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
|
||
try:
|
||
resp.release()
|
||
except Exception:
|
||
pass
|
||
return
|
||
finally:
|
||
try:
|
||
resp.release()
|
||
except Exception:
|
||
pass
|
||
cur = slice_end + 1
|
||
|
||
|
||
# ---- HEAD ----
|
||
|
||
def _parse_total_from_content_range(value: str) -> int:
|
||
|
||
v = (value or "").strip()
|
||
if "/" not in v:
|
||
return 0
|
||
tail = v.split("/")[-1].strip()
|
||
if tail == "*":
|
||
return 0
|
||
try:
|
||
return int(tail)
|
||
except ValueError:
|
||
return 0
|
||
|
||
|
||
async def _probe_file_size_via_range0(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
user_agent: str,
|
||
initial_url: str,
|
||
file_info: FileItem,
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> int:
|
||
|
||
resp = await open_upstream_get(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_header="bytes=0-0",
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
try:
|
||
if resp.status == 206:
|
||
return _parse_total_from_content_range(resp.headers.get("Content-Range", ""))
|
||
finally:
|
||
try:
|
||
resp.release()
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
async def _serve_forwarded_upstream_range(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
file_info: FileItem,
|
||
etag: str,
|
||
user_agent: str,
|
||
range_header: str,
|
||
initial_url: str,
|
||
content_type: str,
|
||
content_disposition: str,
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> Response:
|
||
"""总大小未知时把客户端 Range 交给 CDN;返回 206 则透传(ISO 等依赖随机 Range)。"""
|
||
rh = (range_header or "").strip()
|
||
if not rh.lower().startswith("bytes="):
|
||
return Response(status_code=400, content="Bad Range unit")
|
||
ranges_blob = rh[6:].strip()
|
||
if "," in ranges_blob:
|
||
return Response(status_code=416, content="Multiple ranges not supported")
|
||
|
||
upstream = await open_upstream_get(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_header=rh,
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
|
||
st = upstream.status
|
||
cr = (upstream.headers.get("Content-Range") or "").strip()
|
||
cl = (upstream.headers.get("Content-Length") or "").strip()
|
||
|
||
chunk_size = stream_chunk_size(0)
|
||
|
||
def base_hdrs() -> dict:
|
||
h = _base_headers(file_info, content_type, etag, content_disposition)
|
||
if cr:
|
||
h["Content-Range"] = cr
|
||
if cl:
|
||
h["Content-Length"] = cl
|
||
return h
|
||
|
||
if st == 206:
|
||
async def gen206():
|
||
try:
|
||
async for chunk in upstream.content.iter_chunked(chunk_size):
|
||
yield chunk
|
||
finally:
|
||
try:
|
||
upstream.release()
|
||
except Exception:
|
||
pass
|
||
|
||
return StreamingResponse(
|
||
gen206(), status_code=206, headers=base_hdrs(), media_type=content_type
|
||
)
|
||
|
||
# 少数 CDN 对 Range 仍回 200,但带 Content-Range(视作分片成功)
|
||
if st == 200 and cr and _parse_total_from_content_range(cr) > 0:
|
||
async def gen200partial():
|
||
try:
|
||
async for chunk in upstream.content.iter_chunked(chunk_size):
|
||
yield chunk
|
||
finally:
|
||
try:
|
||
upstream.release()
|
||
except Exception:
|
||
pass
|
||
|
||
hdrs = base_hdrs()
|
||
hdrs["Content-Range"] = cr
|
||
if cl:
|
||
hdrs["Content-Length"] = cl
|
||
return StreamingResponse(
|
||
gen200partial(), status_code=206, headers=hdrs, media_type=content_type
|
||
)
|
||
|
||
try:
|
||
upstream.release()
|
||
except Exception:
|
||
pass
|
||
return Response(
|
||
status_code=502,
|
||
content="Upstream did not return partial content for range request",
|
||
)
|
||
|
||
|
||
def build_head_response(
|
||
file_info: FileItem,
|
||
*,
|
||
content_type: Optional[str] = None,
|
||
content_disposition: str = "",
|
||
) -> Response:
|
||
"""HEAD 响应:只回元数据 + Accept-Ranges,让 NDM/IDM 探测到「能续传」。"""
|
||
total_size = int(file_info.size or 0)
|
||
ct = content_type or guess_content_type(file_info.name or "")
|
||
etag = build_stable_etag(file_info)
|
||
headers = _base_headers(file_info, ct, etag, content_disposition)
|
||
if total_size > 0:
|
||
headers["Content-Length"] = str(total_size)
|
||
return Response(status_code=200, headers=headers, media_type=ct)
|
||
|
||
|
||
# ---- 主入口:serve_range_proxy ----
|
||
|
||
async def serve_range_proxy(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
file_info: FileItem,
|
||
request: Request,
|
||
initial_url: str,
|
||
content_disposition: str = "",
|
||
user_agent_override: Optional[str] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> Response:
|
||
|
||
if file_info.is_dir:
|
||
return Response(status_code=400, content="Cannot stream a directory")
|
||
|
||
total_size = int(file_info.size or 0)
|
||
etag = build_stable_etag(file_info)
|
||
content_type = guess_content_type(file_info.name or "")
|
||
user_agent = user_agent_override if user_agent_override is not None else request.headers.get("User-Agent", "")
|
||
|
||
client_hdrs = dict(request.headers)
|
||
|
||
|
||
if request.method == "HEAD":
|
||
effective_info = file_info
|
||
if int(file_info.size or 0) <= 0:
|
||
probed = await _probe_file_size_via_range0(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
client_headers=client_hdrs,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
if probed > 0:
|
||
effective_info = replace(file_info, size=probed)
|
||
return build_head_response(
|
||
effective_info, content_type=content_type, content_disposition=content_disposition
|
||
)
|
||
|
||
raw_range_header = request.headers.get("Range")
|
||
|
||
# 304 / If-None-Match / If-Modified-Since
|
||
if not raw_range_header and (
|
||
match_if_none_match(request, etag) or match_if_modified_since(request, file_info.modified)
|
||
):
|
||
headers = {"ETag": etag}
|
||
last_modified = format_http_datetime(file_info.modified)
|
||
if last_modified:
|
||
headers["Last-Modified"] = last_modified
|
||
if content_disposition:
|
||
headers["Content-Disposition"] = content_disposition
|
||
return Response(status_code=304, headers=headers)
|
||
|
||
# If-Range 不匹配 → 当作无 Range 处理
|
||
if raw_range_header and not evaluate_if_range(request, etag, file_info.modified):
|
||
raw_range_header = None
|
||
|
||
if raw_range_header and total_size > 0:
|
||
return await _serve_range(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
file_info=file_info,
|
||
etag=etag,
|
||
user_agent=user_agent,
|
||
range_header=raw_range_header,
|
||
total_size=total_size,
|
||
initial_url=initial_url,
|
||
content_type=content_type,
|
||
content_disposition=content_disposition,
|
||
client_headers=client_hdrs,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
|
||
if raw_range_header and total_size <= 0:
|
||
return await _serve_forwarded_upstream_range(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
file_info=file_info,
|
||
etag=etag,
|
||
user_agent=user_agent,
|
||
range_header=raw_range_header,
|
||
initial_url=initial_url,
|
||
content_type=content_type,
|
||
content_disposition=content_disposition,
|
||
client_headers=client_hdrs,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
|
||
return await _serve_full(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
file_info=file_info,
|
||
etag=etag,
|
||
user_agent=user_agent,
|
||
total_size=total_size,
|
||
initial_url=initial_url,
|
||
content_type=content_type,
|
||
content_disposition=content_disposition,
|
||
client_headers=client_hdrs,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
|
||
|
||
async def _serve_range(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
file_info: FileItem,
|
||
etag: str,
|
||
user_agent: str,
|
||
range_header: str,
|
||
total_size: int,
|
||
initial_url: str,
|
||
content_type: str,
|
||
content_disposition: str,
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> Response:
|
||
try:
|
||
start, end = parse_single_range(range_header, total_size)
|
||
except Exception:
|
||
headers = {"Content-Range": f"bytes */{total_size}"} if total_size > 0 else {}
|
||
return Response(status_code=416, content="Invalid range request", headers=headers)
|
||
|
||
chunk_cap = resolve_chunk_cap(driver)
|
||
|
||
headers = _base_headers(file_info, content_type, etag, content_disposition)
|
||
headers["Content-Range"] = f"bytes {start}-{end}/{total_size}"
|
||
headers["Content-Length"] = str(end - start + 1)
|
||
|
||
return StreamingResponse(
|
||
_stream_chunked_range(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_start=start,
|
||
range_end=end,
|
||
chunk_cap=chunk_cap,
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
),
|
||
status_code=206,
|
||
headers=headers,
|
||
media_type=content_type,
|
||
)
|
||
|
||
|
||
async def _serve_full(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
file_info: FileItem,
|
||
etag: str,
|
||
user_agent: str,
|
||
total_size: int,
|
||
initial_url: str,
|
||
content_type: str,
|
||
content_disposition: str,
|
||
client_headers: Optional[dict] = None,
|
||
upstream_headers_override: Optional[dict] = None,
|
||
) -> Response:
|
||
headers = _base_headers(file_info, content_type, etag, content_disposition)
|
||
|
||
|
||
if total_size <= 0:
|
||
upstream = await open_upstream_get(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_header=None,
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
)
|
||
if upstream.status >= 400:
|
||
status = upstream.status
|
||
upstream.release()
|
||
return Response(status_code=status, content=f"Upstream returned {status}")
|
||
|
||
chunk_size = stream_chunk_size(total_size)
|
||
|
||
async def stream_unknown_size():
|
||
try:
|
||
async for chunk in upstream.content.iter_chunked(chunk_size):
|
||
yield chunk
|
||
except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError):
|
||
return
|
||
finally:
|
||
try:
|
||
upstream.release()
|
||
except Exception:
|
||
pass
|
||
|
||
upstream_length = (upstream.headers.get("Content-Length") or "").strip()
|
||
if upstream_length.isdigit():
|
||
headers["Content-Length"] = upstream_length
|
||
return StreamingResponse(stream_unknown_size(), status_code=200, headers=headers, media_type=content_type)
|
||
|
||
# 已知总大小:分片拉,对外 Content-Length=total
|
||
chunk_cap = resolve_chunk_cap(driver)
|
||
headers["Content-Length"] = str(total_size)
|
||
return StreamingResponse(
|
||
_stream_chunked_range(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=initial_url,
|
||
file_info=file_info,
|
||
range_start=0,
|
||
range_end=total_size - 1,
|
||
chunk_cap=chunk_cap,
|
||
client_headers=client_headers,
|
||
upstream_headers_override=upstream_headers_override,
|
||
),
|
||
status_code=200,
|
||
headers=headers,
|
||
media_type=content_type,
|
||
)
|
||
|
||
|
||
# ---- 兼容老调用点(api/strm.py 旧版本曾 import) ----
|
||
|
||
async def assemble_chunked_proxy(
|
||
*,
|
||
driver,
|
||
file_id: str,
|
||
user_agent: str,
|
||
download_url: str,
|
||
download_size: int,
|
||
client_headers: Optional[dict] = None,
|
||
chunk_cap: Optional[int] = None,
|
||
):
|
||
"""保留旧接口:等价于 _stream_chunked_range(0, download_size-1)。"""
|
||
if download_size <= 0:
|
||
async def _empty():
|
||
if False:
|
||
yield b""
|
||
return _empty()
|
||
cap = chunk_cap or max_slice_size(download_size)
|
||
return _stream_chunked_range(
|
||
driver=driver,
|
||
file_id=file_id,
|
||
user_agent=user_agent,
|
||
initial_url=download_url,
|
||
file_info=None,
|
||
range_start=0,
|
||
range_end=download_size - 1,
|
||
chunk_cap=cap,
|
||
client_headers=client_headers,
|
||
)
|