"""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] 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 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//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()