This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+106 -2
View File
@@ -1,14 +1,25 @@
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
"""媒体代理
- /m3u8/proxy :源站路径转发(playlist 内 TS 链接带时效签名,每次回源拉新鲜版本)
- /m3u8/aidrama :91 短剧单集播放列表(上游 m3u8 地址 4 小时过期,播放时现取)
- /img/aidrama :91 封面图解密代理(CDN 返回 AES 加密图片字节,代理解密后输出)
"""
import logging
import re
import httpx
from fastapi import APIRouter, Query, Response
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import NOT_FOUND, PARAM_ERROR
from app.core.response import BizException
from app.database import get_db
from app.services.collection_service import HEADERS, UPSTREAM
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/m3u8", tags=["媒体"])
img_router = APIRouter(prefix="/img", tags=["媒体"])
# 只允许相对资源路径,防 SSRF
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
@@ -32,3 +43,96 @@ async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@router.get("/aidrama")
async def aidrama_playlist(
d: int = Query(gt=0, description="源站短剧 id"),
e: int = Query(gt=0, description="源站集 id"),
n: int | None = Query(None, ge=1, description="集号(用于定位分页)"),
db: AsyncSession = Depends(get_db),
):
"""91 短剧播放列表:上游每次播放重新签发 TS 地址,避免存下来的签名过期"""
from app.services import aidrama91_service
try:
source = await aidrama91_service.get_collection_source(db)
text = await aidrama91_service.fetch_playlist(source, d, e, n)
except Exception:
logger.exception("91 播放列表获取失败 drama=%s ep=%s", d, e)
raise BizException(NOT_FOUND, "播放列表获取失败")
return Response(
content=text,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@router.get("/vlog")
async def vlog_playlist(
vid: int = Query(gt=0, description="源站短视频 id"),
db: AsyncSession = Depends(get_db),
):
"""91 短视频播放列表:playUrl 带时效签名,播放时现取"""
from app.services import aidrama91_service
try:
source = await aidrama91_service.get_vlog_source(db)
text = await aidrama91_service.fetch_vlog_playlist(source, vid)
except Exception:
logger.exception("91 vlog 播放列表获取失败 vid=%s", vid)
raise BizException(NOT_FOUND, "播放列表获取失败")
return Response(
content=text,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@img_router.get("/aidrama", include_in_schema=False)
async def aidrama_image(u: str = Query(min_length=10, max_length=600)):
"""91 封面解密代理:CDN 图为 AES-128-CBC 加密字节,解密后输出真实图片"""
from urllib.parse import urlparse
from app.services import aidrama91_service
parsed = urlparse(u)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise BizException(PARAM_ERROR, "非法图片地址")
host = parsed.hostname.lower()
if not (host == aidrama91_service.IMG_HOST_SUFFIX.lstrip(".")
or host.endswith(aidrama91_service.IMG_HOST_SUFFIX)):
raise BizException(PARAM_ERROR, "图片域名不在白名单")
async with httpx.AsyncClient(follow_redirects=True) as client:
resp = await client.get(u, headers={
"User-Agent": "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
}, timeout=20)
if resp.status_code != 200 or not resp.content:
raise BizException(NOT_FOUND, "图片获取失败")
try:
data = aidrama91_service.decrypt_image(resp.content)
except Exception:
logger.exception("91 封面解密失败 url=%s", u[:120])
raise BizException(NOT_FOUND, "图片解密失败")
# 识别真实格式(密文无 padding,尾部可能有 1 字节冗余,浏览器可正常渲染)
if data[:3] == b"\xff\xd8\xff":
mime = "image/jpeg"
elif data[:8] == b"\x89PNG\r\n\x1a\n":
mime = "image/png"
elif data[:6] in (b"GIF87a", b"GIF89a"):
mime = "image/gif"
else:
raise BizException(NOT_FOUND, "图片解密异常")
return Response(
content=data,
media_type=mime,
headers={"Cache-Control": "public, max-age=86400",
"Access-Control-Allow-Origin": "*"},
)