"""Proxy vidéo : sert les flux distants (mp4/HLS) via le serveur. Indispensable pour les hébergeurs dont les tokens sont liés à l'IP qui a résolu le lien (le navigateur de l'utilisateur a une IP différente du serveur). Les playlists m3u8 sont réécrites pour que segments et variantes passent aussi par le proxy. Accès réservé aux utilisateurs connectés. """ import logging import re from collections.abc import AsyncIterator from urllib.parse import quote, urljoin import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import Response, StreamingResponse from app.routers.auth import current_user from app.scrapers.http import get_client logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["proxy"], dependencies=[Depends(current_user)]) HLS_CONTENT_TYPES = ("application/vnd.apple.mpegurl", "application/x-mpegurl", "audio/mpegurl") HOP_HEADERS = ( "content-type", "content-length", "content-range", "accept-ranges", "cache-control" ) def _check_url(url: str) -> None: if not url.startswith(("http://", "https://")): raise HTTPException(422, "URL invalide") def proxy_url(remote_url: str, referer: str | None = None) -> str: out = "/api/proxy?url=" + quote(remote_url, safe="") if referer: out += "&ref=" + quote(referer, safe="") return out @router.get("/proxy") async def proxy( request: Request, url: str = Query(), ref: str | None = Query(None), ) -> Response: _check_url(url) headers: dict[str, str] = {} if ref: _check_url(ref) headers["Referer"] = ref if range_header := request.headers.get("range"): headers["Range"] = range_header client = get_client() try: upstream = await client.send( client.build_request("GET", url, headers=headers), stream=True ) except httpx.HTTPError as exc: logger.error("Proxy : échec de connexion à %s : %s", url, exc) raise HTTPException(502, f"Hébergeur injoignable : {exc}") from exc content_type = upstream.headers.get("content-type", "") is_hls = any(t in content_type for t in HLS_CONTENT_TYPES) or url.endswith(".m3u8") if is_hls: # Playlist : lecture intégrale puis réécriture des URIs via le proxy try: body = (await upstream.aread()).decode("utf-8", errors="replace") finally: await upstream.aclose() if upstream.status_code >= 400: logger.error("Proxy HLS : %s → HTTP %s", url, upstream.status_code) raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}") rewritten = _rewrite_playlist(body, url, ref) return Response( rewritten, media_type="application/vnd.apple.mpegurl", headers={"Cache-Control": "no-store"}, ) if upstream.status_code >= 400: await upstream.aclose() logger.error("Proxy : %s → HTTP %s", url, upstream.status_code) raise HTTPException(502, f"Hébergeur : HTTP {upstream.status_code}") async def byte_stream() -> AsyncIterator[bytes]: try: async for chunk in upstream.aiter_bytes(1 << 16): yield chunk finally: await upstream.aclose() passthrough = { h: upstream.headers[h] for h in HOP_HEADERS if h in upstream.headers } passthrough.setdefault("accept-ranges", "bytes") return StreamingResponse( byte_stream(), status_code=upstream.status_code, headers=passthrough, media_type=content_type.split(";")[0] or "application/octet-stream", ) _KEY_URI_RE = re.compile(r'URI="([^"]+)"') def _rewrite_playlist(body: str, base_url: str, referer: str | None) -> str: """Réécrit toutes les URIs d'une playlist m3u8 pour passer par le proxy.""" def absolutize(uri: str) -> str: return proxy_url(urljoin(base_url, uri), referer) lines = [] for line in body.splitlines(): stripped = line.strip() if not stripped: continue if stripped.startswith("#"): lines.append(_KEY_URI_RE.sub( lambda m: f'URI="{absolutize(m.group(1))}"', line )) else: lines.append(absolutize(stripped)) return "\n".join(lines) + "\n"