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