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
+15
View File
@@ -251,6 +251,21 @@ class FrenchStreamScraper(SourceScraper):
for media_type, genres in catalog.items()
}
async def genres_of_page(self, page_url: str) -> list[str]:
"""Genres (libellés FR) de la fiche pointée par l'URL d'un téléchargement.
Utilisé par les recommandations « Pour toi » pour les séries/films,
absents de Kitsu (et donc sans signal de genre sans Sonarr).
"""
if self.base_url.split("//")[-1].split("/")[0] not in page_url:
return [] # fiche d'une autre source (animé) : rien à lire ici
source_id = _newsid_from_url(page_url)
try:
details = await self.get_details(source_id)
except ScrapeError:
return []
return details.genres or []
# ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails:
+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
+28
View File
@@ -570,6 +570,34 @@ button { font-family: inherit; }
}
.explore-skel .rail-skel { margin-top: 0.2rem; }
/* CTA d'amorçage « Pour toi » (aucun historique) */
.cta-card {
display: flex; align-items: center; gap: 1.6rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 1.6rem 1.8rem;
}
.cta-emoji { font-size: 2.6rem; }
.cta-body { display: flex; flex-direction: column; gap: 0.4rem; }
.cta-title { font-weight: 700; font-size: 1.05rem; }
.cta-body p { margin: 0; color: var(--text-dim); font-size: 0.92rem; }
.cta-actions { display: flex; gap: 0.6rem; margin-top: 0.6rem; }
.cta-btn {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
font: inherit; font-size: 0.88rem; font-weight: 600;
padding: 0.5rem 1rem; cursor: pointer; text-decoration: none;
transition: filter 0.15s;
}
.cta-btn:hover { filter: brightness(1.12); }
.cta-btn.ghost {
background: transparent; color: var(--text);
border: 1px solid var(--border);
}
.cta-btn.ghost:hover { border-color: var(--accent); color: var(--accent); filter: none; }
@media (max-width: 640px) {
.cta-card { flex-direction: column; text-align: center; }
.cta-actions { justify-content: center; flex-wrap: wrap; }
}
.rail-title {
font-size: 1.15rem;
font-weight: 700;
+16 -1
View File
@@ -156,11 +156,26 @@
</section>
<!-- ------------------------------------------------ Pour toi -->
<section class="rail-section" x-show="!loading && forYou.items.length > 0">
<section class="rail-section" x-show="!loading && (forYou.items.length > 0 || forYou.cold_start)">
<h2 class="rail-title">✨ Pour toi
<span class="rail-source" x-show="forYou.based_on.length"
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
</h2>
<!-- Aucun historique : carte d'amorçage au lieu d'une section muette -->
<div class="cta-card" x-show="!loading && forYou.cold_start">
<div class="cta-emoji">🧭</div>
<div class="cta-body">
<div class="cta-title">On ne te connaît pas encore…</div>
<p>Télécharge tes premiers animés ou séries, ou ajoute des favoris :
« Pour toi » s'ajustera automatiquement à tes genres préférés.</p>
<div class="cta-actions">
<a class="cta-btn" href="/">Chercher un premier titre</a>
<a class="cta-btn ghost" href="/discover">Explorer par genre</a>
</div>
</div>
</div>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>