v0.1.0 — Réécriture complète de OhmStreaming
Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user