139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""媒体代理
|
||
|
||
- /m3u8/proxy :源站路径转发(playlist 内 TS 链接带时效签名,每次回源拉新鲜版本)
|
||
- /m3u8/aidrama :91 短剧单集播放列表(上游 m3u8 地址 4 小时过期,播放时现取)
|
||
- /img/aidrama :91 封面图解密代理(CDN 返回 AES 加密图片字节,代理解密后输出)
|
||
"""
|
||
import logging
|
||
import re
|
||
|
||
import httpx
|
||
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/._-]*$")
|
||
|
||
|
||
@router.get("/proxy")
|
||
async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
|
||
if ".." in path or not _PATH_RE.match(path):
|
||
raise BizException(PARAM_ERROR, "非法路径")
|
||
|
||
url = f"{UPSTREAM}/api/v1/m3u8/proxy"
|
||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||
resp = await client.get(url, params={"path": path}, headers=HEADERS, timeout=20)
|
||
|
||
text = resp.text
|
||
if resp.status_code != 200 or not text.lstrip().startswith("#EXTM3U"):
|
||
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("/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": "*"},
|
||
)
|