Pour toi : fallback sans Sonarr et amorçage sans historique

- Sans Sonarr, les séries/films téléchargés donnent leurs genres via leur
  fiche French-Stream (page_url des téléchargements), convertis vers les
  catégories Kitsu (mapping FR→EN ; genres sans équivalent ignorés)
- Aucun historique (ni téléchargement, ni favori, ni Sonarr) : réponse
  cold_start non cachée → carte d'amorçage « téléchargez un premier titre »
- Fin de téléchargement → invalidation immédiate des recommandations
- genres_of_page ignore les fiches hors French-Stream (URLs d'animés)
This commit is contained in:
Roman
2026-09-25 15:14:14 +00:00
parent c45f1b79fd
commit d5fa546dd7
7 changed files with 174 additions and 12 deletions
+72 -7
View File
@@ -67,6 +67,30 @@ ANIME_GENRES: dict[str, str] = {
"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)
@@ -256,14 +280,23 @@ class DiscoverService:
if cached is not None:
return cached # type: ignore[return-value]
owned, favorite_genres = await self._owned(user_id)
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)
@@ -283,22 +316,54 @@ class DiscoverService:
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]]:
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
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 FROM downloads ORDER BY created_at DESC LIMIT ?",
"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 = {normalize_title(row["title"]).casefold() for row in rows}
owned.discard("")
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:
@@ -308,7 +373,7 @@ class DiscoverService:
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
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)."""
+4
View File
@@ -533,6 +533,10 @@ class DownloadManager:
)
await self._emit(download_id)
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
# L'historique a changé : les recommandations « Pour toi » se recalculent
from app.services.discover import discover
discover.invalidate_for_you()
# ------------------------------------------------------------ helpers