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:
Roman
2026-09-22 10:05:47 +00:00
commit 41566ab5fb
66 changed files with 8895 additions and 0 deletions
View File
+168
View File
@@ -0,0 +1,168 @@
"""Administration : utilisateurs, activation des sources, santé."""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app import auth
from app.db import db
from app.routers.auth import AdminUser
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers
from app.services.discover import discover
from app.services.settings import (
get_sonarr_config,
get_torznab_apikey,
is_source_enabled,
reset_torznab_apikey,
set_sonarr_config,
set_source_enabled,
)
from app.services.sonarr import sonarr
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
import_all_scrapers()
# ---------------------------------------------------------------- utilisateurs
@router.get("/users")
async def list_users(admin: AdminUser) -> list[dict]:
rows = await db.fetchall(
"SELECT id, username, is_admin, is_active, created_at FROM users ORDER BY id"
)
return [dict(row) for row in rows]
@router.post("/users/{user_id}/toggle-active")
async def toggle_active(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de désactiver son propre compte")
row = await db.fetchone("SELECT is_active FROM users WHERE id = ?", (user_id,))
if row is None:
raise HTTPException(404, "Utilisateur introuvable")
new_state = 0 if row["is_active"] else 1
await db.execute("UPDATE users SET is_active = ? WHERE id = ?", (new_state, user_id))
if not new_state:
await auth.revoke_all_refresh_tokens(user_id)
return {"is_active": bool(new_state)}
@router.post("/users/{user_id}/toggle-admin")
async def toggle_admin(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de modifier ses propres droits")
row = await db.fetchone("SELECT is_admin FROM users WHERE id = ?", (user_id,))
if row is None:
raise HTTPException(404, "Utilisateur introuvable")
new_state = 0 if row["is_admin"] else 1
await db.execute("UPDATE users SET is_admin = ? WHERE id = ?", (new_state, user_id))
return {"is_admin": bool(new_state)}
@router.delete("/users/{user_id}")
async def delete_user(user_id: int, admin: AdminUser) -> dict:
if user_id == admin.id:
raise HTTPException(400, "Impossible de supprimer son propre compte")
cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
if cursor.rowcount == 0:
raise HTTPException(404, "Utilisateur introuvable")
return {"ok": True}
@router.get("/stats")
async def stats(admin: AdminUser) -> dict:
users = await db.fetchone("SELECT COUNT(*) AS n FROM users")
downloads = await db.fetchall("SELECT status, COUNT(*) AS n FROM downloads GROUP BY status")
return {
"users": users["n"],
"downloads": {row["status"]: row["n"] for row in downloads},
}
# ---------------------------------------------------------------- sources
class SourceToggle(BaseModel):
enabled: bool
@router.post("/sources/{name}/toggle")
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
get_source(name) # 404 implicite si inconnue
await set_source_enabled(name, payload.enabled)
return {"name": name, "enabled": payload.enabled}
@router.post("/sources/{name}/health")
async def health_check(name: str, admin: AdminUser) -> dict:
"""Test de santé manuel : la source doit répondre à une recherche simple."""
source = get_source(name)
try:
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
healthy = len(results) > 0
detail = f"{len(results)} résultats"
except (ScrapeError, TimeoutError) as exc:
healthy = False
detail = str(exc)[:200]
logger.error("Health check %s KO : %s", name, exc)
return {"name": name, "healthy": healthy, "detail": detail}
@router.get("/sources")
async def sources_status(admin: AdminUser) -> list[dict]:
return [
{
"name": s.name,
"label": s.label,
"base_url": s.base_url,
"enabled": await is_source_enabled(s.name),
}
for s in all_sources()
]
# ---------------------------------------------------------------- intégrations *arr
class SonarrConfig(BaseModel):
url: str
apikey: str
@router.get("/integrations")
async def integrations(admin: AdminUser, request: Request) -> dict:
"""Configuration Torznab (indexeur) et Sonarr (recommandations)."""
config = await get_sonarr_config()
base = str(request.base_url).rstrip("/")
return {
"torznab": {
"apikey": await get_torznab_apikey(),
"endpoint": f"{base}/torznab/api",
},
"sonarr": config,
}
@router.post("/integrations/torznab/regenerate")
async def regenerate_torznab_key(admin: AdminUser) -> dict:
key = await reset_torznab_apikey()
return {"apikey": key}
@router.put("/integrations/sonarr")
async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
"""Enregistre la connexion Sonarr et recalcule les recommandations."""
await set_sonarr_config(payload.url, payload.apikey)
sonarr.invalidate()
discover.invalidate_for_you()
return {"ok": True}
@router.post("/integrations/sonarr/test")
async def test_sonarr(admin: AdminUser) -> dict:
return await sonarr.test_connection()
+110
View File
@@ -0,0 +1,110 @@
"""Routes d'authentification (cookies httponly, adaptées à l'UI htmx)."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from app import auth
from app.auth import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["auth"])
ACCESS_COOKIE = "ohm_access"
REFRESH_COOKIE = "ohm_refresh"
def set_auth_cookies(response: Response, user: User, refresh_token: str) -> None:
response.set_cookie(
ACCESS_COOKIE, auth.create_access_token(user), httponly=True, samesite="lax"
)
response.set_cookie(REFRESH_COOKIE, refresh_token, httponly=True, samesite="lax", path="/")
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(ACCESS_COOKIE)
response.delete_cookie(REFRESH_COOKIE, path="/")
# ---------------------------------------------------------------- dépendances
async def current_user(request: Request, response: Response) -> User:
"""Utilisateur courant via cookie d'accès ; tente un refresh si expiré."""
token = request.cookies.get(ACCESS_COOKIE)
if token:
payload = auth.decode_access_token(token)
if payload:
user = await auth.get_user(int(payload["sub"]))
if user:
return user
refresh = request.cookies.get(REFRESH_COOKIE)
if refresh:
user = await auth.use_refresh_token(refresh)
if user:
new_refresh = await auth.create_refresh_token(user.id)
set_auth_cookies(response, user, new_refresh)
return user
raise HTTPException(status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
async def require_admin(user: Annotated[User, Depends(current_user)]) -> User:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Droits administrateur requis")
return user
CurrentUser = Annotated[User, Depends(current_user)]
AdminUser = Annotated[User, Depends(require_admin)]
# ---------------------------------------------------------------- routes
@router.post("/register")
async def register(
response: Response,
username: Annotated[str, Form(min_length=3, max_length=32)],
password: Annotated[str, Form(min_length=6)],
) -> RedirectResponse:
row = await auth.db.fetchone("SELECT id FROM users WHERE username = ?", (username.strip(),))
if row is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail="Nom d'utilisateur déjà pris")
user = await auth.create_user(username, password)
refresh = await auth.create_refresh_token(user.id)
redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER)
set_auth_cookies(redirect, user, refresh)
return redirect
@router.post("/login")
async def login(
response: Response,
username: Annotated[str, Form()],
password: Annotated[str, Form()],
) -> RedirectResponse:
user = await auth.authenticate(username, password)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Identifiants invalides")
refresh = await auth.create_refresh_token(user.id)
redirect = RedirectResponse("/", status.HTTP_303_SEE_OTHER)
set_auth_cookies(redirect, user, refresh)
return redirect
@router.post("/logout")
async def logout(user: CurrentUser) -> RedirectResponse:
await auth.revoke_all_refresh_tokens(user.id)
redirect = RedirectResponse("/login", status.HTTP_303_SEE_OTHER)
clear_auth_cookies(redirect)
return redirect
@router.get("/me")
async def me(user: CurrentUser) -> dict:
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
+27
View File
@@ -0,0 +1,27 @@
"""Découverte : nouveautés des sources, incontournables, recommandations."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.routers.auth import CurrentUser, current_user
from app.services.discover import discover
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["discover"], dependencies=[Depends(current_user)])
@router.get("/discover")
async def get_discover(
user: CurrentUser,
latest_limit: Annotated[int, Query(ge=1, le=50)] = 24,
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
) -> dict:
"""Les trois sections de découverte en une requête (sections vides si source KO)."""
must_watch = await discover.must_watch(must_watch_limit)
for_you = await discover.for_you(user.id, for_you_limit)
latest = await discover.latest(latest_limit)
return {"latest": latest, "must_watch": must_watch, "for_you": for_you}
+87
View File
@@ -0,0 +1,87 @@
"""Gestion de la file de téléchargements + progression temps réel (SSE)."""
import json
import logging
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from app.routers.auth import current_user
from app.scrapers.base import decode_internal_url
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/downloads", tags=["downloads"], dependencies=[Depends(current_user)]
)
class EnqueueRequest(BaseModel):
internal_url: str # format `video_url|page_url|titre`
@router.get("")
async def list_downloads() -> list[dict]:
return await download_manager.list_all()
@router.post("", status_code=201)
async def enqueue(payload: EnqueueRequest) -> dict:
try:
video_url, page_url, title = decode_internal_url(payload.internal_url)
except ValueError as exc:
raise HTTPException(422, detail=str(exc)) from exc
return await download_manager.enqueue(video_url, page_url, title)
@router.post("/{download_id}/pause")
async def pause(download_id: int) -> dict:
await download_manager.pause(download_id)
return {"ok": True}
@router.post("/{download_id}/resume")
async def resume(download_id: int) -> dict:
if not await download_manager.resume(download_id):
raise HTTPException(409, detail="Ce téléchargement n'est pas en pause")
return {"ok": True}
@router.post("/{download_id}/retry")
async def retry(download_id: int) -> dict:
if not await download_manager.retry(download_id):
raise HTTPException(
409, detail="Seules les tâches en échec/annulées peuvent être relancées"
)
return {"ok": True}
@router.post("/{download_id}/cancel")
async def cancel(download_id: int) -> dict:
await download_manager.cancel(download_id)
return {"ok": True}
@router.post("/cancel-all")
async def cancel_all() -> dict:
return {"cancelled": await download_manager.cancel_all()}
@router.post("/clear-finished")
async def clear_finished() -> dict:
return {"removed": await download_manager.clear_finished()}
@router.get("/events")
async def events() -> EventSourceResponse:
"""Flux SSE : progression de tous les téléchargements en temps réel."""
async def stream() -> AsyncIterator[dict]:
yield {"data": json.dumps({"type": "snapshot", "items": await download_manager.list_all()})}
async for update in download_manager.subscribe():
yield {"data": json.dumps({"type": "update", "item": update}, default=str)}
return EventSourceResponse(stream())
+201
View File
@@ -0,0 +1,201 @@
"""Bibliothèque locale, streaming de fichiers (range requests) et favoris."""
import logging
import mimetypes
import re
from collections.abc import AsyncIterator
import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.config import get_settings
from app.db import db
from app.routers.auth import CurrentUser, current_user
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["library"], dependencies=[Depends(current_user)])
CHUNK_SIZE = 1 << 20 # 1 Mio
# ---------------------------------------------------------------- bibliothèque
@router.get("/library")
async def library(user: CurrentUser) -> list[dict]:
"""Fichiers téléchargés, streamables, avec progression de visionnage."""
rows = await db.fetchall(
"SELECT d.*, wp.position_seconds FROM downloads d "
"LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? "
"WHERE d.status = 'done' ORDER BY d.updated_at DESC",
(user.id,),
)
return [dict(row) for row in rows]
@router.get("/library/{download_id}/neighbors")
async def neighbors(download_id: int) -> dict:
"""Épisode précédent/suivant : heuristique sur les titres (même série, N±1)."""
rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title")
def parse(title: str) -> tuple[str, float | None]:
match = re.search(r"(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)", title, re.IGNORECASE)
if not match:
return title, None
return title[: match.start()].strip(), float(match.group(1))
current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,))
if current is None:
raise HTTPException(404, "Fichier introuvable")
base, number = parse(current["title"])
prev_ep = next_ep = None
for row in rows:
other_base, other_num = parse(row["title"])
if other_base != base or other_num is None or row["id"] == download_id:
continue
if number is not None and other_num == number - 1:
prev_ep = row["id"]
if number is not None and other_num == number + 1:
next_ep = row["id"]
return {"previous": prev_ep, "next": next_ep}
# ---------------------------------------------------------------- streaming
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")
@router.get("/stream/{download_id}")
async def stream(download_id: int, request: Request) -> StreamingResponse:
"""Streaming d'un fichier local avec support des requêtes Range (206)."""
row = await db.fetchone(
"SELECT file_path FROM downloads WHERE id = ? AND status = 'done'", (download_id,)
)
if row is None:
raise HTTPException(404, "Fichier introuvable ou téléchargement incomplet")
path = get_settings().download_dir / row["file_path"]
if not path.is_file():
logger.error("Fichier manquant sur disque : %s", path)
raise HTTPException(404, "Fichier absent du disque")
size = path.stat().st_size
content_type = mimetypes.guess_type(path.name)[0] or "video/mp4"
start, end = 0, size - 1
status_code = 200
range_header = request.headers.get("range")
if range_header:
match = _RANGE_RE.fullmatch(range_header)
if match:
if match.group(1):
start = int(match.group(1))
if match.group(2):
end = min(int(match.group(2)), size - 1)
if start >= size:
raise HTTPException(416, "Plage invalide")
status_code = 206
length = end - start + 1
async def iter_file() -> AsyncIterator[bytes]:
with path.open("rb") as fh:
fh.seek(start)
remaining = length
while remaining > 0:
chunk = fh.read(min(CHUNK_SIZE, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
headers = {
"Content-Range": f"bytes {start}-{end}/{size}",
"Accept-Ranges": "bytes",
"Content-Length": str(length),
}
return StreamingResponse(
iter_file(), status_code=status_code, headers=headers, media_type=content_type
)
class ProgressRequest(BaseModel):
position_seconds: float = Field(ge=0)
@router.post("/stream/{download_id}/progress")
async def save_progress(download_id: int, payload: ProgressRequest, user: CurrentUser) -> dict:
"""Reprise de lecture : mémorise la position de visionnage."""
await db.execute(
"INSERT INTO watch_progress (user_id, download_id, position_seconds, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(user_id, download_id) DO UPDATE SET "
"position_seconds = excluded.position_seconds, updated_at = excluded.updated_at",
(user.id, download_id, payload.position_seconds),
)
return {"ok": True}
# ---------------------------------------------------------------- favoris
class FavoriteRequest(BaseModel):
source: str
source_id: str
title: str
image_url: str | None = None
payload: dict | None = None
@router.get("/favorites")
async def list_favorites(user: CurrentUser, offset: int = 0, limit: int = 24) -> dict:
total = await db.fetchone("SELECT COUNT(*) AS n FROM favorites WHERE user_id = ?", (user.id,))
rows = await db.fetchall(
"SELECT * FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(user.id, limit, offset),
)
return {"total": total["n"], "items": [dict(row) for row in rows]}
@router.post("/favorites", status_code=201)
async def add_favorite(payload: FavoriteRequest, user: CurrentUser) -> dict:
import json
try:
await db.execute(
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
user.id,
payload.source,
payload.source_id,
payload.title,
payload.image_url,
json.dumps(payload.payload, ensure_ascii=False) if payload.payload else None,
),
)
except aiosqlite.IntegrityError as exc:
raise HTTPException(409, "Déjà dans les favoris") from exc
return {"ok": True}
@router.delete("/favorites/{favorite_id}")
async def remove_favorite(favorite_id: int, user: CurrentUser) -> dict:
cursor = await db.execute(
"DELETE FROM favorites WHERE id = ? AND user_id = ?", (favorite_id, user.id)
)
if cursor.rowcount == 0:
raise HTTPException(404, "Favori introuvable")
return {"ok": True}
# raccourci pratique pour l'UI
@router.get("/downloads/{download_id}")
async def get_download(download_id: int) -> dict:
data = await download_manager.get(download_id)
if data is None:
raise HTTPException(404, "Téléchargement introuvable")
return data
+60
View File
@@ -0,0 +1,60 @@
"""Pages HTML (Jinja2 + htmx). Toutes protégées sauf /login."""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.config import BASE_DIR
from app.routers.auth import current_user
router = APIRouter(tags=["pages"], include_in_schema=False)
protected = APIRouter(tags=["pages"], include_in_schema=False, dependencies=[Depends(current_user)])
templates = Jinja2Templates(directory=BASE_DIR / "app" / "templates")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "login.html")
@protected.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "index.html")
@protected.get("/discover", response_class=HTMLResponse)
async def discover_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "discover.html")
@protected.get("/title/{source}/{source_id:path}", response_class=HTMLResponse)
async def title_page(request: Request, source: str, source_id: str) -> HTMLResponse:
return templates.TemplateResponse(
request, "title.html", {"source": source, "source_id": source_id}
)
@protected.get("/downloads", response_class=HTMLResponse)
async def downloads_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "downloads.html")
@protected.get("/library", response_class=HTMLResponse)
async def library_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "library.html")
@protected.get("/watch/{download_id}", response_class=HTMLResponse)
async def watch_page(request: Request, download_id: int) -> HTMLResponse:
return templates.TemplateResponse(request, "watch.html", {"download_id": download_id})
@protected.get("/favorites", response_class=HTMLResponse)
async def favorites_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "favorites.html")
@protected.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "admin.html")
+129
View File
@@ -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"
+131
View File
@@ -0,0 +1,131 @@
"""Recherche multi-sources, fiches de titres et extraction de liens vidéo."""
import asyncio
import dataclasses
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.routers.auth import current_user
from app.scrapers.base import (
ScrapeError,
SourceScraper,
VideoLink,
all_sources,
get_source,
import_all_scrapers,
resolve_hoster,
)
from app.services.kitsu import KitsuService
from app.services.settings import is_source_enabled
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current_user)])
import_all_scrapers()
kitsu = KitsuService()
async def enabled_sources() -> list[SourceScraper]:
sources = []
for source in all_sources():
if await is_source_enabled(source.name):
sources.append(source)
return sources
@router.get("/sources")
async def list_sources() -> list[dict]:
"""Sources disponibles avec leur état d'activation."""
return [
{
"name": source.name,
"label": source.label,
"base_url": source.base_url,
"media_types": list(source.media_types),
"enabled": await is_source_enabled(source.name),
}
for source in all_sources()
]
@router.get("/search")
async def search(q: Annotated[str, Query(min_length=2)]) -> dict:
"""Recherche unifiée : une requête interroge toutes les sources activées."""
sources = await enabled_sources()
async def safe_search(source: SourceScraper) -> tuple[list, str | None]:
try:
results = [dataclasses.asdict(r) for r in await source.search(q)]
return results, None
except ScrapeError as exc:
logger.error("Recherche échouée sur %s : %s", source.name, exc)
return [], source.name
outcomes = await asyncio.gather(*(safe_search(s) for s in sources))
results = [item for items, _ in outcomes for item in items]
failed = [name for _, name in outcomes if name]
return {"query": q, "count": len(results), "results": results, "failed_sources": failed}
@router.get("/titles/{source}/{source_id:path}")
async def title_details(source: str, source_id: str, enrich: bool = True) -> dict:
"""Fiche détaillée d'un titre (+ enrichissement Kitsu des champs manquants)."""
scraper = get_source(source)
try:
details = await scraper.get_details(source_id)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
if enrich:
details = await kitsu.enrich(details)
return dataclasses.asdict(details)
@router.get("/episodes/{source}/{source_id:path}")
async def list_episodes(source: str, source_id: str) -> dict:
scraper = get_source(source)
try:
episodes = await scraper.list_episodes(source_id)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
return {"episodes": [dataclasses.asdict(e) for e in episodes]}
@router.get("/extract")
async def extract(episode_url: Annotated[str, Query()]) -> dict:
"""Résout la chaîne complète : page d'épisode → embeds → URL vidéo directe."""
try:
source = _find_source_for_url(episode_url)
embeds = await source.extract_embed_links(episode_url)
except ScrapeError as exc:
raise HTTPException(502, detail=str(exc)) from exc
links: list[dict] = []
errors: list[str] = []
for embed in embeds:
extractor = resolve_hoster(embed)
if extractor is None:
errors.append(f"Hébergeur non supporté : {embed}")
logger.warning("Aucun extracteur pour %s", embed)
continue
try:
link: VideoLink = await extractor.extract(embed)
data = dataclasses.asdict(link)
data["embed_url"] = embed
links.append(data)
except ScrapeError as exc:
errors.append(str(exc))
logger.error("Extraction échouée pour %s : %s", embed, exc)
if not links and not embeds:
raise HTTPException(502, detail="Aucun lecteur trouvé sur la page de l'épisode")
return {"episode_url": episode_url, "links": links, "errors": errors}
def _find_source_for_url(url: str) -> SourceScraper:
for source in all_sources():
if source.base_url.split("//")[-1].split("/")[0] in url:
return source
raise ScrapeError(f"Aucune source ne correspond à l'URL : {url}")
+124
View File
@@ -0,0 +1,124 @@
"""API Torznab/Newznab consommée par Sonarr, Prowlarr (et les *arr en général).
Authentification par clé API (`?apikey=…` ou en-tête `X-Api-Key`), indépendante
des sessions utilisateurs — aucun cookie requis.
"""
import logging
import secrets
from fastapi import APIRouter, Request, Response
from app.scrapers.base import ScrapeError
from app.services.settings import get_torznab_apikey
from app.services.torznab import torrent_stub, torznab
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/torznab", tags=["torznab"])
def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
async def _auth_error(request: Request, apikey: str | None) -> Response | None:
"""Réponse d'erreur XML si la clé est invalide (None = authentifié)."""
expected = await get_torznab_apikey()
provided = apikey or request.headers.get("X-Api-Key") or ""
if secrets.compare_digest(provided, expected):
return None
return Response(
torznab.error_xml(100, "Invalid API key"),
media_type="application/xml",
status_code=401,
)
@router.get("/api")
@router.post("/api")
async def torznab_api(
request: Request,
t: str = "caps",
q: str | None = None,
season: int | None = None,
ep: int | None = None,
apikey: str | None = None,
) -> Response:
"""Point d'entrée Torznab : caps / tvsearch / search."""
error = await _auth_error(request, apikey)
if error is not None:
return error
key = await get_torznab_apikey()
base = _base_url(request)
if t == "caps":
return Response(torznab.caps_xml(base), media_type="application/xml")
if t in ("tvsearch", "search"):
if not q or len(q) < 2:
return Response(
torznab.error_xml(200, "Paramètre q requis"),
media_type="application/xml",
status_code=400,
)
try:
releases = await torznab.tvsearch(q, season=season, ep=ep)
except Exception as exc: # noqa: BLE001 — Sonarr attend du XML, pas un traceback
logger.error("Torznab %s %r KO : %s", t, q, exc)
return Response(
torznab.error_xml(300, "Recherche indisponible"),
media_type="application/xml",
status_code=502,
)
return Response(
torznab.results_xml(base, key, releases), media_type="application/rss+xml"
)
return Response(
torznab.error_xml(203, f"Fonction non supportée : {t}"),
media_type="application/xml",
status_code=400,
)
@router.get("/download")
async def torznab_download(
request: Request,
source: str | None = None,
sid: str | None = None,
season: int | None = None,
ep: int | None = None,
series: str | None = None,
apikey: str | None = None,
) -> Response:
"""Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode.
Le flux retourne un .torrent de service (blackhole-friendly) pendant que
l'épisode réel entre dans la file de téléchargements interne.
"""
error = await _auth_error(request, apikey)
if error is not None:
return error
if None in (source, sid, season, ep, series):
return Response(
torznab.error_xml(201, "Paramètres manquants : source, sid, season, ep, series"),
media_type="application/xml",
status_code=400,
)
try:
result = await torznab.grab(source, sid, season, ep, series)
logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
except ScrapeError as exc:
logger.error("Torznab grab KO : %s", exc)
return Response(
torznab.error_xml(300, str(exc)),
media_type="application/xml",
status_code=502,
)
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
return Response(
stub,
media_type="application/x-bittorrent",
headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'},
)