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,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()
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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}")
|
||||
@@ -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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user