Files
LitePan/core/range_proxy.py
T
2026-05-26 09:10:51 +08:00

802 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""通用 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,
)