feat(routes): 迁移音频流代理与结果交付端点(流式)

- web/routers/stream.py:/api/v1/audio_stream 音频范围流代理
  · 签名校验(core.security,密钥同源两栈互认);HEAD 直接 200 不请求上游
  · Range 原样透传上游(206 + Content-Range,拖动进度可用)
  · 编码透明:上游带 Content-Encoding 时不沿用压缩前长度(同 Flask F92)
  · StreamingResponse 16KB 分块,finally 关闭上游(下游中断亦释放)
- core/http_client.py 增 open():返回未关闭流式响应,便于先读状态码再转发
- web/routers/delivery.py:/download/result、/download/batch_zip/result
  · 归属 403 / 进行中 202 / 暂停 200 / 取消 200 / 失败 500 / 缺失 404 分支逐一对齐
  · FileResponse 流式交付 + no-store + X-Download-Filename;清理由监控线程统一回收

验证:pytest 51/51(新增 5 项流代理:无效 token/HEAD/全量/Range 透传/上游关闭);
ruff All checks passed;pyright 0 errors;奇偶校验 26/26;实测 5.2MB 流与
Range 206 正确。
This commit is contained in:
snowy-yang committed 2026-09-26 09:49:22 +08:00
1 parent f588d313f0
commit 0104ae55e5
5 files changed
+326

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
"""音频流代理(FastAPI 栈)——与 Flask /audio_stream 行为逐项对齐。
要点(对齐 Flask 实现):
- 签名校验:token 由 core.security 校验(密钥同源,两栈 token 互认);失效 403。
- HEAD 无正文:直接 200 + Accept-Ranges,不请求上游。
- Range 透传:客户端 Range 原样转发上游,206/Content-Range 原样回传(拖动进度可用)。
- 编码透明:上游若带 Content-Encoding(aiohttp 会解压),不得沿用压缩前的
Content-Length/Content-Range(否则头体不一致,同 Flask F92)。
- 流式转发:16KB 分块,不整段入内存;下游中断时 finally 关闭上游响应。
- Cache-Control: no-store。
"""
from fastapi import APIRouter, Request
from fastapi.responses import Response, StreamingResponse
from core import security
from core.http_client import client as http_client
from web.response import error
router = APIRouter()
_UPSTREAM_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://music.163.com/',
}
_CHUNK = 16 * 1024
@router.api_route('/audio_stream', methods=['GET', 'HEAD'])
async def audio_stream(request: Request):
"""签名音频代理流(支持 Range 拖动进度)。"""
if request.method == 'HEAD':
return Response(status_code=200, headers={'Accept-Ranges': 'bytes'})
token = request.query_params.get('token', '')
url = security.verify_audio_token(token)
if not url:
return error('音频链接已过期或无效,请重新解析', 403)
upstream_headers = dict(_UPSTREAM_HEADERS)
range_header = request.headers.get('range')
if range_header:
upstream_headers['Range'] = range_header
try:
upstream = await http_client.open('GET', url, headers=upstream_headers, timeout=20)
except Exception:
return error('音频获取失败', 502)
if upstream.status >= 400:
upstream.close()
return error('音频获取失败', 502)
upstream_encoded = bool(upstream.headers.get('Content-Encoding'))
pass_headers: dict[str, str] = {}
if not upstream_encoded:
for h in ('Content-Length', 'Content-Range', 'Accept-Ranges'):
if h in upstream.headers:
pass_headers[h] = upstream.headers[h]
elif 'Accept-Ranges' in upstream.headers:
pass_headers['Accept-Ranges'] = upstream.headers['Accept-Ranges']
pass_headers['Cache-Control'] = 'no-store'
async def _body():
try:
async for chunk in upstream.content.iter_chunked(_CHUNK):
yield chunk
finally:
# 下游断开/提前关闭时也释放上游连接
upstream.close()
return StreamingResponse(
_body(),
status_code=upstream.status,
media_type=upstream.headers.get('Content-Type', 'audio/mpeg'),
headers=pass_headers,
)