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:
@@ -132,7 +132,9 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
|
||||
(animés triés par date de sortie via Kitsu ; séries & films de French-Stream), 🔥
|
||||
incontournables animés (top popularité Kitsu) et ✨ recommandations par genres déduites
|
||||
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire).
|
||||
des téléchargements et favoris, titres déjà possédés exclus (cache mémoire). Sans
|
||||
Sonarr, les genres des séries/films téléchargés sont lus sur leur fiche source ;
|
||||
sans aucun historique, une carte d'amorçage invite à télécharger un premier titre.
|
||||
|
||||
|
||||
## Intégration Sonarr / Prowlarr (*arr)
|
||||
@@ -213,6 +215,6 @@ app/
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # 131 tests
|
||||
uv run pytest # 132 tests
|
||||
uv run ruff check . # lint
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+35
-2
@@ -326,10 +326,43 @@ async def test_for_you_aggregates_genres_and_excludes_owned(history):
|
||||
assert "Konosuba" in titles
|
||||
|
||||
|
||||
async def test_for_you_empty_without_history(monkeypatch):
|
||||
async def test_for_you_cold_start_without_history(monkeypatch):
|
||||
"""Aucun téléchargement/favori/Sonarr : amorçage signalé (non caché)."""
|
||||
service = DiscoverService()
|
||||
result = await service.for_you(user_id=42)
|
||||
assert result == {"based_on": [], "items": []}
|
||||
assert result == {"based_on": [], "items": [], "cold_start": True}
|
||||
assert service._cache.get("for_you:42:20") is None # pas de cache : bon marché, change au 1er téléchargement
|
||||
|
||||
async def test_for_you_uses_series_page_genres_without_sonarr(monkeypatch):
|
||||
"""Sans Sonarr, les séries téléchargées donnent leurs genres via leur fiche
|
||||
source (libellés FR convertis en catégories Kitsu)."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("serie-user", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, status) VALUES (?,?,?,?,?)",
|
||||
("k9", "https://v/9", "https://french-stream.lat/15138132-stat-saison-5.html",
|
||||
"STAT - Saison 5", "done"),
|
||||
)
|
||||
|
||||
async def fake_no_match(title: str):
|
||||
return None # série réelle : aucun match Kitsu
|
||||
|
||||
async def fake_genres_of_page(self, page_url: str):
|
||||
assert "15138132" in page_url
|
||||
return ["Drame", "Médical"] # Médical : sans équivalent Kitsu → ignoré
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service._kitsu, "search_anime", fake_no_match)
|
||||
monkeypatch.setattr(
|
||||
"app.scrapers.sources.french_stream.FrenchStreamScraper.genres_of_page",
|
||||
fake_genres_of_page,
|
||||
)
|
||||
result = await service.for_you(user_id=user_id, limit=10)
|
||||
assert result["based_on"] == ["Drama"] # « Drame » converti, « Médical » écarté
|
||||
assert result["items"] # des recommandations issues de la catégorie drama
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user