Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
"""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"
|