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).
199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
"""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
|