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
+139
View File
@@ -0,0 +1,139 @@
"""Authentification : JWT court + refresh token longue durée, rôles admin/user."""
import hashlib
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import jwt
from pwdlib import PasswordHash
from app.config import get_settings
from app.db import db
logger = logging.getLogger(__name__)
password_hash = PasswordHash.recommended()
ALGORITHM = "HS256"
@dataclass
class User:
id: int
username: str
is_admin: bool
is_active: bool
# ---------------------------------------------------------------- mots de passe
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
# ---------------------------------------------------------------- utilisateurs
async def create_user(username: str, password: str) -> User:
"""Crée un compte ; le tout premier utilisateur devient admin."""
row = await db.fetchone("SELECT COUNT(*) AS n FROM users")
is_first = row["n"] == 0
cursor = await db.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(username.strip(), hash_password(password), int(is_first)),
)
user = User(id=cursor.lastrowid, username=username.strip(), is_admin=is_first, is_active=True)
logger.info("Utilisateur créé : %s (admin=%s)", user.username, user.is_admin)
return user
async def authenticate(username: str, password: str) -> User | None:
row = await db.fetchone(
"SELECT id, username, password_hash, is_admin, is_active FROM users WHERE username = ?",
(username.strip(),),
)
if row is None or not verify_password(password, row["password_hash"]):
logger.warning("Échec d'authentification pour %r", username)
return None
if not row["is_active"]:
logger.warning("Compte désactivé : %r", username)
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
)
async def get_user(user_id: int) -> User | None:
row = await db.fetchone(
"SELECT id, username, is_admin, is_active FROM users WHERE id = ?", (user_id,)
)
if row is None or not row["is_active"]:
return None
return User(
id=row["id"], username=row["username"], is_admin=bool(row["is_admin"]), is_active=True
)
# ---------------------------------------------------------------- tokens
def create_access_token(user: User) -> str:
settings = get_settings()
payload = {
"sub": str(user.id),
"username": user.username,
"admin": user.is_admin,
"exp": datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
}
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, get_settings().secret_key, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
logger.debug("Token d'accès invalide : %s", exc)
return None
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def create_refresh_token(user_id: int) -> str:
"""Refresh token opaque ; seul son hash est stocké en DB (révocable)."""
token = secrets.token_urlsafe(48)
expires = datetime.now(UTC) + timedelta(days=get_settings().refresh_token_ttl_days)
await db.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, _hash_token(token), expires.strftime("%Y-%m-%d %H:%M:%S")),
)
return token
async def use_refresh_token(token: str) -> User | None:
"""Valide un refresh token, le révoque (rotation) et retourne l'utilisateur."""
row = await db.fetchone(
"SELECT id, user_id FROM refresh_tokens "
"WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')",
(_hash_token(token),),
)
if row is None:
logger.warning("Refresh token invalide ou expiré")
return None
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE id = ?", (row["id"],))
user = await get_user(row["user_id"])
if user is None:
logger.warning("Refresh token d'un compte supprimé/désactivé (user_id=%s)", row["user_id"])
return user
async def revoke_all_refresh_tokens(user_id: int) -> None:
await db.execute("UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", (user_id,))
+46
View File
@@ -0,0 +1,46 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parent.parent
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OHM_", env_file=".env", extra="ignore")
app_name: str = "Ohm Stream Downloader"
debug: bool = False
# Données
data_dir: Path = BASE_DIR / "data"
download_dir: Path = BASE_DIR / "downloads"
database_path: Path = BASE_DIR / "data" / "ohm.db"
# Sécurité — à surcharger via OHM_SECRET_KEY en production
secret_key: str = "change-me-in-production"
access_token_ttl_minutes: int = 15
refresh_token_ttl_days: int = 30
# Scraping
http_timeout: float = 20.0
user_agent: str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
scrapers_config_dir: Path = BASE_DIR / "app" / "scrapers" / "configs"
# Téléchargements
max_parallel_downloads: int = 3
# Kitsu
kitsu_base_url: str = "https://kitsu.io/api/edge"
metadata_cache_ttl_hours: int = 72
def ensure_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
self.download_dir.mkdir(parents=True, exist_ok=True)
@lru_cache
def get_settings() -> Settings:
settings = Settings()
settings.ensure_dirs()
return settings
+123
View File
@@ -0,0 +1,123 @@
"""Couche d'accès SQLite (aiosqlite) — unique source de vérité de l'application."""
import aiosqlite
from app.config import get_settings
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_key TEXT NOT NULL, -- clé de déduplication (video_url normalisée)
video_url TEXT NOT NULL,
page_url TEXT,
title TEXT NOT NULL,
file_path TEXT, -- relatif au dossier de téléchargement
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','downloading','paused','done','failed','cancelled')),
total_bytes INTEGER,
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_downloads_active_source
ON downloads(source_key) WHERE status IN ('pending','downloading','paused');
CREATE TABLE IF NOT EXISTS metadata_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL, -- JSON
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL -- JSON
);
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
title TEXT NOT NULL,
image_url TEXT,
payload TEXT, -- JSON métadonnées
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, source, source_id)
);
CREATE TABLE IF NOT EXISTS watch_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
position_seconds REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, download_id)
);
"""
class Database:
"""Connexion SQLite partagée, initialisée au démarrage de l'app."""
def __init__(self) -> None:
self._conn: aiosqlite.Connection | None = None
async def connect(self) -> None:
path = get_settings().database_path
self._conn = await aiosqlite.connect(path)
self._conn.row_factory = aiosqlite.Row
await self._conn.execute("PRAGMA journal_mode=WAL")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.executescript(SCHEMA)
await self._conn.commit()
async def close(self) -> None:
if self._conn is not None:
await self._conn.close()
self._conn = None
@property
def conn(self) -> aiosqlite.Connection:
if self._conn is None:
raise RuntimeError("Database.connect() n'a pas été appelé")
return self._conn
async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor:
cursor = await self.conn.execute(sql, params)
await self.conn.commit()
return cursor
async def fetchone(self, sql: str, params: tuple = ()) -> aiosqlite.Row | None:
cursor = await self.conn.execute(sql, params)
row = await cursor.fetchone()
await cursor.close()
return row
async def fetchall(self, sql: str, params: tuple = ()) -> list[aiosqlite.Row]:
cursor = await self.conn.execute(sql, params)
rows = await cursor.fetchall()
await cursor.close()
return rows
db = Database()
+15
View File
@@ -0,0 +1,15 @@
import logging
import sys
def setup_logging(debug: bool = False) -> None:
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
stream=sys.stdout,
force=True,
)
# Réduit le bruit des libs tierces
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
+69
View File
@@ -0,0 +1,69 @@
import asyncio
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import BASE_DIR, get_settings
from app.db import db
from app.logging_config import setup_logging
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab
from app.scrapers.http import close_client
from app.services.discover import discover as discover_service
from app.services.downloads import download_manager
logger = logging.getLogger(__name__)
def _log_task_error(task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.warning("Réchauffe découverte échouée : %s", exc)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
setup_logging(settings.debug)
await db.connect()
await download_manager.start()
warmup = asyncio.create_task(discover_service.latest())
warmup.add_done_callback(_log_task_error)
logger.info("%s prêt", settings.app_name)
yield
warmup.cancel()
await download_manager.stop()
await close_client()
await db.close()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router)
app.include_router(pages.router)
app.include_router(pages.protected)
app.include_router(auth.router)
app.include_router(search.router)
app.include_router(discover.router)
app.include_router(downloads.router)
app.include_router(library.router)
app.include_router(admin.router)
app.include_router(proxy.router)
app.mount("/static", StaticFiles(directory=BASE_DIR / "app" / "static"), name="static")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()
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"'},
)
View File
+192
View File
@@ -0,0 +1,192 @@
"""Contrats et registres des scrapers.
Architecture à 2 registres :
- sources (sites de catalogues animes/séries) : recherche, détails, épisodes, liens embed
- hébergeurs vidéo : résolution d'une URL embed → URL directe du fichier
Ajouter une source = écrire une classe qui implémente le contrat et la décorer
@register_source / @register_hoster.
"""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
class ScrapeError(Exception):
"""Échec de scraping/extraction — jamais silencieux, toujours journalisé et remonté."""
# ---------------------------------------------------------------- modèles
@dataclass
class SearchResult:
source: str
source_id: str # identifiant stable chez la source (slug, id…)
title: str
url: str
image_url: str | None = None
media_type: str = "anime" # anime | serie | film
@dataclass
class Episode:
number: float # 1, 2, 2.5 pour les OAV intermédiaires
title: str | None
url: str # page de l'épisode chez la source
season: int = 1
@dataclass
class TitleDetails:
source: str
source_id: str
title: str
url: str
synopsis: str | None = None
image_url: str | None = None
banner_url: str | None = None
genres: list[str] = field(default_factory=list)
rating: float | None = None
year: int | None = None
episode_count: int | None = None
episodes: list[Episode] = field(default_factory=list)
media_type: str = "anime"
@dataclass
class VideoLink:
"""Lien vidéo résolu (URL directe du fichier, lisible par un lecteur)."""
url: str
hoster: str
quality: str | None = None
headers: dict[str, str] = field(default_factory=dict) # ex. Referer obligatoire
is_hls: bool = False
# ---------------------------------------------------------------- contrats
class SourceScraper(ABC):
"""Contrat d'un site catalogue (animes, séries…)."""
name: str # identifiant unique, ex. "vostfree"
label: str # nom affiché
base_url: str
media_types: tuple[str, ...] = ("anime",)
@abstractmethod
async def search(self, query: str) -> list[SearchResult]: ...
@abstractmethod
async def get_details(self, source_id: str) -> TitleDetails: ...
@abstractmethod
async def list_episodes(self, source_id: str) -> list[Episode]: ...
@abstractmethod
async def extract_embed_links(self, episode_url: str) -> list[str]:
"""URLs des lecteurs embarqués trouvés sur la page d'un épisode."""
...
async def latest(self) -> list[SearchResult]:
"""Ajouts récents du catalogue (découverte). Vide si la source ne le supporte pas."""
return []
class HosterExtractor(ABC):
"""Contrat d'un hébergeur vidéo : embed URL → URL directe."""
name: str
domains: tuple[str, ...] = ()
def can_handle(self, url: str) -> bool:
return any(d in url for d in self.domains)
@abstractmethod
async def extract(self, embed_url: str) -> VideoLink: ...
# ---------------------------------------------------------------- registres
_source_registry: dict[str, type[SourceScraper]] = {}
_hoster_registry: list[type[HosterExtractor]] = []
def register_source(cls: type[SourceScraper]) -> type[SourceScraper]:
if cls.name in _source_registry:
raise ValueError(f"Source déjà enregistrée : {cls.name}")
_source_registry[cls.name] = cls
logger.debug("Source enregistrée : %s", cls.name)
return cls
def register_hoster(cls: type[HosterExtractor]) -> type[HosterExtractor]:
_hoster_registry.append(cls)
logger.debug("Hébergeur enregistré : %s", cls.name)
return cls
_source_instances: dict[str, SourceScraper] = {}
_hoster_instances: list[HosterExtractor] | None = None
def get_source(name: str) -> SourceScraper:
if name not in _source_instances:
cls = _source_registry.get(name)
if cls is None:
raise ScrapeError(f"Source inconnue : {name}")
_source_instances[name] = cls()
return _source_instances[name]
def all_sources() -> list[SourceScraper]:
return [get_source(name) for name in _source_registry]
def resolve_hoster(url: str) -> HosterExtractor | None:
"""Trouve l'extracteur capable de traiter une URL embed (None → générique)."""
global _hoster_instances
if _hoster_instances is None:
_hoster_instances = [cls() for cls in _hoster_registry]
for extractor in _hoster_instances:
if extractor.can_handle(url):
return extractor
return None
def import_all_scrapers() -> None:
"""Importe tous les modules pour déclencher les décorateurs d'enregistrement."""
import importlib
import pkgutil
import app.scrapers.hosters as hosters_pkg
import app.scrapers.sources as sources_pkg
for pkg in (sources_pkg, hosters_pkg):
for mod in pkgutil.iter_modules(pkg.__path__):
importlib.import_module(f"{pkg.__name__}.{mod.name}")
# ---------------------------------------------------------------- format interne
INTERNAL_SEP = "|"
def encode_internal_url(video_url: str, page_url: str, title: str) -> str:
"""Format interne `video_url|page_url|titre` pour transporter le contexte."""
for part in (video_url, page_url, title):
if INTERNAL_SEP in part:
raise ValueError(f"Caractère interdit '{INTERNAL_SEP}' dans : {part!r}")
return INTERNAL_SEP.join([video_url, page_url, title])
def decode_internal_url(value: str) -> tuple[str, str, str]:
parts = value.split(INTERNAL_SEP)
if len(parts) != 3 or not parts[0]:
raise ValueError(f"URL interne invalide : {value!r}")
return parts[0], parts[1], parts[2]
+31
View File
@@ -0,0 +1,31 @@
"""Chargement des configurations de scraping externalisées (sélecteurs YAML).
Permet de réparer un site cassé en modifiant un fichier de config sans toucher
au code. Chaque source peut avoir un fichier `configs/<nom_source>.yaml`.
"""
import logging
from functools import lru_cache
from typing import Any
import yaml
from app.config import get_settings
logger = logging.getLogger(__name__)
@lru_cache
def load_scraper_config(source_name: str) -> dict[str, Any]:
path = get_settings().scrapers_config_dir / f"{source_name}.yaml"
if not path.exists():
logger.debug("Pas de config externe pour %s (%s)", source_name, path)
return {}
try:
with path.open(encoding="utf-8") as fh:
config = yaml.safe_load(fh) or {}
logger.info("Config de scraping chargée pour %s", source_name)
return config
except yaml.YAMLError as exc:
logger.error("Config %s invalide : %s", path, exc)
return {}
+29
View File
@@ -0,0 +1,29 @@
# Sélecteurs de scraping French-Manga (DataLife Engine + API AJAX maison)
# Structure vérifiée en live sur https://w16.french-manga.net
search:
endpoint: "/engine/ajax/search.php" # POST query=<q>&page=1 (la recherche DLE native est cassée)
result: ".search-item" # lien dans l'attribut onclick
title: ".search-title"
image: ".search-poster img"
details:
config_el: "#serie-config" # data-title / data-news-id
poster: ".fposter img"
synopsis: ".flist .fdesc p"
release: ".facts .release" # année
genres: ".facts .genres a"
episode_badge: ".short-meta.short-label" # "Ep 32 sur 32"
episodes:
api: "/engine/ajax/manga_episodes_api.php" # ?id=<newsid> → JSON {vf, vostfr, info}
versions: ["vostfr", "vf"] # priorité des versions
fragment_prefix: "ep" # URL épisode : <fiche>#ep=<version>-<numéro>
# Nouveautés — section « Récemment mises à jour » de la page d'accueil épinglée
latest:
path: "/manga-streaming-1/"
item: ".short"
link: "a.short-poster" # href = index.php?newsid=<id>, alt = titre
title: ".short-title"
image: "a.short-poster img"
+47
View File
@@ -0,0 +1,47 @@
# Sélecteurs de scraping Vostfree (DataLife Engine) — surchargeables sans toucher au code.
# Structure vérifiée en live sur https://ipv4.vostfree.ws
search:
result: "div.search-result" # bloc d'un résultat de recherche
link: "div.title a" # lien + titre
image: "span.image img" # poster
genres: "ul.additional li" # "Genre:...", "Anneé:..." (détection film)
details:
title: "h1"
poster: ".slide-poster img"
synopsis: ".slide-desc" # les .cast internes sont retirés
genres: '.slide-top li.right a[href*="/genre/"]'
episode_badge: ".slide-poster .year" # ex. "Episode 293"
season_li: "ul.slide-top li" # ex. "Saison: 01"
episodes:
option: "select.new_player_selector option" # value="buttons_N" → Episode NN
button: "div.button_box" # repli si pas de sélecteur
content_prefix: "content_" # #player_M → #content_player_M
# Nouveautés — page « Animes VOSTFR récemment ajoutés »
latest:
path: "/animes-vostfr-recement-ajoutees.html"
item: "div.movie-poster"
link: ".play a" # href = fiche, alt = titre
image: "span.image img"
# class CSS du lecteur → template d'URL ({} = valeur de #content_player_M)
# chaîne vide = la valeur est déjà une URL complète
players:
new_player_vip: ""
new_player_moevideo: ""
new_player_sibnet: "https://video.sibnet.ru/shell.php?videoid={}"
new_player_netu: "https://video.sibnet.ru/shell.php?videoid={}"
new_player_uqload: "https://uqload.com/embed-{}.html"
new_player_mp4: "https://www.mp4upload.com/embed-{}.html"
new_player_fembed: "https://www.fembed.com/v/{}"
new_player_mytv: "https://www.myvi.top/embed/{}"
new_player_myvi: "https://myvi.ru/player/embed/html/{}"
new_player_rutube: "https://rutube.ru/play/embed/{}"
new_player_ok: "https://ok.ru/video/{}"
new_player_mail2: "https://my.mail.ru/video/embed/{}"
new_player_rapids: "https://rapidstream.co/embed-{}.html"
new_player_gtv: "https://iframedream.com/embed/{}.html"
new_player_cloudvideo: "https://cloudvideo.tv/embed-{}.html"
new_player_uptostream: "https://uptostream.com/iframe/{}"
View File
+40
View File
@@ -0,0 +1,40 @@
"""Décompresseur pour le JavaScript packé « p,a,c,k,e,d » (Dean Edwards).
Utilisé par plusieurs hébergeurs vidéo (Uqload, VidMoly…) pour masquer
l'URL du lecteur : le HTML contient `eval(function(p,a,c,k,e,d){...}(...))`.
"""
import re
_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_PACKED_RE = re.compile(
r"\}\('(?P<payload>.*?)',(?P<a>\d+),(?P<c>\d+),'(?P<keys>.*?)'\.split\('\|'\)",
re.DOTALL,
)
def _base_n(number: int, base: int) -> str:
if number == 0:
return "0"
chars = []
while number:
number, rest = divmod(number, base)
chars.append(_DIGITS[rest])
return "".join(reversed(chars))
def unpack_packed_js(text: str) -> str | None:
"""Retourne le JS dépacké si `text` contient un bloc packé, sinon None."""
match = _PACKED_RE.search(text)
if not match:
return None
payload = match.group("payload").replace("\\\\", "\\").replace("\\'", "'")
base, count = int(match.group("a")), int(match.group("c"))
keys = match.group("keys").split("|")
for index in range(count - 1, -1, -1):
token = _base_n(index, base)
if keys[index]:
replacement = keys[index].replace("\\", "\\\\")
payload = re.sub(rf"\b{re.escape(token)}\b", replacement, payload)
return payload
+50
View File
@@ -0,0 +1,50 @@
"""Extracteur Luluvid/Luluvdo (luluvdo.com, luluvid.com, lulustream.com) — embed → m3u8.
Famille StreamSB : la page embed configure jwplayer dans du JS packé
(p,a,c,k,e,d) avec `sources:[{file:"https://.../master.m3u8?t=<token>"}]`.
Le token CDN (tnmr.org…) est lié à l'IP **et à l'User-Agent** de la requête
d'embed : la VideoLink doit donc renvoyer le User-Agent du socle pour que le
téléchargement passe — sans lui, le CDN répond 403.
"""
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+\.m3u8[^"\']*)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.m3u8(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class LuluvdoExtractor(HosterExtractor):
name = "luluvdo"
domains = ("luluvdo.com", "luluvid.com", "lulustream.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
url = match.group("url")
if url.startswith("http"):
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
is_hls=".m3u8" in url,
)
raise ScrapeError(f"luluvdo : URL vidéo introuvable dans {embed_url}")
+51
View File
@@ -0,0 +1,51 @@
"""Extracteur SendVid (sendvid.com) — page embed → mp4 direct.
La page embed expose soit une balise `<source src="...">` ( lecteur HTML5),
soit une variable JS `video_source`. Extraction par regex, sans dépendance JS.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'<source[^>]+src=["\'](?P<url>[^"\']+)["\']'),
re.compile(r'video_source\s*[:=]\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:mp4|m3u8)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class SendvidExtractor(HosterExtractor):
name = "sendvid"
domains = ("sendvid.com",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
url = self._pick(candidates)
if not url:
raise ScrapeError(f"sendvid : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
@staticmethod
def _pick(candidates: list[str]) -> str | None:
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
for url in video:
if ".mp4" in url:
return url
return video[0] if video else None
+69
View File
@@ -0,0 +1,69 @@
"""Extracteur Sibnet (video.sibnet.ru) — page shell/watch → mp4 direct.
La page embed `shell.php?videoid=N` (ou la page publique `/videoN`) contient
une config jwplayer du type `player.src([{src: "/v/<hash>/<id>.mp4"}])`.
`shell.php` répond 403 depuis certains réseaux : on retombe alors sur la page
publique de la vidéo, qui expose la même config.
"""
import logging
import re
from urllib.parse import urljoin
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_BASE = "https://video.sibnet.ru"
_VIDEO_ID_RE = re.compile(r"(?:videoid=|/video)(\d+)")
_MP4_PATTERNS = (
re.compile(r'player\.src\(\[\{src:\s*"(?P<url>/v/[^"]+?\.mp4)"'),
re.compile(r'["\'](?P<url>/v/[0-9a-f]{32}/\d+\.mp4)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?/v/[^"\']+?\.mp4)["\']'),
)
@register_hoster
class SibnetExtractor(HosterExtractor):
name = "sibnet"
domains = ("video.sibnet.ru",)
async def extract(self, embed_url: str) -> VideoLink:
match = _VIDEO_ID_RE.search(embed_url)
if not match:
raise ScrapeError(f"sibnet : identifiant vidéo introuvable dans {embed_url}")
video_id = match.group(1)
watch_url = f"{_BASE}/video{video_id}"
pages: list[tuple[str, str]] = []
try:
pages.append(("embed", await fetch(embed_url, retries=0)))
except ScrapeError as exc:
logger.info(
"sibnet : page embed %s inaccessible (%s), essai page publique", embed_url, exc
)
try:
pages.append(("watch", await fetch(watch_url, retries=1)))
except ScrapeError as exc:
logger.error("sibnet : page publique %s inaccessible : %s", watch_url, exc)
for source, html in pages:
url = self._find_mp4(html, source)
if url:
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": watch_url},
is_hls=False,
)
raise ScrapeError(f"sibnet : URL mp4 introuvable pour la vidéo {video_id}")
def _find_mp4(self, html: str, source: str) -> str | None:
for pattern in _MP4_PATTERNS:
match = pattern.search(html)
if match:
url = urljoin(_BASE + "/", match.group("url"))
logger.debug("sibnet : mp4 trouvé via %s → %s", source, url)
return url
return None
+45
View File
@@ -0,0 +1,45 @@
"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8.
La page embed contient un jwplayer configuré dans du JS packé
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
On dépacke puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?(?:file|src)\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'(?:file|src)\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class UqloadExtractor(HosterExtractor):
name = "uqload"
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"uqload : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
+44
View File
@@ -0,0 +1,44 @@
"""Extracteur VidMoly (vidmoly.to / vidmoly.me) — page embed → mp4/m3u8.
Le lecteur jwplayer est configuré dans du JS parfois packé (p,a,c,k,e,d) :
`sources: [{file: "...m3u8"}]`. On dépacke si besoin puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class VidmolyExtractor(HosterExtractor):
name = "vidmoly"
domains = ("vidmoly.to", "vidmoly.me", "vidmoly.biz", "vidmoly.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"vidmoly : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
+78
View File
@@ -0,0 +1,78 @@
"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8.
La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
dérivée du hostname (`somme des codes des caractères & 0xFF`). Si le décodage
échoue, la page sert un leurre `https://s1.fsvid.lol/troll/master.m3u8`
(même valeur pour tous les épisodes) — on le rejette explicitement.
Algorithme (reproduit fidèlement depuis le JS de la page) :
b = base64decode(s) ; a = b[::-1]
r[i] = chr(a[i] ^ ((0x3D + i*89 + H) & 0xFF)) avec H = sum(ord(hostname)) & 0xFF
"""
import base64
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_B64_RE = re.compile(
r"atob\(s\)(?s:.){0,800}?\}\)\(\"(?P<b64>[A-Za-z0-9+/=]{50,})\"",
)
_CLEAR_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{\s*src\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'_fsvHls\s*=\s*"(?P<url>https?://[^"]+?\.m3u8[^"]*)"'),
)
_TROLL = "/troll/"
def _decode(b64: str, hostname: str) -> str | None:
try:
data = base64.b64decode(b64)
except (ValueError, TypeError):
return None
seed = sum(ord(c) for c in hostname) & 0xFF
decoded = "".join(chr(b ^ ((0x3D + i * 89 + seed) & 0xFF)) for i, b in enumerate(data[::-1]))
return decoded if decoded.startswith(("http://", "https://")) else None
@register_hoster
class VidzyExtractor(HosterExtractor):
name = "vidzy"
domains = ("vidzy.org",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
hostname = urlparse(embed_url).hostname or ""
for match in _B64_RE.finditer(html):
url = _decode(match.group("b64"), hostname)
if url and _TROLL not in url:
return self._video_link(url, embed_url)
logger.warning("vidzy : décodage atob+XOR sans résultat pour %s", embed_url)
for pattern in _CLEAR_PATTERNS:
for match in pattern.finditer(html):
url = match.group("url")
if _TROLL not in url:
return self._video_link(url, embed_url)
raise ScrapeError(
f"vidzy : URL vidéo introuvable dans {embed_url} (leurre anti-bot ou page modifiée)"
)
def _video_link(self, url: str, embed_url: str) -> VideoLink:
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
},
is_hls=".m3u8" in url,
)
+63
View File
@@ -0,0 +1,63 @@
"""Client HTTP partagé pour le scraping (httpx async, headers navigateur, retries)."""
import asyncio
import logging
import httpx
from bs4 import BeautifulSoup
from app.config import get_settings
from app.scrapers.base import ScrapeError
logger = logging.getLogger(__name__)
_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
settings = get_settings()
_client = httpx.AsyncClient(
timeout=settings.http_timeout,
follow_redirects=True,
headers={
"User-Agent": settings.user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
)
return _client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def fetch(
url: str,
*,
referer: str | None = None,
retries: int = 2,
) -> str:
"""GET d'une page avec retries ; lève ScrapeError en cas d'échec définitif."""
headers = {"Referer": referer} if referer else {}
last_error: Exception | None = None
for attempt in range(retries + 1):
try:
response = await get_client().get(url, headers=headers)
response.raise_for_status()
return response.text
except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc
logger.warning("fetch %s — tentative %d/%d : %s", url, attempt + 1, retries + 1, exc)
if attempt < retries:
await asyncio.sleep(1.0 * (attempt + 1))
raise ScrapeError(f"Échec de récupération de {url} : {last_error}")
async def fetch_soup(url: str, *, referer: str | None = None) -> BeautifulSoup:
html = await fetch(url, referer=referer)
return BeautifulSoup(html, "lxml")
View File
+313
View File
@@ -0,0 +1,313 @@
"""Source French-Manga (w16.french-manga.net) — animes VF/VOSTFR, moteur DataLife Engine.
Faits structurels (vérifiés en live) :
- La recherche native DLE est cassée (erreur MySQL en GET, « moins de 4 caractères » en POST) ;
le site utilise un endpoint AJAX : POST /engine/ajax/search.php avec `query=<q>&page=1`
→ blocs `.search-item` (lien dans l'attribut onclick).
- Nouveautés : /manga-streaming-1/ (« Récemment mises à jour ») → blocs `.short`
(lien `a.short-poster` href = `index.php?newsid=<id>`, titre `.short-title`, poster img).
- Fiche : `/<id>-<slug>.html` (alias canonique `index.php?newsid=<id>`), métadonnées dans
`.facts`, `.fdesc`, `.fposter` et `#serie-config` (data-title, data-news-id).
- Épisodes et lecteurs : GET /engine/ajax/manga_episodes_api.php?id=<newsid> → JSON
{"vf": {ep: {hoster: url}}, "vostfr": {...}, "info": {ep: {title, poster}}}.
- Pas de page par épisode : l'URL d'épisode est `<fiche>#ep=<version>-<numéro>`.
"""
import asyncio
import json
import logging
import re
from copy import deepcopy
from urllib.parse import urljoin
import httpx
from bs4 import BeautifulSoup, Tag
from app.scrapers.base import (
Episode,
ScrapeError,
SearchResult,
SourceScraper,
TitleDetails,
register_source,
)
from app.scrapers.config_loader import load_scraper_config
from app.scrapers.http import fetch, fetch_soup, get_client
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
"search": {
"endpoint": "/engine/ajax/search.php",
"result": ".search-item",
"title": ".search-title",
"image": ".search-poster img",
},
"details": {
"config_el": "#serie-config",
"poster": ".fposter img",
"synopsis": ".flist .fdesc p",
"release": ".facts .release",
"genres": ".facts .genres a",
"episode_badge": ".short-meta.short-label",
"episodes": {
"api": "/engine/ajax/manga_episodes_api.php",
"versions": ["vostfr", "vf"],
"fragment_prefix": "ep",
},
"latest": {
"path": "/manga-streaming-1/", # « Récemment mises à jour »
"item": ".short",
"link": "a.short-poster", # href = index.php?newsid=<id>, alt = titre
"title": ".short-title",
"image": "a.short-poster img",
},
},
}
_ID_RE = re.compile(r"newsid=(\d+)|/(\d+)-[^/]+\.html?")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s+(\d+)", re.IGNORECASE)
_YEAR_RE = re.compile(r"\((\d{4})\)\s*$")
def _merged_config() -> dict:
merged = deepcopy(DEFAULT_CONFIG)
for key, values in load_scraper_config("french_manga").items():
if isinstance(values, dict) and isinstance(merged.get(key), dict):
merged[key].update(values)
else:
merged[key] = values
return merged
@register_source
class FrenchMangaScraper(SourceScraper):
name = "french_manga"
label = "French-Manga"
base_url = "https://w16.french-manga.net"
media_types = ("anime",)
# ------------------------------------------------------------- helpers
@staticmethod
def _id_from_url(url: str) -> str | None:
match = _ID_RE.search(url)
if not match:
return None
return match.group(1) or match.group(2)
def _title_url(self, source_id: str) -> str:
return f"{self.base_url}/index.php?newsid={source_id}"
async def _post(self, url: str, data: dict[str, str]) -> str:
"""POST avec retries (fetch du socle est GET uniquement)."""
last_error: Exception | None = None
for attempt in range(3):
try:
response = await get_client().post(url, data=data)
response.raise_for_status()
return response.text
except (httpx.HTTPError, httpx.InvalidURL) as exc:
last_error = exc
logger.warning("french_manga POST %s — tentative %d/3 : %s", url, attempt + 1, exc)
await asyncio.sleep(1.0 * (attempt + 1))
raise ScrapeError(f"Échec de récupération de {url} : {last_error}")
async def _fetch_episodes_api(self, source_id: str) -> dict:
config = _merged_config()
api_url = urljoin(self.base_url + "/", config["episodes"]["api"])
html = await fetch(f"{api_url}?id={source_id}", referer=self._title_url(source_id))
try:
data = json.loads(html)
except json.JSONDecodeError as exc:
raise ScrapeError(
f"french_manga : réponse JSON invalide de l'API épisodes ({source_id}) : {exc}"
) from exc
if not isinstance(data, dict):
raise ScrapeError(f"french_manga : réponse inattendue de l'API épisodes ({source_id})")
return data
# ------------------------------------------------------------- search
async def search(self, query: str) -> list[SearchResult]:
config = _merged_config()
search_cfg = config["search"]
endpoint = urljoin(self.base_url + "/", search_cfg["endpoint"])
html = await self._post(endpoint, {"query": query, "page": "1"})
soup = BeautifulSoup(html, "lxml")
results: list[SearchResult] = []
for item in soup.select(search_cfg["result"]):
onclick = item.get("onclick", "")
match = re.search(r"location\.href='([^']+)'", onclick)
if not match:
logger.warning("french_manga : search-item sans lien (onclick=%r)", onclick[:80])
continue
url = urljoin(self.base_url + "/", match.group(1))
source_id = self._id_from_url(url)
if not source_id:
logger.warning("french_manga : identifiant introuvable dans %s", url)
continue
title_el = item.select_one(search_cfg["title"])
title = title_el.get_text(strip=True) if title_el else url
image = item.select_one(search_cfg["image"])
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=_YEAR_RE.sub("", title).strip() or title,
url=url,
image_url=image.get("src") if image else None,
media_type="film" if re.search(r"\bfilm\b", title, re.IGNORECASE) else "anime",
)
)
logger.info("french_manga : %d résultats pour %r", len(results), query)
return results
# ------------------------------------------------------------- latest
async def latest(self) -> list[SearchResult]:
"""Ajouts récents — section « Récemment mises à jour »."""
config = _merged_config()
latest_cfg = config["latest"]
url = urljoin(self.base_url + "/", latest_cfg["path"])
soup = await fetch_soup(url)
results: list[SearchResult] = []
for item in soup.select(latest_cfg["item"]):
link = item.select_one(latest_cfg["link"])
href = link.get("href") if link else None
if not href:
logger.warning("french_manga : bloc nouveauté sans lien, ignoré")
continue
absolute = urljoin(self.base_url + "/", href)
source_id = self._id_from_url(absolute)
if not source_id:
logger.warning("french_manga : identifiant introuvable dans %s", absolute)
continue
image = item.select_one(latest_cfg["image"])
title_el = item.select_one(latest_cfg["title"])
title = title_el.get_text(strip=True) if title_el else (link.get("alt") or absolute)
title = _YEAR_RE.sub("", title).strip() or title
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=title,
url=self._title_url(source_id),
image_url=image.get("src") if image else None,
)
)
logger.info("french_manga : %d nouveautés récupérées", len(results))
return results
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails:
config = _merged_config()
url = self._title_url(source_id)
soup = await fetch_soup(url)
details_cfg = config["details"]
config_el = soup.select_one(details_cfg["config_el"])
news_id = config_el.get("data-news-id") if config_el else None
if news_id != source_id:
raise ScrapeError(f"french_manga : fiche introuvable pour {source_id} ({url})")
title = config_el.get("data-title") or (
soup.title.get_text(strip=True) if soup.title else source_id
)
release = self._text(soup.select_one(details_cfg["release"]))
release_match = re.search(r"(\d{4})", release)
badge = self._text(soup.select_one(details_cfg["episode_badge"]))
badge_match = _NUMBER_RE.search(badge)
episodes = await self._episodes_from_api(source_id, title, url)
return TitleDetails(
source=self.name,
source_id=source_id,
title=title,
url=url,
synopsis=self._text(soup.select_one(details_cfg["synopsis"])) or None,
image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"),
genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])],
year=int(release_match.group(1)) if release_match else None,
episode_count=int(float(badge_match.group(1).replace(",", ".")))
if badge_match
else None,
episodes=episodes,
)
@staticmethod
def _text(element: Tag | None) -> str:
return element.get_text(" ", strip=True) if element else ""
# ------------------------------------------------------------ episodes
async def list_episodes(self, source_id: str) -> list[Episode]:
return await self._episodes_from_api(source_id, None, self._title_url(source_id))
async def _episodes_from_api(
self, source_id: str, title: str | None, page_url: str
) -> list[Episode]:
config = _merged_config()
data = await self._fetch_episodes_api(source_id)
versions: list[str] = config["episodes"]["versions"]
season = 1
if title:
season_match = _SEASON_RE.search(title)
if season_match:
season = int(season_match.group(1))
numbers: set[float] = set()
for version in versions:
for number_text in data.get(version) or {}:
try:
numbers.add(float(number_text))
except ValueError:
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
info = data.get("info") or {}
episodes: list[Episode] = []
for number in sorted(numbers):
number_text = str(int(number)) if number.is_integer() else str(number)
episode_info = info.get(number_text) or info.get(str(int(number)))
label = episode_info.get("title") if isinstance(episode_info, dict) else None
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
episodes.append(
Episode(
number=number,
title=label,
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
season=season,
)
)
if not episodes:
raise ScrapeError(f"french_manga : aucun épisode trouvé pour {source_id}")
return episodes
# -------------------------------------------------------------- embeds
async def extract_embed_links(self, episode_url: str) -> list[str]:
config = _merged_config()
page_url, _, fragment = episode_url.partition("#")
prefix = config["episodes"]["fragment_prefix"] + "="
match = re.match(rf"{re.escape(prefix)}([A-Za-z0-9]+)-([\d.]+)$", fragment)
if not match:
raise ScrapeError(f"french_manga : fragment d'épisode invalide dans {episode_url}")
version, number_text = match.group(1), match.group(2)
if number_text.isdigit():
number_text = str(int(number_text))
source_id = self._id_from_url(page_url)
if not source_id:
raise ScrapeError(f"french_manga : identifiant introuvable dans {episode_url}")
data = await self._fetch_episodes_api(source_id)
hosters = (data.get(version) or {}).get(number_text)
if not hosters:
raise ScrapeError(
f"french_manga : aucun lecteur pour {version} épisode {number_text} ({episode_url})"
)
links = list(hosters.values())
logger.info("french_manga : %d liens embed pour %s", len(links), episode_url)
return links
+327
View File
@@ -0,0 +1,327 @@
"""Source Vostfree (ipv4.vostfree.ws) — moteur DataLife Engine, animes/films VF & VOSTFR.
Faits structurels (vérifiés en live) :
- Recherche : GET /index.php?do=search&subaction=search&story=<q> → blocs `div.search-result`.
- Nouveautés : /animes-vostfr-recement-ajoutees.html → blocs `div.movie-poster`
(lien `.play a`, alt = titre, poster `span.image img`).
- Fiche : `/444-telecharger-....html` — métadonnées dans `.slide-*`, épisodes dans un
`select.new_player_selector` (une `option value="buttons_N"` par épisode).
- Chaque `div#buttons_N` contient un ou plusieurs `div.new_player_<hoster>#player_M` ;
l'URL embed est le texte de `div#content_player_M` (URL complète ou simple ID selon
l'hébergeur — les templates de reconstruction viennent du JS `anime.js` du site).
- Pas de page par épisode : l'URL d'épisode est `<fiche>#buttons_N`.
"""
import logging
import re
from copy import deepcopy
from urllib.parse import quote
from bs4 import BeautifulSoup, Tag
from app.scrapers.base import (
Episode,
ScrapeError,
SearchResult,
SourceScraper,
TitleDetails,
register_source,
)
from app.scrapers.config_loader import load_scraper_config
from app.scrapers.http import fetch_soup
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
"search": {
"result": "div.search-result",
"link": "div.title a",
"image": "span.image img",
"genres": "ul.additional li",
},
"details": {
"title": "h1",
"poster": ".slide-poster img",
"synopsis": ".slide-desc",
"genres": '.slide-top li.right a[href*="/genre/"]',
"episode_badge": ".slide-poster .year",
"season_li": "ul.slide-top li",
},
"episodes": {
"option": "select.new_player_selector option",
"button": "div.button_box",
"content_prefix": "content_",
},
"latest": {
"path": "/animes-vostfr-recement-ajoutees.html",
"item": "div.movie-poster",
"link": ".play a", # href = fiche, alt = titre
"image": "span.image img",
},
# class CSS du lecteur → template d'URL ({} = valeur de #content_player_M ;
# chaîne vide = la valeur est déjà une URL complète)
"players": {
"new_player_vip": "",
"new_player_moevideo": "",
"new_player_sibnet": "https://video.sibnet.ru/shell.php?videoid={}",
"new_player_netu": "https://video.sibnet.ru/shell.php?videoid={}",
"new_player_uqload": "https://uqload.com/embed-{}.html",
"new_player_mp4": "https://www.mp4upload.com/embed-{}.html",
"new_player_fembed": "https://www.fembed.com/v/{}",
"new_player_mytv": "https://www.myvi.top/embed/{}",
"new_player_myvi": "https://myvi.ru/player/embed/html/{}",
"new_player_moevideo_mail": "",
"new_player_rutube": "https://rutube.ru/play/embed/{}",
"new_player_ok": "https://ok.ru/video/{}",
"new_player_mail2": "https://my.mail.ru/video/embed/{}",
"new_player_rapids": "https://rapidstream.co/embed-{}.html",
"new_player_gtv": "https://iframedream.com/embed/{}.html",
"new_player_cloudvideo": "https://cloudvideo.tv/embed-{}.html",
"new_player_uptostream": "https://uptostream.com/iframe/{}",
},
}
_SLUG_RE = re.compile(r"([^/]+)\.html?$")
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
def _merged_config() -> dict:
merged = deepcopy(DEFAULT_CONFIG)
for key, values in load_scraper_config("vostfree").items():
if isinstance(values, dict) and isinstance(merged.get(key), dict):
merged[key].update(values)
else:
merged[key] = values
return merged
@register_source
class VostfreeScraper(SourceScraper):
name = "vostfree"
label = "Vostfree"
base_url = "https://ipv4.vostfree.ws"
media_types = ("anime",)
# ------------------------------------------------------------- helpers
def _title_url(self, source_id: str) -> str:
return f"{self.base_url}/{source_id}.html"
@staticmethod
def _slug_from_url(url: str) -> str | None:
match = _SLUG_RE.search(url)
return match.group(1) if match else None
@staticmethod
def _text(element: Tag | None) -> str:
return element.get_text(" ", strip=True) if element else ""
# ------------------------------------------------------------- search
async def search(self, query: str) -> list[SearchResult]:
config = _merged_config()
url = f"{self.base_url}/index.php?do=search&subaction=search&story={quote(query)}"
soup = await fetch_soup(url)
results: list[SearchResult] = []
for block in soup.select(config["search"]["result"]):
link = block.select_one(config["search"]["link"])
href = link.get("href") if link else None
if not href:
logger.warning("vostfree : bloc de résultat sans lien, ignoré")
continue
source_id = self._slug_from_url(href)
if not source_id:
logger.warning("vostfree : slug introuvable dans %s", href)
continue
image = block.select_one(config["search"]["image"])
genres_text = " ".join(
li.get_text(" ", strip=True) for li in block.select(config["search"]["genres"])
)
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=self._text(link),
url=href,
image_url=image.get("src") if image else None,
media_type="film"
if re.search(r"\bfilm", genres_text, re.IGNORECASE)
else "anime",
)
)
logger.info("vostfree : %d résultats pour %r", len(results), query)
return results
# ------------------------------------------------------------- latest
async def latest(self) -> list[SearchResult]:
"""Ajouts récents — page « Animes VOSTFR récemment ajoutés »."""
config = _merged_config()
latest_cfg = config["latest"]
soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}")
results: list[SearchResult] = []
for block in soup.select(latest_cfg["item"]):
link = block.select_one(latest_cfg["link"])
href = link.get("href") if link else None
if not href:
logger.warning("vostfree : bloc nouveauté sans lien, ignoré")
continue
source_id = self._slug_from_url(href)
if not source_id:
logger.warning("vostfree : slug introuvable dans %s", href)
continue
image = block.select_one(latest_cfg["image"])
title = link.get("alt") or self._text(link)
results.append(
SearchResult(
source=self.name,
source_id=source_id,
title=title,
url=href,
image_url=image.get("src") if image else None,
)
)
logger.info("vostfree : %d nouveautés récupérées", len(results))
return results
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails:
config = _merged_config()
url = self._title_url(source_id)
soup = await fetch_soup(url)
details_cfg = config["details"]
title_el = soup.select_one(details_cfg["title"])
if title_el is None:
raise ScrapeError(f"vostfree : fiche introuvable pour {source_id} ({url})")
synopsis_el = soup.select_one(details_cfg["synopsis"])
if synopsis_el is not None:
for cast in synopsis_el.select(".cast"):
cast.decompose()
synopsis = self._text(synopsis_el) or None
badge = self._text(soup.select_one(details_cfg["episode_badge"]))
badge_match = _NUMBER_RE.search(badge)
season = 1
for li in soup.select(details_cfg["season_li"]):
season_match = _SEASON_RE.search(self._text(li))
if season_match:
season = int(season_match.group(1))
break
episodes = self._parse_episodes(soup, url, season)
return TitleDetails(
source=self.name,
source_id=source_id,
title=self._text(title_el),
url=url,
synopsis=synopsis,
image_url=(soup.select_one(details_cfg["poster"]) or Tag(name="img")).get("src"),
genres=[a.get_text(strip=True) for a in soup.select(details_cfg["genres"])],
episode_count=int(badge_match.group(1).replace(",", ".")) if badge_match else None,
episodes=episodes,
)
# ------------------------------------------------------------ episodes
async def list_episodes(self, source_id: str) -> list[Episode]:
url = self._title_url(source_id)
soup = await fetch_soup(url)
season = self._find_season(soup)
episodes = self._parse_episodes(soup, url, season)
if not episodes:
raise ScrapeError(f"vostfree : aucun épisode trouvé pour {source_id} ({url})")
return episodes
def _find_season(self, soup: BeautifulSoup) -> int:
config = _merged_config()
for li in soup.select(config["details"]["season_li"]):
season_match = _SEASON_RE.search(self._text(li))
if season_match:
return int(season_match.group(1))
return 1
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
config = _merged_config()
options = soup.select(config["episodes"]["option"])
entries: list[tuple[str, str]] = []
seen_ids: set[str] = set()
for opt in options:
value = opt.get("value", "")
if value in seen_ids:
continue # le site duplique parfois une option (ex. buttons_268)
seen_ids.add(value)
entries.append((value, opt.get_text(strip=True)))
if not entries: # fiche sans sélecteur : on retombe sur les blocs de boutons
entries = [
(box.get("id", ""), f"Episode {index}")
for index, box in enumerate(soup.select(config["episodes"]["button"]), start=1)
]
episodes: list[Episode] = []
for index, (button_id, label) in enumerate(entries, start=1):
number_match = _NUMBER_RE.search(label)
number = (
float(number_match.group(1).replace(",", ".")) if number_match else float(index)
)
episodes.append(
Episode(
number=number,
title=label or f"Episode {index}",
url=f"{page_url}#{button_id}" if button_id else page_url,
season=season,
)
)
return episodes
# -------------------------------------------------------------- embeds
async def extract_embed_links(self, episode_url: str) -> list[str]:
config = _merged_config()
page_url, _, fragment = episode_url.partition("#")
soup = await fetch_soup(page_url)
button_cfg = config["episodes"]
button: Tag | None = None
if fragment and _SAFE_FRAGMENT_RE.match(fragment):
button = soup.select_one(f"#{fragment}")
if button is None:
logger.warning(
"vostfree : fragment #%s sans bloc correspondant dans %s", fragment, page_url
)
if button is None:
button = soup.select_one(button_cfg["button"])
if button is None:
raise ScrapeError(f"vostfree : aucun lecteur trouvé sur {episode_url}")
prefix = button_cfg["content_prefix"]
links: list[str] = []
for player in button.find_all("div", recursive=False):
player_id = player.get("id")
content = soup.select_one(f"#{prefix}{player_id}") if player_id else None
if content is None:
logger.warning(
"vostfree : contenu absent pour le lecteur #%s (%s)", player_id, page_url
)
continue
value = content.get_text(strip=True)
if not value:
continue
player_class = (player.get("class") or [""])[0]
template = config["players"].get(player_class)
if value.startswith("http"):
links.append(value)
elif template is not None:
links.append(template.format(value))
else:
logger.warning(
"vostfree : pas de template pour le lecteur %r (valeur %r)", player_class, value
)
if not links:
raise ScrapeError(f"vostfree : aucun lien embed extrait de {episode_url}")
logger.info("vostfree : %d liens embed pour %s", len(links), episode_url)
return links
View File
+352
View File
@@ -0,0 +1,352 @@
"""Découverte : nouveautés des sources, incontournables et recommandations.
Trois sections :
- **latest** — « récemment ajoutés » scrapés sur chaque source activée (cliquables
directement vers la fiche) ;
- **must_watch** — titres les plus populaires du catalogue Kitsu (tous temps) ;
- **for_you** — recommandations par genres : les genres des titres téléchargés
(serveur), des favoris (par utilisateur) et des séries téléchargées sur Sonarr
sont agrégés, puis Kitsu est interrogé sur ces catégories en excluant le
déjà-possédé.
Toutes les sources externes sont optionnelles : un échec (réseau, scraping, API)
laisse la section vide et n'est jamais remonté au caller (dégradation gracieuse).
Un cache mémoire TTL évite de re-scaper à chaque chargement de page.
"""
import asyncio
import dataclasses
import json
import logging
import re
import time
import unicodedata
import httpx
from app.config import get_settings
from app.db import db
from app.scrapers.base import (
ScrapeError,
SourceScraper,
all_sources,
import_all_scrapers,
)
from app.services.kitsu import KitsuService, normalize_title
from app.services.settings import is_source_enabled
from app.services.sonarr import sonarr
logger = logging.getLogger(__name__)
import_all_scrapers()
# Bornes de l'algorithme
_MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
class _TTLCache:
"""Cache mémoire minimal avec expiration (mono-processus, suffisant ici)."""
def __init__(self) -> None:
self._data: dict[str, tuple[float, object]] = {}
def get(self, key: str) -> object | None:
entry = self._data.get(key)
if entry is None:
return None
expires_at, value = entry
if expires_at <= time.monotonic():
del self._data[key]
return None
return value
def set(self, key: str, value: object, ttl_seconds: float) -> None:
self._data[key] = (time.monotonic() + ttl_seconds, value)
def clear(self, prefix: str = "") -> None:
"""Invalide les clés commençant par prefix (vide = tout le cache)."""
for key in [k for k in self._data if k.startswith(prefix)]:
del self._data[key]
def category_slug(name: str) -> str:
"""Nom de genre → slug de catégorie Kitsu (« Slice of Life » → « slice-of-life »)."""
decomposed = unicodedata.normalize("NFKD", name)
ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char))
return re.sub(r"[^a-z0-9]+", "-", ascii_only.casefold()).strip("-")
class DiscoverService:
"""Agrégation des trois sections de découverte, avec cache mémoire."""
def __init__(self) -> None:
self._cache = _TTLCache()
self._kitsu = KitsuService()
# ------------------------------------------------------------ nouveautés
async def latest(self, limit: int = 24) -> list[dict]:
"""Nouveautés toutes sources confondues, triées par date de sortie réelle.
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés),
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus
récent au plus ancien — ce qui sort / vient de sortir en premier.
"""
cached = self._cache.get(f"latest:{limit}")
if cached is not None:
return cached # type: ignore[return-value]
sources = [s for s in all_sources() if await is_source_enabled(s.name)]
outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources))
items = [item for outcome in outcomes for item in (outcome or [])]
merged: dict[str, dict] = {}
for item in items:
key = normalize_title(item["title"]).casefold()
existing = merged.get(key)
if existing is None or (not existing.get("image_url") and item.get("image_url")):
merged[key] = item
semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
async def bounded(item: dict) -> dict:
async with semaphore:
return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True)
result = result[:limit]
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS)
return result
async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO)."""
try:
results = await source.latest()
except ScrapeError as exc:
logger.warning("Nouveautés indisponibles pour %s : %s", source.name, exc)
return None
return [
{**dataclasses.asdict(r), "source": source.name, "label": source.label}
for r in results
]
async def _with_release_info(self, item: dict) -> dict:
"""Complète un item de nouveauté avec sa date de sortie Kitsu (None si absent)."""
item.setdefault("start_date", None)
item.setdefault("status", None)
item.setdefault("rating", None)
match = await self._kitsu_match_for_title(item["title"])
if match is None:
return item
attrs = match.get("attributes", {})
item["start_date"] = attrs.get("startDate")
item["status"] = attrs.get("status")
item["rating"] = KitsuService._to_rating_10(attrs.get("averageRating"))
return item
# --------------------------------------------------------- incontournables
async def must_watch(self, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
"""Titres les plus populaires du catalogue Kitsu (tous temps)."""
limit = min(limit, _KITSU_PAGE_MAX)
key = f"must_watch:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
items = await self._kitsu_anime({"sort": "-userCount", "page[limit]": limit})
self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
return items
# ------------------------------------------------------------ pour toi
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
"""Recommandations par genres, à partir de l'historique de l'utilisateur.
Genres = téléchargements du serveur (titres → Kitsu) + favoris de l'utilisateur
(genres du payload) + séries téléchargées sur Sonarr (genres fournis par
Sonarr). On exclut les titres déjà possédés (local et Sonarr).
"""
limit = min(limit, _KITSU_PAGE_MAX)
cache_key = f"for_you:{user_id}:{limit}"
cached = self._cache.get(cache_key)
if cached is not None:
return cached # type: ignore[return-value]
owned, favorite_genres = await self._owned(user_id)
genre_counts = await self._genres_from_downloads(owned)
for genre, count in favorite_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
sonarr_owned, sonarr_genres = await sonarr.profile()
for genre, count in sonarr_genres.items():
genre_counts[genre] = genre_counts.get(genre, 0) + count
owned |= sonarr_owned
if not genre_counts:
result: dict = {"based_on": [], "items": []}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
top_genres = sorted(genre_counts, key=genre_counts.get, reverse=True)[:_MAX_GENRES]
slugs = [category_slug(genre) for genre in top_genres]
items = await self._kitsu_anime(
{
"filter[categories]": ",".join(slugs),
"sort": "-userCount",
"page[limit]": limit, # le déjà-possédé est filtré après
}
)
kept = [item for item in items if item["title"] and item["title"].casefold() not in owned]
result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
def invalidate_for_you(self) -> None:
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
self._cache.clear("for_you:")
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
rows = await db.fetchall(
"SELECT DISTINCT title FROM downloads ORDER BY created_at DESC LIMIT ?",
(_MAX_HISTORY_TITLES,),
)
fav_rows = await db.fetchall(
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
(user_id, _MAX_HISTORY_TITLES),
)
owned = {normalize_title(row["title"]).casefold() for row in rows}
owned.discard("")
genre_counts: dict[str, int] = {}
for row in fav_rows:
try:
payload = json.loads(row["payload"]) if row["payload"] else {}
except (TypeError, ValueError):
continue
for genre in payload.get("genres") or []:
if isinstance(genre, str) and genre.strip():
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
return owned, genre_counts
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
semaphore = asyncio.Semaphore(_MAX_HISTORY_TITLES)
async def genres_of(title: str) -> list[str]:
async with semaphore:
return await self._kitsu_genres_for_title(title)
outcomes = await asyncio.gather(*(genres_of(t) for t in list(titles)[:_MAX_HISTORY_TITLES]))
counts: dict[str, int] = {}
for genres in outcomes:
for genre in genres:
counts[genre] = counts.get(genre, 0) + 1
return counts
async def _kitsu_match_for_title(self, title: str) -> dict | None:
"""Match Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache)."""
query = normalize_title(title)
if not query:
return None
cache_key = f"kitsu:anime:{query.casefold()}"
match = await self._kitsu.get_cached(cache_key)
if match is None:
match = await self._kitsu.search_anime(title)
if match is not None:
await self._kitsu.set_cached(cache_key, match)
return match
async def _kitsu_genres_for_title(self, title: str) -> list[str]:
"""Genres Kitsu d'un titre scrapé (cache DB 72 h via metadata_cache).
La recherche Kitsu ne renvoie plus les genres (`include=genres` vide) :
on complète avec l'endpoint /anime/<id>/categories.
"""
match = await self._kitsu_match_for_title(title)
if match is None:
return []
genres = [g for g in match.get("genres", []) if isinstance(g, str)]
if not genres:
genres = await self._kitsu_categories(match.get("id"))
match["genres"] = genres
query = normalize_title(title)
await self._kitsu.set_cached( # refresh avec les genres
f"kitsu:anime:{query.casefold()}", match
)
return genres
# -------------------------------------------------------------- Kitsu
async def _kitsu_anime(self, params: dict) -> list[dict]:
"""Requête générique liste Kitsu → items normalisés ([] si échec)."""
settings = get_settings()
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(f"{settings.kitsu_base_url}/anime", params=params)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Liste Kitsu échouée (%s) : %s", params, exc)
return []
return [
self._normalize_anime(item)
for item in payload.get("data", [])
if item.get("type") == "anime"
]
async def _kitsu_categories(self, anime_id: object) -> list[str]:
"""Titres des catégories Kitsu d'un anime ([] si échec)."""
if not anime_id:
return []
settings = get_settings()
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(
f"{settings.kitsu_base_url}/anime/{anime_id}/categories",
params={"page[limit]": _KITSU_PAGE_MAX},
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Catégories Kitsu échouées (anime %s) : %s", anime_id, exc)
return []
return [
attrs["title"]
for item in payload.get("data", [])
if isinstance(attrs := item.get("attributes", {}), dict) and attrs.get("title")
]
@staticmethod
def _normalize_anime(item: dict) -> dict:
attrs = item.get("attributes", {})
titles = attrs.get("titles") or {}
poster = attrs.get("posterImage") or {}
return {
"kitsu_id": item.get("id"),
"title": attrs.get("canonicalTitle") or titles.get("en_jp"),
"image_url": poster.get("large") or poster.get("medium") or poster.get("tiny"),
"rating": KitsuService._to_rating_10(attrs.get("averageRating")),
"year": KitsuService._extract_year(attrs.get("startDate")),
"subtype": attrs.get("subtype"),
"user_count": attrs.get("userCount"),
}
discover = DiscoverService()
+499
View File
@@ -0,0 +1,499 @@
"""Gestionnaire de téléchargements : file asyncio, parallélisme limité,
pause/reprise (Range HTTP), anti-doublons, persistance, progression temps réel.
Les statuts : pending → downloading → done | failed | cancelled
↕ paused
"""
import asyncio
import contextlib
import logging
import re
import signal
import time
import unicodedata
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
import httpx
from app.config import get_settings
from app.db import db
logger = logging.getLogger(__name__)
ACTIVE_STATUSES = ("pending", "downloading", "paused")
_FFMPEG_TIME_RE = re.compile(r"time=(\d+:\d+:\d+(?:\.\d+)?)")
_EXTINF_RE = re.compile(r"#EXTINF:([\d.]+)")
_BANDWIDTH_RE = re.compile(r"#EXT-X-STREAM-INF:[^\n]*BANDWIDTH=(\d+)[^\n]*\n(\S+)")
def _parse_ffmpeg_time(value: str) -> float:
parts = value.split(":")
return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
def _best_variant(master_body: str, base_url: str) -> str | None:
"""URL de la variante au plus haut débit d'une playlist maître HLS."""
variants = [
(int(bw), urljoin(base_url, uri)) for bw, uri in _BANDWIDTH_RE.findall(master_body)
]
return max(variants)[1] if variants else None
_STATUS_LABELS = {
"pending": "en attente",
"downloading": "en cours",
"paused": "en pause",
"done": "terminé",
"failed": "échec",
"cancelled": "annulé",
}
def sanitize_filename(name: str) -> str:
"""Nettoie un nom de fichier : caractères interdits retirés, anti-traversée."""
name = unicodedata.normalize("NFKC", name)
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", name)
name = re.sub(r"\s+", " ", name).strip(" .")
if not name or name in (".", ".."):
name = "video"
return name[:150]
class DownloadManager:
"""File d'attente de téléchargements, injectée dans les routes via app.state."""
def __init__(self) -> None:
self._queue: asyncio.Queue[int] # créée dans start() (affinité avec la boucle)
self._tasks: dict[int, asyncio.Task] = {} # download_id → tâche asyncio
self._pause_events: dict[int, asyncio.Event] = {} # set = peut tourner
self._progress: dict[int, dict[str, Any]] = {} # progression temps réel en mémoire
self._listeners: list[asyncio.Queue] = []
self._workers: list[asyncio.Task] = []
self._client: httpx.AsyncClient | None = None
self._hls_processes: dict[int, asyncio.subprocess.Process] = {}
# ------------------------------------------------------------ cycle de vie
async def start(self) -> None:
self._queue = asyncio.Queue()
settings = get_settings()
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
headers={
"User-Agent": settings.user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
)
# Restaure les téléchargements interrompus (crash/arrêt) en 'pending'
await db.execute(
"UPDATE downloads SET status = 'pending', updated_at = datetime('now') "
"WHERE status = 'downloading'"
)
await self._scan_download_dir()
for _ in range(settings.max_parallel_downloads):
self._workers.append(asyncio.create_task(self._worker()))
logger.info("DownloadManager démarré (%d workers)", settings.max_parallel_downloads)
async def stop(self) -> None:
for worker in self._workers:
worker.cancel()
for task in self._tasks.values():
task.cancel()
for proc in self._hls_processes.values():
if proc.returncode is None:
proc.kill()
if self._client:
await self._client.aclose()
self._workers.clear()
self._tasks.clear()
async def _scan_download_dir(self) -> None:
"""Restaure en 'done' les fichiers présents sur disque mais inconnus de la DB."""
download_dir = get_settings().download_dir
rows = await db.fetchall("SELECT file_path FROM downloads WHERE file_path IS NOT NULL")
known = {row["file_path"] for row in rows}
for path in download_dir.iterdir():
if path.is_file() and path.suffix != ".part" and path.name not in known:
size = path.stat().st_size
await db.execute(
"INSERT INTO downloads "
"(source_key, video_url, page_url, title, file_path, status, "
" total_bytes, downloaded_bytes) "
"VALUES (?, ?, ?, ?, ?, 'done', ?, ?)",
(
f"file:{path.name}",
"",
"",
path.stem,
path.name,
size,
size,
),
)
logger.info("Fichier restauré depuis le disque : %s", path.name)
# ------------------------------------------------------------ API publique
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif."""
source_key = video_url
existing = await db.fetchone(
f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
f"({','.join('?' * len(ACTIVE_STATUSES))})",
(source_key, *ACTIVE_STATUSES),
)
if existing:
logger.info("Anti-doublon : %s déjà en file (id=%s)", title, existing["id"])
return self._to_dict(existing, duplicate=True)
filename = sanitize_filename(title) + self._guess_extension(video_url)
cursor = await db.execute(
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path) "
"VALUES (?, ?, ?, ?, ?)",
(source_key, video_url, page_url, title, filename),
)
download_id = cursor.lastrowid
await self._queue.put(download_id)
await self._emit(download_id)
logger.info("Téléchargement ajouté : %s (id=%s)", title, download_id)
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
return self._to_dict(row)
async def pause(self, download_id: int) -> bool:
event = self._pause_events.get(download_id)
if event:
event.clear()
await self._set_status(download_id, "paused")
return True
async def resume(self, download_id: int) -> bool:
row = await self._get_row(download_id)
if row["status"] != "paused":
return False
await self._set_status(download_id, "pending")
await self._queue.put(download_id)
return True
async def retry(self, download_id: int) -> bool:
row = await self._get_row(download_id)
if row["status"] not in ("failed", "cancelled"):
return False
await db.execute(
"UPDATE downloads SET status = 'pending', error = NULL, downloaded_bytes = 0, "
"updated_at = datetime('now') WHERE id = ?",
(download_id,),
)
part = self._part_path(row["file_path"])
part.unlink(missing_ok=True)
await self._queue.put(download_id)
await self._emit(download_id)
return True
async def cancel(self, download_id: int) -> bool:
task = self._tasks.get(download_id)
if task:
task.cancel()
proc = self._hls_processes.get(download_id)
if proc and proc.returncode is None:
proc.kill()
await self._set_status(download_id, "cancelled")
row = await self._get_row(download_id)
self._part_path(row["file_path"]).unlink(missing_ok=True)
await self._emit(download_id)
return True
async def cancel_all(self) -> int:
rows = await db.fetchall(
f"SELECT id FROM downloads WHERE status IN ({','.join('?' * len(ACTIVE_STATUSES))})",
ACTIVE_STATUSES,
)
for row in rows:
await self.cancel(row["id"])
return len(rows)
async def clear_finished(self) -> int:
"""Supprime de la file les tâches terminées/échouées/annulées (fichiers gardés)."""
cursor = await db.execute(
"DELETE FROM downloads WHERE status IN ('done', 'failed', 'cancelled')"
)
return cursor.rowcount or 0
async def list_all(self, limit: int = 200) -> list[dict]:
rows = await db.fetchall(
"SELECT * FROM downloads ORDER BY "
"CASE status WHEN 'downloading' THEN 0 WHEN 'pending' THEN 1 WHEN 'paused' THEN 2 "
"ELSE 3 END, updated_at DESC LIMIT ?",
(limit,),
)
return [self._to_dict(row) for row in rows]
async def get(self, download_id: int) -> dict | None:
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
return self._to_dict(row) if row else None
# ------------------------------------------------------------ événements SSE
async def subscribe(self) -> AsyncIterator[dict]:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._listeners.append(queue)
try:
while True:
yield await queue.get()
finally:
self._listeners.remove(queue)
async def _emit(self, download_id: int) -> None:
data = await self.get(download_id)
if data is None:
return
for queue in self._listeners:
with contextlib.suppress(asyncio.QueueFull):
queue.put_nowait(data)
# ------------------------------------------------------------ worker interne
async def _worker(self) -> None:
while True:
download_id = await self._queue.get()
row = await db.fetchone("SELECT status FROM downloads WHERE id = ?", (download_id,))
if row is None or row["status"] != "pending":
continue # annulé/pausé entre-temps
# Tâche dédiée : annuler un téléchargement ne doit pas tuer le worker
task = asyncio.create_task(self._download(download_id))
self._tasks[download_id] = task
self._pause_events[download_id] = asyncio.Event()
self._pause_events[download_id].set()
try:
await task
except asyncio.CancelledError:
if asyncio.current_task().cancelling() > 0:
raise # le worker lui-même s'arrête (stop())
finally:
self._tasks.pop(download_id, None)
self._pause_events.pop(download_id, None)
self._progress.pop(download_id, None)
async def _download(self, download_id: int) -> None:
"""Dispatche HTTP/HLS ; gestion d'erreurs centralisée ici."""
row = await self._get_row(download_id)
try:
if ".m3u8" in row["video_url"]:
await self._download_hls(download_id, row)
else:
await self._download_http(download_id, row)
except asyncio.CancelledError:
logger.info("Téléchargement annulé : %s", row["file_path"])
raise
except (httpx.HTTPError, OSError) as exc:
# Échec réseau/disque : journalisé, statut 'failed' visible dans l'UI
logger.error("Échec du téléchargement de %s : %s", row["file_path"], exc)
part = self._part_path(row["file_path"])
downloaded = part.stat().st_size if part.exists() else 0
await self._fail(download_id, exc, downloaded)
async def _download_http(self, download_id: int, row: Any) -> None:
"""Téléchargement HTTP direct avec reprise via Range."""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
part = self._part_path(file_path)
downloaded = part.stat().st_size if part.exists() else 0
headers: dict[str, str] = {}
if row["page_url"]:
headers["Referer"] = row["page_url"]
if downloaded:
headers["Range"] = f"bytes={downloaded}-"
logger.info("Reprise de %s à %d octets", file_path, downloaded)
await self._set_status(download_id, "downloading")
await self._emit(download_id)
started = time.monotonic()
last_emit = 0.0
async with self._client.stream("GET", video_url, headers=headers) as response:
if response.status_code == 416: # plage invalide → déjà complet
part.rename(target)
await self._finish(download_id, downloaded)
return
response.raise_for_status()
if downloaded and response.status_code != 206:
downloaded = 0 # serveur sans support Range → on repart de zéro
logger.warning("Pas de reprise possible pour %s", file_path)
total = int(response.headers.get("content-length") or 0) + downloaded or None
await db.execute(
"UPDATE downloads SET total_bytes = ? WHERE id = ?", (total, download_id)
)
mode = "ab" if downloaded else "wb"
with part.open(mode) as fh:
async for chunk in response.aiter_bytes(1 << 16):
event = self._pause_events.get(download_id)
if event is not None:
await event.wait() # pause coopérative
fh.write(chunk)
downloaded += len(chunk)
now = time.monotonic()
if now - last_emit >= 1.0:
last_emit = now
await self._report_progress(download_id, downloaded, total, started)
part.rename(target)
await self._finish(download_id, downloaded)
async def _download_hls(self, download_id: int, row: Any) -> None:
"""Télécharge un flux HLS (.m3u8) via ffmpeg (remux en mp4).
Pause via SIGSTOP/SIGCONT du processus, annulation via kill.
La progression est estimée depuis la durée totale de la playlist.
"""
video_url, file_path = row["video_url"], row["file_path"]
target = get_settings().download_dir / file_path
part = self._part_path(file_path)
part.unlink(missing_ok=True) # pas de reprise partielle en HLS
ffmpeg_headers = f"User-Agent: {get_settings().user_agent}\r\n"
if row["page_url"]:
ffmpeg_headers += f"Referer: {row['page_url']}\r\n"
ffmpeg_headers += "Accept-Language: fr-FR,fr;q=0.9,en;q=0.8\r\n"
total_seconds = await self._hls_duration(video_url, row["page_url"])
await self._set_status(download_id, "downloading")
await self._emit(download_id)
started = time.monotonic()
process = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-nostdin", "-v", "error", "-nostats", "-progress", "pipe:2",
"-headers", ffmpeg_headers,
"-i", video_url,
"-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4",
str(part),
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
self._hls_processes[download_id] = process
try:
assert process.stderr is not None
async for raw_line in process.stderr:
event = self._pause_events.get(download_id)
if event is not None and not event.is_set():
process.send_signal(signal.SIGSTOP)
await event.wait()
process.send_signal(signal.SIGCONT)
line = raw_line.decode(errors="replace")
if match := _FFMPEG_TIME_RE.search(line):
elapsed_video = _parse_ffmpeg_time(match.group(1))
size = part.stat().st_size if part.exists() else 0
total_est = None
if total_seconds and elapsed_video > 0:
total_est = int(size / elapsed_video * total_seconds)
await self._report_progress(download_id, size, total_est, started)
return_code = await process.wait()
finally:
self._hls_processes.pop(download_id, None)
if return_code != 0:
part.unlink(missing_ok=True)
raise OSError(f"ffmpeg a échoué (code {return_code}) sur le flux HLS")
size = part.stat().st_size
part.rename(target)
await self._finish(download_id, size)
async def _hls_duration(self, playlist_url: str, referer: str | None) -> float | None:
"""Durée totale d'une playlist HLS (somme des EXTINF de la variante max)."""
headers = {"Referer": referer} if referer else {}
try:
response = await self._client.get(playlist_url, headers=headers)
response.raise_for_status()
body = response.text
# Playlist maître → on suit la variante de plus haut débit
variant = _best_variant(body, playlist_url)
if variant and variant != playlist_url:
response = await self._client.get(variant, headers=headers)
response.raise_for_status()
body = response.text
durations = [float(m) for m in _EXTINF_RE.findall(body)]
return sum(durations) if durations else None
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Durée HLS indéterminée pour %s : %s", playlist_url, exc)
return None
async def _report_progress(
self, download_id: int, downloaded: int, total: int | None, started: float
) -> None:
elapsed = time.monotonic() - started
self._progress[download_id] = {
"downloaded_bytes": downloaded,
"total_bytes": total,
"speed_bps": int(downloaded / elapsed) if elapsed > 0 else 0,
}
await self._emit(download_id)
async def _fail(self, download_id: int, exc: Exception, downloaded: int = 0) -> None:
await db.execute(
"UPDATE downloads SET status = 'failed', error = ?, "
"downloaded_bytes = ?, updated_at = datetime('now') WHERE id = ?",
(str(exc)[:500], downloaded, download_id),
)
await self._emit(download_id)
async def _finish(self, download_id: int, size: int) -> None:
await db.execute(
"UPDATE downloads SET status = 'done', total_bytes = ?, downloaded_bytes = ?, "
"updated_at = datetime('now') WHERE id = ?",
(size, size, download_id),
)
await self._emit(download_id)
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
# ------------------------------------------------------------ helpers
async def _get_row(self, download_id: int) -> Any:
row = await db.fetchone("SELECT * FROM downloads WHERE id = ?", (download_id,))
if row is None:
raise KeyError(f"Téléchargement introuvable : {download_id}")
return row
async def _set_status(self, download_id: int, status: str) -> None:
await db.execute(
"UPDATE downloads SET status = ?, updated_at = datetime('now') WHERE id = ?",
(status, download_id),
)
@staticmethod
def _part_path(file_path: str | None) -> Path:
name = file_path or "video"
return get_settings().download_dir / (name + ".part")
@staticmethod
def _guess_extension(url: str) -> str:
match = re.search(r"\.(mp4|mkv|webm|avi|m3u8)(?:\?|$)", url)
ext = match.group(1) if match else "mp4"
return ".mp4" if ext == "m3u8" else f".{ext}"
def _to_dict(self, row: Any, duplicate: bool = False) -> dict:
data = dict(row)
live = self._progress.get(data["id"], {})
downloaded = live.get("downloaded_bytes", data["downloaded_bytes"])
total = live.get("total_bytes", data["total_bytes"])
speed = live.get("speed_bps", 0)
percent = round(downloaded / total * 100, 1) if total else None
eta = int((total - downloaded) / speed) if total and speed else None
data.update(
downloaded_bytes=downloaded,
total_bytes=total,
percent=percent,
speed_bps=speed,
eta_seconds=eta,
status_label=_STATUS_LABELS.get(data["status"], data["status"]),
duplicate=duplicate,
)
return data
download_manager = DownloadManager()
+198
View File
@@ -0,0 +1,198 @@
"""Enrichissement de métadonnées via l'API Kitsu (https://kitsu.io/api/edge).
Fusion intelligente : seuls les champs manquants d'un TitleDetails sont complétés.
Les échecs (réseau, cache indisponible) sont loggés en warning et jamais remontés
au caller — la fiche d'origine est retournée inchangée (dégradation gracieuse).
"""
import json
import logging
import re
import sqlite3
import httpx
from app.config import get_settings
from app.db import db
from app.scrapers.base import TitleDetails
logger = logging.getLogger(__name__)
_NOISE_WORDS_RE = re.compile(
r"\b(?:VOSTFR\d*|VOST|VF[IV]?|TRUEFRENCH|FRENCH|MULTI|SUBFR?)\b", re.IGNORECASE
)
_TRAILING_SEASON_RE = re.compile(r"[\s\-–—:.]*\s*(?:saison|season)\s*\d+\s*$", re.IGNORECASE)
_TRAILING_CODE_RE = re.compile(r"[\s\-–—:.]*\s*S\d+(?:E\d+)?\s*$", re.IGNORECASE)
def normalize_title(title: str) -> str:
"""Nettoie un titre de scraping avant recherche Kitsu (bruit, saison, tirets)."""
cleaned = _NOISE_WORDS_RE.sub(" ", title)
cleaned = _TRAILING_SEASON_RE.sub("", cleaned)
cleaned = _TRAILING_CODE_RE.sub("", cleaned)
cleaned = re.sub(r"\s*[-–—_]+\s*", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
return cleaned.strip(" -–—:.")
class KitsuService:
"""Recherche et cache des métadonnées anime depuis l'API Kitsu."""
async def search_anime(self, title: str) -> dict | None:
"""Recherche le meilleur match Kitsu pour un titre (None si échec/rien trouvé)."""
settings = get_settings()
query = normalize_title(title)
if not query:
return None
try:
async with httpx.AsyncClient(
timeout=settings.http_timeout,
headers={
"User-Agent": settings.user_agent,
"Accept": "application/vnd.api+json",
},
) as client:
response = await client.get(
f"{settings.kitsu_base_url}/anime",
params={"filter[text]": query, "page[limit]": 5, "include": "genres"},
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Recherche Kitsu échouée pour %r : %s", title, exc)
return None
return self._pick_best_match(payload, query)
async def enrich(self, details: TitleDetails) -> TitleDetails:
"""Complète les champs manquants de details via Kitsu (jamais episodes/url/source)."""
query = normalize_title(details.title)
cache_key = f"kitsu:anime:{query.casefold()}"
result = await self.get_cached(cache_key)
from_cache = result is not None
if result is None:
result = await self.search_anime(details.title)
if result is not None:
await self.set_cached(cache_key, result)
if result is None:
logger.info("Aucune métadonnée Kitsu pour %r — fiche inchangée", details.title)
return details
attrs = result.get("attributes", {})
if not details.synopsis:
details.synopsis = attrs.get("synopsis")
if not details.image_url:
poster = attrs.get("posterImage") or {}
details.image_url = poster.get("large") or poster.get("medium")
if not details.banner_url:
cover = attrs.get("coverImage") or {}
details.banner_url = cover.get("large") or cover.get("original")
if not details.genres:
details.genres = list(result.get("genres", []))
if details.rating is None:
details.rating = self._to_rating_10(attrs.get("averageRating"))
if details.year is None:
details.year = self._extract_year(attrs.get("startDate"))
if details.episode_count is None:
details.episode_count = attrs.get("episodeCount")
logger.debug(
"Fiche %r enrichie via Kitsu (cache=%s, id=%s)",
details.title,
from_cache,
result.get("id"),
)
return details
async def get_cached(self, cache_key: str) -> dict | None:
"""Retourne le payload en cache s'il existe et est frais (TTL), sinon None."""
ttl_hours = get_settings().metadata_cache_ttl_hours
try:
row = await db.fetchone(
"SELECT payload FROM metadata_cache "
f"WHERE cache_key = ? AND fetched_at > datetime('now', '-{ttl_hours} hours')",
(cache_key,),
)
except (RuntimeError, sqlite3.Error) as exc:
logger.warning(
"Cache métadonnées illisible (%s), continuation sans cache : %s", cache_key, exc
)
return None
if row is None:
return None
try:
return json.loads(row["payload"])
except (TypeError, ValueError) as exc:
logger.warning("Cache métadonnées corrompu pour %s, re-fetch : %s", cache_key, exc)
return None
async def set_cached(self, cache_key: str, payload: dict) -> None:
"""Écrit/upsert une entrée de cache (échec non bloquant, warning seulement)."""
try:
await db.execute(
"INSERT INTO metadata_cache (cache_key, payload, fetched_at) "
"VALUES (?, ?, datetime('now')) "
"ON CONFLICT(cache_key) DO UPDATE SET "
"payload = excluded.payload, fetched_at = excluded.fetched_at",
(cache_key, json.dumps(payload, ensure_ascii=False)),
)
except (RuntimeError, sqlite3.Error) as exc:
logger.warning(
"Cache métadonnées non écrit (%s), continuation sans cache : %s", cache_key, exc
)
@staticmethod
def _pick_best_match(payload: dict, query: str) -> dict | None:
items = [item for item in payload.get("data", []) if item.get("type") == "anime"]
if not items:
return None
genre_names = {
obj.get("id"): obj.get("attributes", {}).get("name")
for obj in payload.get("included", [])
if obj.get("type") == "genres"
}
def titles_of(item: dict) -> set[str]:
attrs = item.get("attributes", {})
titles = attrs.get("titles") or {}
return {
t.strip().casefold()
for t in (attrs.get("canonicalTitle"), titles.get("en"), titles.get("en_jp"))
if t
}
def popularity(item: dict) -> tuple[int, float]:
attrs = item.get("attributes", {})
return (
attrs.get("userCount") or 0,
float(attrs.get("averageRating") or 0),
)
best = next((item for item in items if query.casefold() in titles_of(item)), None)
if best is None:
best = max(items, key=popularity)
genre_ids = best.get("relationships", {}).get("genres", {}).get("data") or []
genres = [genre_names[g["id"]] for g in genre_ids if g.get("id") in genre_names]
return {"id": best.get("id"), "attributes": best.get("attributes", {}), "genres": genres}
@staticmethod
def _to_rating_10(average_rating: str | None) -> float | None:
"""Kitsu note sur 100 (chaîne) → note sur 10 arrondie à 1 décimale."""
if average_rating is None:
return None
try:
return round(float(average_rating) / 10, 1)
except (TypeError, ValueError):
return None
@staticmethod
def _extract_year(start_date: str | None) -> int | None:
if not start_date:
return None
try:
return int(str(start_date)[:4])
except ValueError:
return None
+72
View File
@@ -0,0 +1,72 @@
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
import json
import logging
import secrets
from app.db import db
logger = logging.getLogger(__name__)
async def get_setting(key: str, default: object = None) -> object:
row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,))
if row is None:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
logger.warning("Paramètre %r corrompu, valeur par défaut utilisée", key)
return default
async def set_setting(key: str, value: object) -> None:
await db.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, json.dumps(value, ensure_ascii=False)),
)
async def is_source_enabled(name: str) -> bool:
return bool(await get_setting(f"source:{name}:enabled", True))
async def set_source_enabled(name: str, enabled: bool) -> None:
await set_setting(f"source:{name}:enabled", enabled)
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
# ---------------------------------------------------------------- intégrations *arr
TORZNAB_APIKEY_KEY = "torznab:apikey"
SONARR_URL_KEY = "sonarr:url"
SONARR_APIKEY_KEY = "sonarr:apikey"
async def get_torznab_apikey() -> str:
"""Clé API Torznab (générée au premier appel, persistée en DB)."""
key = await get_setting(TORZNAB_APIKEY_KEY)
if not isinstance(key, str) or not key:
key = secrets.token_hex(16)
await set_setting(TORZNAB_APIKEY_KEY, key)
logger.info("Clé API Torznab générée")
return key
async def reset_torznab_apikey() -> str:
key = secrets.token_hex(16)
await set_setting(TORZNAB_APIKEY_KEY, key)
logger.info("Clé API Torznab régénérée")
return key
async def get_sonarr_config() -> dict[str, str]:
return {
"url": await get_setting(SONARR_URL_KEY, ""),
"apikey": await get_setting(SONARR_APIKEY_KEY, ""),
}
async def set_sonarr_config(url: str, apikey: str) -> None:
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
logger.info("Configuration Sonarr enregistrée (%s)", url)
+152
View File
@@ -0,0 +1,152 @@
"""Client Sonarr (API v3) — personnalisation de « Pour toi ».
OhmStreaming lit ce qui est téléchargé/grabé sur Sonarr :
- `/api/v3/history` (événements grab/import) → titres récemment obtenus ;
- `/api/v3/series` → genres de chaque série (fournis par Sonarr lui-même).
Ces données alimentent le profil de genres des recommandations et la liste
d'exclusion (le déjà-possédé sur Sonarr n'est pas re-recommandé).
Toujours tolérant aux pannes : Sonarr absent/non configuré → profil vide,
la découverte continue de fonctionner avec l'historique local.
"""
import logging
import time
import httpx
from app.services.kitsu import normalize_title
from app.services.settings import get_sonarr_config
logger = logging.getLogger(__name__)
_HISTORY_PAGE_SIZE = 100
_MAX_TITLES = 30 # titres Sonarr récents analysés
_PROFILE_TTL = 900.0 # profil Sonarr re-quantifié au bout de 15 min
_REQUEST_TIMEOUT = 15.0
class SonarrService:
"""Profil de consommation Sonarr : titres téléchargés + genres associés."""
def __init__(self) -> None:
self._cache: tuple[float, tuple[set[str], dict[str, int]]] | None = None
async def _config(self) -> tuple[str, str]:
"""(url, apikey) — lu en DB à chaque usage (lecture SQLite négligeable)."""
config = await get_sonarr_config()
return config["url"], config["apikey"]
def invalidate(self) -> None:
"""Force le recalcul du profil au prochain appel (réglages modifiés)."""
self._cache = None
async def _client(self) -> httpx.AsyncClient | None:
url, apikey = await self._config()
if not url or not apikey:
return None
return httpx.AsyncClient(
base_url=url,
timeout=_REQUEST_TIMEOUT,
headers={"X-Api-Key": apikey},
)
async def _get(self, path: str, params: dict | None = None) -> dict | list | None:
client = await self._client()
if client is None:
return None
try:
async with client:
response = await client.get(path, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Sonarr %s KO : %s", path, exc)
return None
# ------------------------------------------------------------ profil
async def profile(self) -> tuple[set[str], dict[str, int]]:
"""(titres possédés normalisés, comptage de genres) — vide si non configuré."""
if self._cache is not None and self._cache[0] > time.monotonic():
return self._cache[1]
titles = await self.downloaded_titles()
genres_by_title = await self.series_genres()
owned: set[str] = set()
genre_counts: dict[str, int] = {}
for title in titles:
normalized = normalize_title(title).casefold()
if normalized:
owned.add(normalized)
for genre in genres_by_title.get(normalized, []):
genre_counts[genre] = genre_counts.get(genre, 0) + 1
result = (owned, genre_counts)
self._cache = (time.monotonic() + _PROFILE_TTL, result)
return result
async def downloaded_titles(self) -> list[str]:
"""Titres récemment grabés/importés sur Sonarr (les plus récents d'abord)."""
payload = await self._get(
"/api/v3/history",
{
"page": 1,
"pageSize": _HISTORY_PAGE_SIZE,
"eventType": 1, # grab ; les imports (3) suivent le même titre
"sortKey": "date",
"sortDir": "desc",
},
)
if not isinstance(payload, dict):
return []
titles: list[str] = []
seen: set[str] = set()
for record in payload.get("records", []):
title = record.get("series", {}).get("title")
if not title or title in seen:
continue
seen.add(title)
titles.append(title)
if len(titles) >= _MAX_TITLES:
break
return titles
async def series_genres(self) -> dict[str, list[str]]:
"""Genres par série, clé = titre normalisé (minuscule)."""
payload = await self._get("/api/v3/series")
if not isinstance(payload, list):
return {}
return {
normalize_title(s.get("title", "")).casefold(): [
g for g in s.get("genres", []) if isinstance(g, str)
]
for s in payload
if s.get("title")
}
# ------------------------------------------------------------ admin
async def test_connection(self) -> dict:
"""Diagnostic admin : version Sonarr, séries, grabs récents."""
url, apikey = await self._config()
if not url or not apikey:
return {"ok": False, "detail": "URL ou clé API manquante"}
payload = await self._get("/api/v3/system/status")
if payload is None:
return {"ok": False, "detail": "Connexion impossible — vérifiez URL et clé"}
series = await self._get("/api/v3/series")
titles = await self.downloaded_titles()
return {
"ok": True,
"detail": (
f"Sonarr {payload.get('version', '?')} — "
f"{len(series) if isinstance(series, list) else 0} séries, "
f"{len(titles)} saisies récentes"
),
}
sonarr = SonarrService()
+304
View File
@@ -0,0 +1,304 @@
"""Indexeur Torznab/Newznab : expose le catalogue OhmStreaming à Sonarr/Prowlarr.
OhmStreaming devient une source d'indexeur à part entière : Sonarr (ou Prowlarr,
qui relaiera vers Radarr/Lidarr…) interroge `/torznab/api` comme n'importe quel
indexer Jackett. Chaque « release » correspond à un épisode résolu depuis les
sources de scraping ; le grab (`/torznab/download`) déclenche l'extraction puis
l'ajout dans la file de téléchargements OhmStreaming — le fichier arrive donc
dans la bibliothèque locale, comme un téléchargement manuel.
Formats :
- `t=caps` → capacités du serveur (catégories TV/Anime, paramètres supportés)
- `t=tvsearch` → recherche par série + saison + épisode (Sonarr)
- `t=search` → recherche libre (Prowlarr, recherche manuelle)
"""
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from email.utils import format_datetime
from urllib.parse import urlencode
from app.scrapers.base import (
Episode,
ScrapeError,
SourceScraper,
VideoLink,
all_sources,
import_all_scrapers,
resolve_hoster,
)
from app.services.downloads import download_manager
from app.services.settings import is_source_enabled
logger = logging.getLogger(__name__)
import_all_scrapers()
TORZNAB_NS = "http://torznab.com/schemas/2015/feed"
CAT_TV = "5000"
CAT_ANIME = "5070"
_SIZE_ESTIMATE = 400 * 1024 * 1024 # estimation affichée (~400 Mo/épisode)
_MAX_SERIES_PER_SOURCE = 2 # fiches explorées par source lors d'une recherche
_MAX_RELEASES = 100 # borne du flux retourné
_SEARCH_TIMEOUT = 40.0 # scraping lent : garde-fou par source
_EPISODE_CACHE_TTL = 600.0 # listes d'épisodes re-scrapées au bout de 10 min
@dataclass
class Release:
"""Un épisode vu comme une release par Sonarr/Prowlarr."""
series: str
season: int
ep: int
source: str
source_id: str
episode_url: str
@property
def sonarr_title(self) -> str:
return f"{self.series} S{self.season:02d}E{self.ep:02d} VOSTFR WEB-DL"
def _bencode(value) -> bytes:
if isinstance(value, int):
return f"i{value}e".encode()
if isinstance(value, str):
raw = value.encode()
return f"{len(raw)}:".encode() + raw
if isinstance(value, bytes):
return f"{len(value)}:".encode() + value
if isinstance(value, dict):
return b"d" + b"".join(
_bencode(k) + _bencode(v) for k, v in sorted(value.items())
) + b"e"
if isinstance(value, list):
return b"l" + b"".join(_bencode(v) for v in value) + b"e"
raise TypeError(f"type non encodable en bencode : {type(value)!r}")
def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming)."""
return _bencode(
{
"announce": announce_url,
"created by": "OhmStreaming",
"comment": name,
"info": {
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
}
)
class TorznabService:
"""Recherche multi-sources mappée en releases Torznab + grab → file interne."""
def __init__(self) -> None:
self._episodes_cache: dict[tuple[str, str], tuple[float, list[Episode]]] = {}
# ------------------------------------------------------------ recherche
async def tvsearch(self, q: str, season: int | None = None, ep: int | None = None) -> list[Release]:
"""Recherche type Sonarr : série (+ saison/épisode optionnels)."""
releases: list[Release] = []
for source in await self._enabled_sources():
outcomes = await self._search_source(source, q)
for result in outcomes[:_MAX_SERIES_PER_SOURCE]:
try:
episodes = await self._episodes_of(source, result.source_id)
except (ScrapeError, TimeoutError):
continue
releases.extend(
self._releases_for(result.title, source, result.source_id, episodes, season, ep)
)
if len(releases) >= _MAX_RELEASES:
return releases[:_MAX_RELEASES]
return releases
async def search(self, q: str) -> list[Release]:
"""Recherche libre : tous les épisodes des fiches trouvées."""
return await self.tvsearch(q)
async def _search_source(self, source: SourceScraper, q: str):
try:
return await asyncio.wait_for(source.search(q), timeout=_SEARCH_TIMEOUT)
except (ScrapeError, TimeoutError) as exc:
logger.warning("Torznab : recherche %s KO pour %r : %s", source.name, q, exc)
return []
async def _enabled_sources(self) -> list[SourceScraper]:
return [s for s in all_sources() if await is_source_enabled(s.name)]
async def _episodes_of(self, source: SourceScraper, source_id: str) -> list[Episode]:
"""Liste d'épisodes d'une fiche, avec cache mémoire TTL."""
key = (source.name, source_id)
cached = self._episodes_cache.get(key)
if cached is not None and cached[0] > time.monotonic():
return cached[1]
episodes = await asyncio.wait_for(source.list_episodes(source_id), timeout=_SEARCH_TIMEOUT)
self._episodes_cache[key] = (time.monotonic() + _EPISODE_CACHE_TTL, episodes)
return episodes
def _releases_for(
self,
series: str,
source: SourceScraper,
source_id: str,
episodes: list[Episode],
season: int | None,
ep: int | None,
) -> list[Release]:
"""Filtre les épisodes selon saison/épisode demandés (entiers uniquement)."""
out = []
for item in episodes:
if item.number != int(item.number): # OAV 2.5 → ignorée (inparseable Sonarr)
continue
if season is not None and item.season != season:
continue
if ep is not None and int(item.number) != ep:
continue
out.append(
Release(
series=series,
season=item.season,
ep=int(item.number),
source=source.name,
source_id=source_id,
episode_url=item.url,
)
)
return out
# ------------------------------------------------------------ grab
async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict:
"""Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne.
Retourne le dict du téléchargement (existant si doublon actif).
Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu.
"""
from app.scrapers.base import get_source
scraper = get_source(source)
episodes = await self._episodes_of(scraper, source_id)
match = next(
(
e
for e in episodes
if e.season == season and e.number == int(ep)
),
None,
)
if match is None:
raise ScrapeError(f"Épisode S{season:02d}E{ep:02d} introuvable sur {source}")
link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}"
result = await download_manager.enqueue(link.url, match.url, title)
if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg"
logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
return result
async def _resolve_video(self, scraper: SourceScraper, episode_url: str) -> VideoLink:
"""Chaîne complète : page épisode → embeds → première URL directe valide."""
embeds = await asyncio.wait_for(
scraper.extract_embed_links(episode_url), timeout=_SEARCH_TIMEOUT
)
errors: list[str] = []
for embed in embeds:
extractor = resolve_hoster(embed)
if extractor is None:
errors.append(f"hébergeur non supporté : {embed}")
continue
try:
link = await asyncio.wait_for(extractor.extract(embed), timeout=_SEARCH_TIMEOUT)
if link and link.url:
return link
except (ScrapeError, TimeoutError) as exc:
errors.append(str(exc))
raise ScrapeError(f"Aucun hébergeur résolu pour {episode_url} ({'; '.join(errors[:3])})")
# ------------------------------------------------------------ XML
def download_url(self, base_url: str, apikey: str, release: Release) -> str:
query = urlencode(
{
"apikey": apikey,
"source": release.source,
"sid": release.source_id,
"season": release.season,
"ep": release.ep,
"series": release.series,
}
)
return f"{base_url}/torznab/download?{query}"
def caps_xml(self, base_url: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.0" title="OhmStreaming" url="{base_url}"
email="ohm@localhost" image="{base_url}/static/img/logo.png"/>
<searching>
<search available="yes" supportedParams="q"/>
<tv-search available="yes" supportedParams="q,season,ep"/>
<movie-search available="no" supportedParams=""/>
<audio-search available="no" supportedParams=""/>
</searching>
<categories>
<category id="{CAT_TV}" name="TV">
<subcat id="{CAT_ANIME}" name="Anime"/>
</category>
</categories>
</caps>"""
def results_xml(self, base_url: str, apikey: str, releases: list[Release]) -> str:
from xml.sax.saxutils import escape
items = []
for release in releases:
url = escape(self.download_url(base_url, apikey, release))
pubdate = escape(format_datetime(datetime.now(UTC)))
items.append(f""" <item>
<title>{escape(release.sonarr_title)}</title>
<guid isPermaLink="true">{url}</guid>
<link>{url}</link>
<comments>{escape(release.episode_url)}</comments>
<pubDate>{pubdate}</pubDate>
<category>{CAT_ANIME}</category>
<enclosure url="{url}" length="{_SIZE_ESTIMATE}" type="application/x-bittorrent"/>
<torznab:attr name="seeders" value="1"/>
<torznab:attr name="peers" value="1"/>
<torznab:attr name="downloadvolumefactor" value="0"/>
<torznab:attr name="uploadvolumefactor" value="0"/>
</item>""")
body = "\n".join(items)
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:torznab="{TORZNAB_NS}">
<channel>
<title>OhmStreaming</title>
<link>{escape(base_url)}</link>
<description>Indexeur OhmStreaming — animes VOSTFR scrapés en direct</description>
<language>fr-FR</language>
{body}
</channel>
</rss>"""
def error_xml(self, code: int, description: str) -> str:
from xml.sax.saxutils import escape
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<error code="{code}" description="{escape(description)}"/>'
)
torznab = TorznabService()
+654
View File
@@ -0,0 +1,654 @@
/* ============================================================
Ohm Stream — Design system « plateforme streaming »
Sombre immersif, accent rouge, typographie Outfit
============================================================ */
:root {
--bg: #0a0a0c;
--surface: #141416;
--surface-2: #1c1c20;
--border: #26262b;
--text: #f5f5f7;
--text-dim: #8e8e96;
--accent: #e50914;
--accent-2: #f5c518;
--success: #46d369;
--warning: #f5c518;
--danger: #ff5c5c;
--radius: 10px;
--radius-lg: 16px;
--shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
--font: "Outfit", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
min-height: 100vh;
line-height: 1.55;
}
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
button { font-family: inherit; }
/* ------------------------------------------------------------ topbar */
.topbar {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 1.8rem;
padding: 0.85rem 2.2rem;
background: linear-gradient(180deg, rgba(10, 10, 12, 0.96), rgba(10, 10, 12, 0.8));
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 800;
font-size: 1.25rem;
letter-spacing: 0.04em;
color: var(--accent);
white-space: nowrap;
}
.logo span { color: var(--text); font-weight: 400; }
.topnav { display: flex; gap: 0.25rem; }
.topnav a {
color: var(--text-dim);
font-size: 0.88rem;
font-weight: 500;
padding: 0.45rem 0.85rem;
border-radius: 99px;
transition: color 0.15s, background 0.15s;
}
.topnav a:hover { color: var(--text); }
.topnav a.active { color: var(--text); background: var(--surface-2); }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 0.8rem; }
.avatar {
width: 34px; height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 0.85rem; color: #fff;
text-transform: uppercase;
}
.icon-btn {
background: none; border: none; color: var(--text-dim);
font-size: 1.05rem; cursor: pointer; padding: 0.4rem;
border-radius: 8px; transition: color 0.15s, background 0.15s;
}
.icon-btn:hover { color: var(--text); background: var(--surface-2); }
/* ------------------------------------------------------------ layout */
.main { padding: 2rem clamp(1rem, 3.5vw, 4rem) 4rem; }
.page-title { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.3rem; letter-spacing: -0.01em; }
.page-sub { color: var(--text-dim); font-size: 0.92rem; margin-bottom: 1.6rem; }
/* ------------------------------------------------------------ recherche */
.search-bar { display: flex; gap: 0.7rem; margin-bottom: 1.8rem; max-width: 720px; }
.input, .search-bar input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 0.85rem 1.3rem;
border-radius: 99px;
font-size: 1rem;
font-family: inherit;
outline: none;
transition: border 0.15s, box-shadow 0.15s;
}
.input:focus, .search-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(229, 9, 20, 0.15);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--accent);
color: #fff;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 99px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.15); }
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border);
color: var(--text);
backdrop-filter: blur(6px);
}
.btn-ghost:hover { background: rgba(255, 255, 255, 0.16); filter: none; }
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
.btn-danger:hover { background: #4d1518; filter: none; }
/* ------------------------------------------------------------ grille de posters */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(135px, 12vw, 190px), 1fr));
gap: 1.1rem;
}
.card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
transition: transform 0.25s cubic-bezier(0.22, 0.68, 0.35, 1), box-shadow 0.25s;
cursor: pointer;
}
.card:hover { transform: scale(1.04); box-shadow: var(--shadow); z-index: 2; }
.card-poster {
aspect-ratio: 2 / 3;
width: 100%;
object-fit: cover;
background: var(--surface-2);
transition: transform 0.4s ease;
}
.card:hover .card-poster { transform: scale(1.07); }
.card-overlay {
position: absolute;
inset: auto 0 0;
padding: 2rem 0.75rem 0.65rem;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.92), transparent);
}
.card-title {
font-size: 0.84rem;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.35rem;
}
.card-chip {
position: absolute;
top: 0.5rem; left: 0.5rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18rem 0.5rem;
border-radius: 5px;
color: var(--accent-2);
}
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 5px;
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-source { background: rgba(229, 9, 20, 0.18); color: #ff6b73; }
.badge-type { background: rgba(245, 197, 24, 0.14); color: var(--accent-2); }
/* ------------------------------------------------------------ fiche titre */
.title-hero {
position: relative;
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 2rem;
background: var(--surface);
min-height: 380px;
display: flex;
align-items: flex-end;
}
.title-banner {
position: absolute; inset: 0;
background-size: cover; background-position: center 25%;
}
.title-banner::after {
content: "";
position: absolute; inset: 0;
background:
linear-gradient(90deg, rgba(10, 10, 12, 0.94) 15%, rgba(10, 10, 12, 0.4) 60%, transparent),
linear-gradient(0deg, var(--bg) 0%, transparent 45%);
}
.title-hero-inner {
position: relative;
z-index: 1;
display: flex;
gap: 2rem;
padding: 2.2rem;
width: 100%;
align-items: flex-end;
}
.title-poster {
width: 180px;
border-radius: var(--radius);
box-shadow: var(--shadow);
flex-shrink: 0;
}
.title-info h1 { font-size: 2.4rem; font-weight: 800; letter-spacing: -0.02em; margin-bottom: 0.5rem; }
.title-tags { display: flex; flex-wrap: wrap; gap: 0.45rem; margin: 0.7rem 0; }
.title-synopsis {
color: #cfcfd6;
font-size: 0.95rem;
max-width: 640px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title-stats { display: flex; gap: 1.3rem; margin-top: 0.8rem; font-size: 0.88rem; color: var(--text-dim); }
.title-stats strong { color: var(--text); }
.title-actions { display: flex; gap: 0.7rem; margin-top: 1.2rem; flex-wrap: wrap; }
.rating-star { color: var(--accent-2); }
/* ------------------------------------------------------------ épisodes */
.section-title { font-size: 1.15rem; font-weight: 700; margin: 2rem 0 1rem; }
.episode-list { display: flex; flex-direction: column; gap: 0.55rem; }
.episode-row {
display: flex;
align-items: center;
gap: 1rem;
background: var(--surface);
border: 1px solid transparent;
border-radius: var(--radius);
padding: 0.65rem 1rem;
transition: background 0.15s, border 0.15s;
}
.episode-row:hover { background: var(--surface-2); border-color: var(--border); }
.episode-num {
min-width: 3rem;
font-weight: 800;
color: var(--accent-2);
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
}
.episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; }
/* ------------------------------------------------------------ téléchargements */
.progress {
height: 6px;
background: var(--surface-2);
border-radius: 99px;
overflow: hidden;
flex: 1;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 99px;
transition: width 0.5s ease;
}
.progress-fill.done { background: var(--success); }
.progress-fill.failed, .progress-fill.cancelled { background: var(--danger); }
.progress-fill.paused, .progress-fill.pending { background: var(--warning); }
.dl-row {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.dl-head { display: flex; align-items: center; gap: 0.9rem; }
.dl-title { flex: 1; font-weight: 600; font-size: 0.95rem; }
.dl-stats { display: flex; gap: 1.2rem; font-size: 0.8rem; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.dl-actions { display: flex; gap: 0.45rem; }
.status-pill { font-size: 0.7rem; font-weight: 700; padding: 0.2rem 0.65rem; border-radius: 99px; text-transform: uppercase; letter-spacing: 0.03em; }
.status-pending, .status-paused { background: rgba(245, 197, 24, 0.14); color: var(--warning); }
.status-downloading { background: rgba(229, 9, 20, 0.16); color: #ff6b73; }
.status-done { background: rgba(70, 211, 105, 0.14); color: var(--success); }
.status-failed, .status-cancelled { background: rgba(255, 92, 92, 0.14); color: var(--danger); }
/* ------------------------------------------------------------ lecteur */
.player-wrap {
background: #000;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
margin-bottom: 1.2rem;
}
.player-wrap video { width: 100%; max-height: 74vh; }
/* ------------------------------------------------------------ admin / tables */
.table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.table th, .table td { padding: 0.7rem 0.9rem; text-align: left; border-bottom: 1px solid var(--border); }
.table th { color: var(--text-dim); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.06em; }
.table tr:last-child td { border-bottom: none; }
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.4rem 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 1.1rem; }
.toggle {
position: relative;
width: 44px; height: 24px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 99px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--text-dim);
transition: transform 0.2s, background 0.2s;
}
.toggle.on { background: var(--accent); border-color: var(--accent); }
.toggle.on::after { transform: translateX(20px); background: #fff; }
/* ------------------------------------------------------------ divers */
.spinner {
width: 36px; height: 36px;
border: 3px solid var(--surface-2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 3rem auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty-state { text-align: center; color: var(--text-dim); padding: 4rem 1rem; font-size: 0.95rem; }
.empty-state .big { font-size: 2.6rem; margin-bottom: 0.7rem; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: block; }
.toast {
position: fixed;
bottom: 1.5rem; right: 1.5rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
color: var(--text);
padding: 0.8rem 1.2rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
font-size: 0.88rem;
z-index: 100;
animation: toast-in 0.25s ease;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
/* ------------------------------------------------------------ login */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background:
radial-gradient(ellipse 55% 45% at 50% 0%, rgba(229, 9, 20, 0.13), transparent),
var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.5rem;
width: 400px;
box-shadow: var(--shadow);
}
.login-card .logo { display: block; text-align: center; font-size: 1.5rem; margin-bottom: 0.3rem; }
.login-sub { text-align: center; color: var(--text-dim); font-size: 0.88rem; margin-bottom: 1.8rem; }
.login-card form { display: flex; flex-direction: column; gap: 0.85rem; }
.login-card .input { border-radius: var(--radius); }
.login-card .btn { border-radius: var(--radius); justify-content: center; }
.login-tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; background: var(--surface-2); padding: 0.3rem; border-radius: 99px; }
.login-tab {
flex: 1;
text-align: center;
padding: 0.5rem;
border-radius: 99px;
cursor: pointer;
color: var(--text-dim);
font-size: 0.88rem;
font-weight: 600;
transition: all 0.15s;
}
.login-tab.active { background: var(--accent); color: #fff; }
.login-error {
border: 1px solid rgba(255, 92, 92, 0.4);
color: var(--danger);
border-radius: var(--radius);
padding: 0.6rem 0.9rem;
font-size: 0.85rem;
}
.card-year {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
font-size: 0.68rem;
font-weight: 700;
padding: 0.18rem 0.45rem;
border-radius: 5px;
color: var(--text-dim);
}
.card-airing {
position: absolute;
bottom: 3.4rem;
left: 0.5rem;
background: rgba(46, 160, 67, 0.85);
color: #fff;
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.06em;
padding: 0.16rem 0.45rem;
border-radius: 5px;
}
[x-cloak] { display: none !important; }
/* ---------------------------------------------------------- découverte (rails) */
.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; }
.rail-title {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.8rem;
display: flex;
align-items: baseline;
gap: 0.6rem;
flex-wrap: wrap;
}
.rail-source {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
}
.rail-wrap { position: relative; }
.rail {
--visible: 2.4;
--rail-gap: 1.1rem;
display: flex;
gap: var(--rail-gap);
overflow-x: auto;
padding: 0.3rem 0.2rem 0.8rem;
scroll-snap-type: x proximity;
scroll-behavior: smooth;
}
.rail::-webkit-scrollbar { height: 8px; }
.rail::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 4px; }
.rail-card {
flex: 1 0 calc((100% - (var(--visible) - 1) * var(--rail-gap)) / var(--visible));
max-width: 300px;
scroll-snap-align: start;
animation: card-in 0.55s cubic-bezier(0.22, 0.68, 0.35, 1) backwards;
animation-delay: calc(var(--i, 0) * 45ms);
}
.rail-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 3;
width: 42px;
height: 42px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(20, 20, 22, 0.85);
backdrop-filter: blur(6px);
color: var(--text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.rail-prev { left: -10px; }
.rail-next { right: -10px; }
.rail-wrap:hover .rail-nav { opacity: 1; }
.rail-nav:hover { background: var(--accent); transform: translateY(-50%) scale(1.1); }
@media (hover: none) { .rail-nav { display: none; } }
/* ------------------------------------------------------------ skeletons */
.skel {
background: linear-gradient(100deg, var(--surface) 40%, var(--surface-2) 50%, var(--surface) 60%);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
}
.skel-title { height: 1.2rem; width: 220px; border-radius: 6px; margin-bottom: 0.8rem; }
.skel-card { aspect-ratio: 2 / 3; border-radius: var(--radius); }
.rail-skel { overflow: hidden; }
/* ------------------------------------------------------------ animations */
@keyframes card-in { from { opacity: 0; transform: translateY(16px) scale(0.96); } }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } }
@keyframes shimmer { to { background-position: -200% 0; } }
.page-title { animation: rise 0.4s ease backwards; }
.page-sub { animation: rise 0.4s 0.08s ease backwards; }
.grid .card {
animation: card-in 0.5s cubic-bezier(0.22, 0.68, 0.35, 1) backwards;
animation-delay: calc(var(--i, 0) * 40ms);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ------------------------------------------------------------ responsive */
@media (min-width: 720px) { .rail { --visible: 3.4; } }
@media (min-width: 1000px) { .rail { --visible: 4; } }
@media (min-width: 1200px) { .rail { --visible: 5; } }
@media (min-width: 1450px) { .rail { --visible: 6; } }
@media (min-width: 1700px) { .rail { --visible: 7; } }
@media (min-width: 1920px) { .rail { --visible: 8; } }
@media (min-width: 2150px) { .rail { --visible: 9; } }
@media (min-width: 2400px) { .rail { --visible: 10; } }
@media (min-width: 2650px) { .rail { --visible: 12; } }
@media (min-width: 3000px) { .rail { --visible: 13; } }
@media (min-width: 3400px) { .rail { --visible: 14; } }
@media (min-width: 3800px) { .rail { --visible: 15; } }
@media (min-width: 1800px) {
.page-title { font-size: 1.8rem; }
.rail-title { font-size: 1.3rem; }
}
@media (max-width: 860px) {
.topbar { padding: 0.6rem 1rem 0; gap: 0.8rem; flex-wrap: wrap; }
.topbar-right { margin-left: auto; }
.topnav {
order: 3;
width: 100%;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.5rem;
}
.topnav::-webkit-scrollbar { display: none; }
.topnav a { padding: 0.4rem 0.7rem; font-size: 0.82rem; white-space: nowrap; }
.main { padding: 1.2rem 1rem 3rem; }
.title-hero { min-height: 0; }
.title-hero-inner { flex-direction: column; align-items: flex-start; padding: 1.4rem; }
.title-info h1 { font-size: 1.8rem; }
.title-poster { width: 130px; }
}
@media (max-width: 640px) {
.main { padding: 1rem 0.9rem 3rem; }
.page-title { font-size: 1.35rem; }
.page-sub { font-size: 0.86rem; margin-bottom: 1.2rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(105px, 1fr)); gap: 0.7rem; }
.rail { --rail-gap: 0.7rem; }
.rail-section { margin-bottom: 1.8rem; }
.search-bar { flex-direction: column; }
.search-bar .btn { justify-content: center; }
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="300" viewBox="0 0 200 300">
<rect width="200" height="300" fill="#222738"/>
<text x="100" y="145" font-size="42" text-anchor="middle" fill="#4a5170">⛩</text>
<text x="100" y="180" font-size="13" text-anchor="middle" fill="#4a5170" font-family="sans-serif">Pas d'image</text>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+12
View File
@@ -0,0 +1,12 @@
// Petit toast global utilisé par les pages
function toast(message) {
const el = document.createElement('div');
el.className = 'toast';
el.textContent = message;
document.body.appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transition = 'opacity 0.4s';
setTimeout(() => el.remove(), 400);
}, 2800);
}
+199
View File
@@ -0,0 +1,199 @@
{% extends "base.html" %}
{% set active = 'admin' %}
{% block title %}Administration — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Administration</h1>
<p class="page-sub">Utilisateurs, sources et santé du système.</p>
<div x-data="adminPage()" x-init="load()">
<div class="panel">
<h2>📊 Statistiques</h2>
<div class="title-stats" style="margin-top:0">
<span>Utilisateurs : <strong x-text="stats.users ?? '—'"></strong></span>
<template x-for="(n, st) in stats.downloads || {}" :key="st">
<span><span class="status-pill" :class="'status-' + st" x-text="st"></span> <strong x-text="n"></strong></span>
</template>
</div>
</div>
<div class="panel">
<h2>🌐 Sources de scraping</h2>
<table class="table">
<thead><tr><th>Source</th><th>URL</th><th>Activée</th><th>Santé</th><th></th></tr></thead>
<tbody>
<template x-for="s in sources" :key="s.name">
<tr>
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
<td>
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
</td>
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="panel">
<h2>🔗 Intégrations Sonarr / Prowlarr</h2>
<h3 style="margin:1rem 0 0.4rem;font-size:0.95rem">OhmStreaming comme indexeur (Torznab)</h3>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé —
les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
</p>
<table class="table">
<tbody>
<tr>
<td style="width:110px"><strong>URL</strong></td>
<td><code x-text="integrations.torznab.endpoint"></code></td>
<td style="width:90px"><button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.endpoint)">Copier</button></td>
</tr>
<tr>
<td><strong>Clé API</strong></td>
<td><code x-text="integrations.torznab.apikey"></code></td>
<td style="display:flex;gap:0.4rem">
<button class="btn btn-sm btn-ghost" @click="copy(integrations.torznab.apikey)">Copier</button>
<button class="btn btn-sm btn-ghost" @click="regenerateKey()" :disabled="integrations._regen">Régénérer</button>
</td>
</tr>
</tbody>
</table>
<h3 style="margin:1.4rem 0 0.4rem;font-size:0.95rem">Sonarr → « Pour toi »</h3>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
Les genres des séries téléchargées sur Sonarr personnalisent la section
« Pour toi » (ce qui y est possédé n'est pas re-recommandé).
</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
<input class="input" x-model="integrations.sonarr.url" placeholder="http://sonarr:8989"
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
<input class="input" x-model="integrations.sonarr.apikey" placeholder="Clé API Sonarr"
style="flex:1;min-width:200px" @change="integrations.sonarr._dirty = true">
<button class="btn btn-sm" @click="saveSonarr()" :disabled="!integrations.sonarr._dirty">Enregistrer</button>
<button class="btn btn-sm btn-ghost" @click="testSonarr()" :disabled="integrations._testing"
x-text="integrations._testing ? '…' : 'Tester'"></button>
</div>
<p x-show="integrations.testResult" style="margin:0.5rem 0 0;font-size:0.85rem"
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="integrations.testResult?.detail"></p>
</div>
<div class="panel">
<h2>👥 Utilisateurs</h2>
<table class="table">
<thead><tr><th>ID</th><th>Nom</th><th>Rôle</th><th>Actif</th><th>Créé le</th><th></th></tr></thead>
<tbody>
<template x-for="u in users" :key="u.id">
<tr>
<td x-text="u.id"></td>
<td><strong x-text="u.username"></strong></td>
<td>
<span class="badge" :class="u.is_admin ? 'badge-source' : 'badge-type'"
x-text="u.is_admin ? 'admin' : 'utilisateur'"></span>
</td>
<td><div class="toggle" :class="u.is_active && 'on'" @click="post(`/api/admin/users/${u.id}/toggle-active`)"></div></td>
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="u.created_at?.slice(0, 10)"></td>
<td style="display:flex;gap:0.4rem">
<button class="btn btn-sm btn-ghost" @click="post(`/api/admin/users/${u.id}/toggle-admin`)" x-text="u.is_admin ? 'Rétrograder' : 'Promouvoir'"></button>
<button class="btn btn-sm btn-danger" @click="del(u)">Supprimer</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="forbidden">
<div class="login-error">Cette page est réservée aux administrateurs.</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script>
function adminPage() {
return {
users: [], sources: [], stats: {}, forbidden: false,
integrations: {
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
_regen: false, _testing: false, testResult: null,
},
async load() {
const [u, s, st] = await Promise.all([
fetch('/api/admin/users'), fetch('/api/admin/sources'), fetch('/api/admin/stats'),
]);
if (u.status === 403) { this.forbidden = true; return; }
this.users = await u.json();
this.sources = await s.json();
this.stats = await st.json();
const itg = await fetch('/api/admin/integrations');
if (itg.ok) {
const data = await itg.json();
this.integrations.torznab = data.torznab;
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
}
},
copy(value) {
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
},
async regenerateKey() {
if (!confirm('Régénérer la clé API Torznab ? Les indexeurs configurés devront être mis à jour.')) return;
this.integrations._regen = true;
const res = await fetch('/api/admin/integrations/torznab/regenerate', { method: 'POST' });
if (res.ok) { this.integrations.torznab.apikey = (await res.json()).apikey; toast('✔ Nouvelle clé générée'); }
this.integrations._regen = false;
},
async saveSonarr() {
const res = await fetch('/api/admin/integrations/sonarr', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.integrations.sonarr.url, apikey: this.integrations.sonarr.apikey }),
});
if (res.ok) { this.integrations.sonarr._dirty = false; toast('✔ Configuration Sonarr enregistrée'); }
},
async toggle(s) {
const res = await fetch(`/api/admin/sources/${s.name}/toggle`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !s.enabled }),
});
if (res.ok) s.enabled = !s.enabled;
},
async healthCheck(s) {
s._checking = true;
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
const data = await res.json();
s.health = data.healthy; s.healthDetail = data.detail;
s._checking = false;
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
},
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
async testSonarr() {
this.integrations._testing = true;
const res = await fetch('/api/admin/integrations/sonarr/test', { method: 'POST' });
this.integrations.testResult = res.ok ? await res.json() : { ok: false, detail: 'Erreur serveur' };
this.integrations._testing = false;
},
async del(u) {
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
await this.load();
},
};
}
</script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Ohm Stream{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body>
<header class="topbar" x-data="{ username: '' }" x-init="fetch('/auth/me').then(r => r.json()).then(u => username = u.username).catch(() => {})">
<a class="logo" href="/">OHM<span>STREAM</span></a>
<nav class="topnav">
<a class="{{ 'active' if active == 'discover' }}" href="/discover">Découvrir</a>
<a class="{{ 'active' if active == 'search' }}" href="/">Recherche</a>
<a class="{{ 'active' if active == 'downloads' }}" href="/downloads">Téléchargements</a>
<a class="{{ 'active' if active == 'library' }}" href="/library">Bibliothèque</a>
<a class="{{ 'active' if active == 'favorites' }}" href="/favorites">Favoris</a>
<a class="{{ 'active' if active == 'admin' }}" href="/admin">Admin</a>
</nav>
<div class="topbar-right">
<div class="avatar" x-text="username ? username[0] : '·'" :title="username"></div>
<form method="post" action="/auth/logout" style="display:inline">
<button class="icon-btn" type="submit" title="Déconnexion">⏻</button>
</form>
</div>
</header>
<main class="main">
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% set active = 'discover' %}
{% block title %}Découvrir — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Découvrir</h1>
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<div x-data="discoverPage()" x-init="load()" x-cloak>
<!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }">
<div class="skel skel-title"></div>
<div class="rail rail-skel">
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
</div>
</section>
<section class="rail-section" x-data="{ n: 8 }">
<div class="skel skel-title"></div>
<div class="rail rail-skel">
<template x-for="i in n"><div class="rail-card skel skel-card"></div></template>
</div>
</section>
</div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<!-- ------------------------------------------------ Nouveautés -->
<section class="rail-section" x-show="!loading && latest.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latest" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="item.label"></span>
<span class="card-year" x-text="item.start_date ? item.start_date.slice(0, 4) : ''"></span>
<span class="card-airing" x-show="item.status === 'current'" title="En cours de diffusion">● EN COURS</span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
</div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Incontournables -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0">
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in mustWatch" :key="'mw' + item.kitsu_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :title="`Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta"><span class="badge badge-type" x-text="item.year || ''"></span></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Pour toi -->
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
<h2 class="rail-title">✨ Pour toi
<span class="rail-source" x-show="forYou.based_on.length"
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
</h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in forYou.items" :key="'fy' + item.kitsu_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/?q=${encodeURIComponent(item.title)}`" :title="`Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta"><span class="badge badge-type" x-text="item.year || ''"></span></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script>
function discoverPage() {
return {
latest: [], mustWatch: [], forYou: { based_on: [], items: [] },
loading: true, error: null,
scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
},
async load() {
this.loading = true; this.error = null;
try {
const res = await fetch('/api/discover');
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.latest = data.latest;
this.mustWatch = data.must_watch;
this.forYou = data.for_you;
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
},
};
}
</script>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% set active = 'downloads' %}
{% block title %}Téléchargements — Ohm Stream{% endblock %}
{% block content %}
<div x-data="downloadsPage()" x-init="connect()">
<div style="display:flex;align-items:center;gap:0.8rem;margin-bottom:1.6rem">
<div>
<h1 class="page-title">Téléchargements</h1>
<p class="page-sub" style="margin-bottom:0">
<span x-text="items.length"></span> tâche(s) —
<span :style="connected ? 'color:var(--success)' : 'color:var(--danger)'"
x-text="connected ? '● temps réel' : '○ reconnecté…'"></span>
</p>
</div>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" @click="action('clear-finished')">🧹 Nettoyer</button>
<button class="btn btn-danger btn-sm" @click="action('cancel-all')">✖ Tout annuler</button>
</div>
<template x-if="items.length === 0">
<div class="empty-state"><div class="big">📭</div>Aucun téléchargement. Lance une recherche !</div>
</template>
<div class="episode-list">
<template x-for="d in items" :key="d.id">
<div class="dl-row">
<div class="dl-head">
<span class="status-pill" :class="'status-' + d.status" x-text="d.status_label"></span>
<span class="dl-title" x-text="d.title"></span>
<div class="dl-actions">
<template x-if="d.status === 'downloading'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/pause')">⏸</button>
</template>
<template x-if="d.status === 'paused'">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/resume')">▶</button>
</template>
<template x-if="['failed', 'cancelled'].includes(d.status)">
<button class="btn btn-sm btn-ghost" @click="action(d.id + '/retry')">↻</button>
</template>
<template x-if="['pending', 'downloading', 'paused'].includes(d.status)">
<button class="btn btn-sm btn-danger" @click="action(d.id + '/cancel')">✖</button>
</template>
<template x-if="d.status === 'done'">
<a class="btn btn-sm" :href="'/watch/' + d.id">▶ Regarder</a>
</template>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.9rem">
<div class="progress">
<div class="progress-fill" :class="d.status"
:style="`width:${d.percent ?? (d.status === 'done' ? 100 : 0)}%`"></div>
</div>
<span style="min-width:4rem;text-align:right;font-size:0.85rem;font-weight:600"
x-text="d.percent != null ? d.percent + '%' : (d.status === 'done' ? '100%' : '—')"></span>
</div>
<div class="dl-stats">
<span x-text="fmtBytes(d.downloaded_bytes) + ' / ' + (d.total_bytes ? fmtBytes(d.total_bytes) : '?')"></span>
<span x-show="d.speed_bps" x-text="'⚡ ' + fmtBytes(d.speed_bps) + '/s'"></span>
<span x-show="d.eta_seconds != null" x-text="'⏱ ' + fmtEta(d.eta_seconds)"></span>
<span x-show="d.error" style="color:var(--danger)" x-text="d.error"></span>
</div>
</div>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function downloadsPage() {
return {
items: [], connected: false, es: null,
connect() {
this.es = new EventSource('/api/downloads/events');
this.es.onopen = () => this.connected = true;
this.es.onerror = () => this.connected = false;
this.es.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'snapshot') this.items = msg.items;
else if (msg.type === 'update') this.upsert(msg.item);
};
},
upsert(item) {
const i = this.items.findIndex(d => d.id === item.id);
if (i >= 0) this.items[i] = item;
else this.items.unshift(item);
this.sort();
},
sort() {
const order = { downloading: 0, pending: 1, paused: 2 };
this.items.sort((a, b) => (order[a.status] ?? 3) - (order[b.status] ?? 3));
},
async action(path) {
await fetch('/api/downloads/' + path, { method: 'POST' });
if (path === 'clear-finished') {
const res = await fetch('/api/downloads');
this.items = await res.json();
}
},
fmtBytes(n) {
if (n == null) return '?';
const units = ['o', 'Ko', 'Mo', 'Go'];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
},
fmtEta(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'min';
return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0');
},
};
}
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% set active = 'favorites' %}
{% block title %}Favoris — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Favoris</h1>
<p class="page-sub" x-data x-text="$store.fav?.total != null ? $store.fav.total + ' titre(s) suivi(s)' : ''"></p>
<div x-data="favoritesPage()" x-init="load()">
<div class="spinner" x-show="loading"></div>
<template x-if="!loading && items.length === 0">
<div class="empty-state"><div class="big">⭐</div>Aucun favori — ajoute-en depuis une fiche de titre.</div>
</template>
<div class="grid">
<template x-for="f in items" :key="f.id">
<div class="card">
<a :href="`/title/${f.source}/${encodeURIComponent(f.source_id)}`">
<img class="card-poster" :src="f.image_url || '/static/img/placeholder.svg'" loading="lazy"
onerror="this.src='/static/img/placeholder.svg'">
</a>
<span class="card-chip" x-text="f.source"></span>
<button class="btn btn-sm btn-danger" style="position:absolute;top:0.5rem;right:0.5rem"
@click.prevent="remove(f)">✖</button>
<div class="card-overlay">
<div class="card-title" x-text="f.title"></div>
</div>
</div>
</template>
</div>
<div style="display:flex;gap:0.6rem;justify-content:center;margin-top:1.6rem" x-show="total > limit">
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="page(-1)">← Précédent</button>
<button class="btn btn-ghost btn-sm" :disabled="offset + limit >= total" @click="page(1)">Suivant →</button>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function favoritesPage() {
return {
items: [], total: 0, offset: 0, limit: 24, loading: true,
async load() {
this.loading = true;
const res = await fetch(`/api/favorites?offset=${this.offset}&limit=${this.limit}`);
const data = await res.json();
this.items = data.items; this.total = data.total;
Alpine.store('fav', { total: data.total });
this.loading = false;
},
async page(dir) { this.offset = Math.max(0, this.offset + dir * this.limit); await this.load(); },
async remove(f) {
await fetch('/api/favorites/' + f.id, { method: 'DELETE' });
await this.load();
toast('Favori retiré');
},
};
}
</script>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% set active = 'search' %}
{% block title %}Recherche — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Recherche unifiée</h1>
<p class="page-sub">Une seule requête interroge toutes tes sources activées.</p>
<div x-data="searchPage()">
<form class="search-bar" @submit.prevent="run">
<input type="search" x-model="query" placeholder="Naruto, One Piece, Frieren…" autofocus>
<button class="btn" type="submit" :disabled="loading">Rechercher</button>
</form>
<div class="spinner" x-show="loading"></div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<template x-if="!loading && searched && results.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Aucun résultat pour cette recherche.</div>
</template>
<div class="grid" x-show="results.length > 0">
<template x-for="r in results" :key="r.source + r.source_id">
<a class="card" :href="`/title/${r.source}/${encodeURIComponent(r.source_id)}`">
<img class="card-poster" :src="r.image_url || '/static/img/placeholder.svg'" :alt="r.title" loading="lazy"
onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="r.source"></span>
<div class="card-overlay">
<div class="card-title" x-text="r.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-text="r.media_type"></span>
</div>
</div>
</a>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function searchPage() {
return {
query: new URLSearchParams(location.search).get('q') || '',
results: [], loading: false, searched: false, error: null,
init() { if (this.query) this.run(); },
async run() {
if (this.query.trim().length < 2) return;
this.loading = true; this.error = null; this.searched = false;
history.replaceState(null, '', '?q=' + encodeURIComponent(this.query));
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(this.query.trim()));
if (!res.ok) throw new Error('Erreur ' + res.status);
const data = await res.json();
this.results = data.results;
if (data.failed_sources.length) this.error = 'Sources en échec : ' + data.failed_sources.join(', ');
} catch (e) { this.error = e.message; }
finally { this.loading = false; this.searched = true; }
}
};
}
</script>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% set active = 'library' %}
{% block title %}Bibliothèque — Ohm Stream{% endblock %}
{% block content %}
<h1 class="page-title">Bibliothèque</h1>
<p class="page-sub">Tes fichiers téléchargés, prêts à être regardés.</p>
<div x-data="libraryPage()" x-init="load()">
<div class="spinner" x-show="loading"></div>
<template x-if="!loading && items.length === 0">
<div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div>
</template>
<div class="episode-list">
<template x-for="f in items" :key="f.id">
<a class="episode-row" :href="'/watch/' + f.id">
<span style="font-size:1.3rem">🎬</span>
<span class="episode-name" style="font-weight:600" x-text="f.title"></span>
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
<span x-show="f.position_seconds > 0" class="badge badge-type"
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
<span class="btn btn-sm">▶</span>
</a>
</template>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function libraryPage() {
return {
items: [], loading: true,
async load() {
const res = await fetch('/api/library');
this.items = await res.json();
this.loading = false;
},
fmtBytes(n) {
if (n == null) return '';
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + ' ' + units[i];
},
fmtTime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(sec).padStart(2, '0');
},
};
}
</script>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connexion — Ohm Stream</title>
<link rel="stylesheet" href="/static/css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body class="login-page">
<div class="login-card" x-data="{ mode: 'login', error: null }"
x-init="
window.addEventListener('htmx:responseError', e => {
error = e.detail.xhr.status === 401 ? 'Identifiants invalides'
: e.detail.xhr.status === 409 ? 'Nom d\'utilisateur déjà pris'
: 'Erreur ' + e.detail.xhr.status;
})">
<span class="logo">OHM<span style="color:var(--text);font-weight:400">STREAM</span></span>
<p class="login-sub">Ton centre de contrôle anime &amp; séries</p>
<div class="login-tabs">
<div class="login-tab" :class="mode === 'login' && 'active'" @click="mode = 'login'; error = null">Connexion</div>
<div class="login-tab" :class="mode === 'register' && 'active'" @click="mode = 'register'; error = null">Inscription</div>
</div>
<div class="login-error" x-show="error" x-text="error" style="margin-bottom:0.8rem"></div>
<form x-show="mode === 'login'" hx-post="/auth/login" hx-swap="none">
<input class="input" name="username" placeholder="Nom d'utilisateur" required autocomplete="username">
<input class="input" name="password" type="password" placeholder="Mot de passe" required autocomplete="current-password">
<button class="btn" type="submit">Se connecter</button>
</form>
<form x-show="mode === 'register'" hx-post="/auth/register" hx-swap="none">
<input class="input" name="username" placeholder="Nom d'utilisateur (3+ caractères)" required minlength="3" autocomplete="username">
<input class="input" name="password" type="password" placeholder="Mot de passe (6+ caractères)" required minlength="6" autocomplete="new-password">
<button class="btn" type="submit">Créer mon compte</button>
<p style="font-size:0.75rem;color:var(--text-dim);text-align:center">Le premier compte créé est administrateur.</p>
</form>
</div>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
// Redirection après login/register réussi (303 intercepté par htmx)
document.body.addEventListener('htmx:afterRequest', e => {
if (e.detail.successful && (e.detail.pathInfo.requestPath.startsWith('/auth/login') || e.detail.pathInfo.requestPath.startsWith('/auth/register'))) {
window.location.href = '/';
}
});
</script>
</body>
</html>
+176
View File
@@ -0,0 +1,176 @@
{% extends "base.html" %}
{% set active = 'search' %}
{% block title %}Fiche — Ohm Stream{% endblock %}
{% block content %}
<div x-data="titlePage()" data-source="{{ source }}" data-source-id="{{ source_id }}"
x-init="load($el.dataset.source, $el.dataset.sourceId)">
<div class="spinner" x-show="loading"></div>
<template x-if="error">
<div class="login-error" x-text="error"></div>
</template>
<template x-if="details">
<div>
<div class="title-hero">
<div class="title-banner" :style="details.banner_url || details.image_url ? `background-image:url('${details.banner_url || details.image_url}')` : ''"></div>
<div class="title-hero-inner">
<img class="title-poster" :src="details.image_url || '/static/img/placeholder.svg'"
onerror="this.src='/static/img/placeholder.svg'">
<div class="title-info">
<h1 x-text="details.title"></h1>
<div class="title-tags">
<span class="badge badge-source" x-text="details.source"></span>
<template x-for="g in details.genres.slice(0, 6)"><span class="badge badge-type" x-text="g"></span></template>
</div>
<p class="title-synopsis" x-text="details.synopsis || 'Pas de synopsis disponible.'"></p>
<div class="title-stats">
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
</div>
<div class="title-actions">
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
<span x-show="!seasonJob.running">⬇ Télécharger la saison</span>
<span x-show="seasonJob.running" x-text="`⏳ ${seasonJob.done}/${seasonJob.total} ajoutés…`"></span>
</button>
<button class="btn btn-ghost" @click="toggleFavorite">
<span x-text="favorite ? '★ Dans les favoris' : '☆ Ajouter aux favoris'"></span>
</button>
</div>
</div>
</div>
</div>
<h2 class="section-title">Épisodes</h2>
<div class="episode-list">
<template x-for="ep in details.episodes" :key="ep.number">
<div class="episode-row">
<span class="episode-num" x-text="'E' + ep.number"></span>
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
</div>
</template>
</div>
<!-- Lecteur modal -->
<div x-show="player.active" style="position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:0.8rem"
@keydown.escape.window="closePlayer()" x-transition x-cloak>
<video x-ref="player" controls autoplay style="max-width:90vw;max-height:80vh;border-radius:8px"></video>
<button class="btn btn-ghost" @click="closePlayer()">Fermer ✕</button>
</div>
</div>
</template>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
<script>
function titlePage() {
return {
source: null, sourceId: null,
details: null, loading: true, error: null, favorite: false,
player: { active: false, hls: null },
seasonJob: { running: false, done: 0, total: 0 },
async load(source, sourceId) {
this.source = source; this.sourceId = sourceId;
try {
const res = await fetch(`/api/titles/${source}/${encodeURIComponent(sourceId)}`);
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
this.details = await res.json();
this.details.episodes.forEach(e => e._busy = false);
document.title = this.details.title + ' — Ohm Stream';
} catch (e) { this.error = e.message; }
finally { this.loading = false; }
},
// Chaîne complète : page épisode → embeds → lien vidéo direct
async extract(ep) {
const res = await fetch('/api/extract?episode_url=' + encodeURIComponent(ep.url));
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Extraction impossible');
if (!data.links.length) throw new Error(data.errors[0] || 'Aucun lien vidéo résolu');
return data.links[0];
},
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
openPlayer(link) {
const ref = link.headers?.Referer || '';
const src = '/api/proxy?url=' + encodeURIComponent(link.url)
+ (ref ? '&ref=' + encodeURIComponent(ref) : '');
const video = this.$refs.player;
if (link.is_hls && window.Hls && window.Hls.isSupported()) {
this.player.hls = new Hls();
this.player.hls.loadSource(src);
this.player.hls.attachMedia(video);
} else {
video.src = src; // Safari lit le HLS nativement
}
this.player.active = true;
},
closePlayer() {
if (this.player.hls) { this.player.hls.destroy(); this.player.hls = null; }
this.$refs.player.pause();
this.$refs.player.removeAttribute('src');
this.player.active = false;
},
async streamEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
this.openPlayer(link);
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadEpisode(ep) {
ep._busy = true;
try {
const link = await this.extract(ep);
const referer = link.headers?.Referer || ep.url;
const internal = [link.url, referer, this.epLabel(ep)].join('|');
await fetch('/api/downloads', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_url: internal }),
});
toast('⬇ Ajouté : ' + this.epLabel(ep));
} catch (e) { toast('⚠ ' + e.message); }
finally { ep._busy = false; }
},
async downloadSeason() {
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
for (const ep of this.details.episodes) {
await this.downloadEpisode(ep);
this.seasonJob.done++;
}
this.seasonJob.running = false;
toast('✔ Saison ajoutée à la file de téléchargement');
},
async toggleFavorite() {
if (this.favorite) return;
await fetch('/api/favorites', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source: this.source, source_id: this.sourceId,
title: this.details.title, image_url: this.details.image_url,
payload: { year: this.details.year, genres: this.details.genres },
}),
});
this.favorite = true;
toast('⭐ Ajouté aux favoris');
},
};
}
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% set active = 'library' %}
{% block title %}Lecture — Ohm Stream{% endblock %}
{% block content %}
<div x-data="watchPage({{ download_id }})" x-init="load()">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.2rem">
<a href="/library" class="btn btn-ghost btn-sm">← Bibliothèque</a>
<h1 class="page-title" style="margin:0" x-text="file ? file.title : '…'"></h1>
<div style="flex:1"></div>
<a class="btn btn-ghost btn-sm" x-show="neighbors.previous" :href="'/watch/' + neighbors.previous">⏮ Précédent</a>
<a class="btn btn-sm" x-show="neighbors.next" :href="'/watch/' + neighbors.next">Suivant ⏭</a>
</div>
<div class="player-wrap">
<video x-ref="video" :src="`/api/stream/${id}`" controls
@timeupdate.throttle.5s="saveProgress"
@loadedmetadata="restorePosition">
</video>
</div>
<p class="page-sub" x-show="position > 0">⏵ Reprise automatique à ta position précédente.</p>
</div>
{% endblock %}
{% block scripts %}
<script>
function watchPage(id) {
return {
id, file: null, neighbors: {}, position: 0,
async load() {
const [fileRes, neighRes] = await Promise.all([
fetch('/api/downloads/' + id),
fetch(`/api/library/${id}/neighbors`),
]);
this.file = await fileRes.json();
this.neighbors = await neighRes.json();
document.title = (this.file.title || 'Lecture') + ' — Ohm Stream';
// Position enregistrée côté serveur via /api/library
const lib = await fetch('/api/library').then(r => r.json());
const entry = lib.find(f => f.id === id);
this.position = entry?.position_seconds || 0;
},
restorePosition() {
if (this.position > 5 && this.$refs.video.duration - this.position > 10) {
this.$refs.video.currentTime = this.position;
}
},
saveProgress() {
const t = this.$refs.video.currentTime;
if (!t) return;
fetch(`/api/stream/${id}/progress`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ position_seconds: t }),
});
},
};
}
</script>
{% endblock %}