Files
Roman fed61adc56 Pour toi : recommandations diverses et déjà-possédé réellement exclu
Deux défauts enchaînés : la requête Kitsu cumulait les genres en ET
(quatre catégories → 1 seul titre, précisément le téléchargement à
l'origine des genres), et l'exclusion échouait car « Titre - Saison 1 -
E3 » ne normalisait jamais vers « Titre » canonique.

- Une requête Kitsu par genre, fusion entrelacée (chaque genre contribue)
- normalize_title retire saison/épisode en boucle avec frontières de mots
  (« - Saison 1 - E3 », « S1 E1 » → titre canonique ; « Série 9 » intact)
- test de non-régession : le possédé avec marqueurs n'est plus recommandé
2026-09-25 15:23:29 +00:00

514 lines
21 KiB
Python

"""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,
get_source,
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)
# (les TTL des sections suivent, plus bas)
# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles.
ANIME_GENRES: dict[str, str] = {
"action": "Action",
"adventure": "Aventure",
"comedy": "Comédie",
"drama": "Drame",
"fantasy": "Fantasy",
"science-fiction": "Science-Fiction",
"romance": "Romance",
"slice-of-life": "Tranche de vie",
"sports": "Sport",
"supernatural": "Surnaturel",
"mystery": "Mystère",
"psychological": "Psychologique",
"horror": "Horreur",
"mecha": "Mecha",
"isekai": "Isekai",
"music": "Musique",
}
# Genres des fiches séries/films (French-Stream, libellés FR, slugifiés) →
# catégorie Kitsu équivalente. Les genres sans équivalent (Médical, Western…)
# sont ignorés : mieux vaut un signal incomplet qu'une catégorie inexistante.
_GENRE_FR_TO_KITSU: dict[str, str] = {
"action": "Action",
"aventure": "Adventure",
"aventures": "Adventure",
"comedie": "Comedy",
"comedies": "Comedy",
"drame": "Drama",
"drames": "Drama",
"epouvante-horreur": "Horror",
"horreur": "Horror",
"fantastique": "Fantasy",
"fantastiques": "Fantasy",
"romance": "Romance",
"romances": "Romance",
"science-fiction": "Science Fiction",
"science-fictions": "Science Fiction",
"surnaturel": "Supernatural",
"thriller": "Thriller",
"thrillers": "Thriller",
}
_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)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
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_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
"""Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
retirés) puis répartis en rails indépendants — chaque type garde sa place,
aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de
sortie, statut) et trié du plus récent au plus ancien ; les séries et films
réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
Chaque rail est tronqué à ``limit``.
"""
cache_key = f"latest_by_type:{limit}"
cached = self._cache.get(cache_key)
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
by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []}
for item in merged.values():
by_type.setdefault(item.get("media_type", "anime"), []).append(item)
# Enrichissement Kitsu limité aux animés (seul catalogue couvert) —
# inutile de bombarder l'API pour des titres qui n'y figurent pas.
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 by_type["anime"]))
by_type["anime"] = sorted(
enriched, key=lambda it: it.get("start_date") or "", reverse=True
)[:limit]
for kind in ("serie", "film"):
by_type[kind] = by_type[kind][:limit]
self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
return by_type
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
async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]:
"""Séries/films d'un genre French-Stream, aplatis comme les nouveautés.
Liste vide si la catégorie est inconnue ou la source en échec.
"""
key = f"browse_fs:{media_type}:{genre}:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
try:
scraper = get_source("french_stream")
results = await scraper.browse(media_type, genre)
except ScrapeError as exc:
logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc)
return []
items = [
{**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label}
for r in results
][:limit]
self._cache.set(key, items, _LATEST_TTL_SECONDS)
return items
async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]:
"""Animés populaires d'une catégorie Kitsu (genre = slug du catalogue).
Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse).
"""
limit = min(limit, _KITSU_PAGE_MAX)
if genre not in ANIME_GENRES:
return []
key = f"browse_anime:{genre}:{limit}"
cached = self._cache.get(key)
if cached is not None:
return cached # type: ignore[return-value]
items = await self._kitsu_anime(
{"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit}
)
self._cache.set(key, items, _FOR_YOU_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, series_pages = 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
if series_pages:
# Séries/films téléchargés : genres lus sur leur fiche source (sans Sonarr)
for genre, count in (await self._genres_from_series_pages(series_pages)).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
if not (owned or favorite_genres or sonarr_owned):
# Aucun historique : proposition d'amorçage plutôt qu'une section muette
return {"based_on": [], "items": [], "cold_start": True}
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]
# Une requête par genre (Kitsu cumule les catégories en ET : quatre genres
# ensemble ne laissent que quelques titres — parfois le déjà-possédé !).
# Fusion entrelacée : chaque genre contribue, doublons retirés.
pools = [
list(pool)
for pool in await asyncio.gather(*(
self._kitsu_anime(
{"filter[categories]": category_slug(g), "sort": "-userCount", "page[limit]": limit}
)
for g in top_genres
))
if pool
]
candidates: list[dict] = []
seen: set[str] = set()
while pools:
for pool in pools[:]:
item = pool.pop(0)
key = str(item.get("kitsu_id") or item.get("title", "").casefold())
if key not in seen:
seen.add(key)
candidates.append(item)
if not pool:
pools.remove(pool)
kept = [
item for item in candidates
if item["title"] and normalize_title(item["title"]).casefold() not in owned
][:limit]
result = {"based_on": top_genres, "items": kept}
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
return result
async def _genres_from_series_pages(self, pages: dict[str, str]) -> dict[str, int]:
"""Genres Kitsu des séries/films téléchargés, lus sur leur fiche source.
Les libellés français des fiches sont convertis vers les catégories Kitsu
(anglais) ; les genres sans équivalent sont ignorés.
"""
if not pages:
return {}
try:
scraper = get_source("french_stream")
except ScrapeError:
return {}
counts: dict[str, int] = {}
for page_url in list(pages.values())[:_MAX_HISTORY_TITLES]:
for genre_fr in await scraper.genres_of_page(page_url):
canonical = _GENRE_FR_TO_KITSU.get(category_slug(genre_fr))
if canonical:
counts[canonical] = counts.get(canonical, 0) + 1
return counts
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], dict[str, str]]:
"""Titres possédés (normalisés), genres des favoris, fiches des téléchargements.
``series_pages`` relie un titre possédé à l'URL de sa fiche chez la source
(séries/films French-Stream) : sans Sonarr, c'est la seule source de genres.
"""
rows = await db.fetchall(
"SELECT DISTINCT title, page_url 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: set[str] = set()
series_pages: dict[str, str] = {}
for row in rows:
key = normalize_title(row["title"]).casefold()
if not key:
continue
owned.add(key)
page_url = row["page_url"] or ""
if page_url and key not in series_pages:
series_pages[key] = page_url
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, series_pages
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()