v0.1.0 — Réécriture complète de OhmStreaming
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).
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user