"""媒体代理 - /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": "*"}, )