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
|
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), 🔥
|
(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
|
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)
|
## Intégration Sonarr / Prowlarr (*arr)
|
||||||
@@ -213,6 +215,6 @@ app/
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest # 131 tests
|
uv run pytest # 132 tests
|
||||||
uv run ruff check . # lint
|
uv run ruff check . # lint
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -251,6 +251,21 @@ class FrenchStreamScraper(SourceScraper):
|
|||||||
for media_type, genres in catalog.items()
|
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
|
# ------------------------------------------------------------- details
|
||||||
|
|
||||||
async def get_details(self, source_id: str) -> TitleDetails:
|
async def get_details(self, source_id: str) -> TitleDetails:
|
||||||
|
|||||||
@@ -67,6 +67,30 @@ ANIME_GENRES: dict[str, str] = {
|
|||||||
"music": "Musique",
|
"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
|
_LATEST_TTL_SECONDS = 600 # nouveautés : re-scrape au bout de 10 min
|
||||||
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
|
_MUST_WATCH_TTL_SECONDS = 21600 # incontournables : quasi statique, 6 h
|
||||||
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
|
_FOR_YOU_TTL_SECONDS = 3600 # recommandations : 1 h (l'historique évolue lentement)
|
||||||
@@ -256,14 +280,23 @@ class DiscoverService:
|
|||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached # type: ignore[return-value]
|
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)
|
genre_counts = await self._genres_from_downloads(owned)
|
||||||
for genre, count in favorite_genres.items():
|
for genre, count in favorite_genres.items():
|
||||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
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()
|
sonarr_owned, sonarr_genres = await sonarr.profile()
|
||||||
for genre, count in sonarr_genres.items():
|
for genre, count in sonarr_genres.items():
|
||||||
genre_counts[genre] = genre_counts.get(genre, 0) + count
|
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
|
owned |= sonarr_owned
|
||||||
|
|
||||||
if not genre_counts:
|
if not genre_counts:
|
||||||
result: dict = {"based_on": [], "items": []}
|
result: dict = {"based_on": [], "items": []}
|
||||||
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
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)
|
self._cache.set(cache_key, result, _FOR_YOU_TTL_SECONDS)
|
||||||
return result
|
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:
|
def invalidate_for_you(self) -> None:
|
||||||
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
|
"""Recommandations recalculées au prochain appel (réglages Sonarr modifiés)."""
|
||||||
self._cache.clear("for_you:")
|
self._cache.clear("for_you:")
|
||||||
|
|
||||||
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int]]:
|
async def _owned(self, user_id: int) -> tuple[set[str], dict[str, int], dict[str, str]]:
|
||||||
"""Titres possédés (normalisés) + genres directement connus via les favoris."""
|
"""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(
|
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,),
|
(_MAX_HISTORY_TITLES,),
|
||||||
)
|
)
|
||||||
fav_rows = await db.fetchall(
|
fav_rows = await db.fetchall(
|
||||||
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
"SELECT payload FROM favorites WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
(user_id, _MAX_HISTORY_TITLES),
|
(user_id, _MAX_HISTORY_TITLES),
|
||||||
)
|
)
|
||||||
owned = {normalize_title(row["title"]).casefold() for row in rows}
|
owned: set[str] = set()
|
||||||
owned.discard("")
|
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] = {}
|
genre_counts: dict[str, int] = {}
|
||||||
for row in fav_rows:
|
for row in fav_rows:
|
||||||
try:
|
try:
|
||||||
@@ -308,7 +373,7 @@ class DiscoverService:
|
|||||||
for genre in payload.get("genres") or []:
|
for genre in payload.get("genres") or []:
|
||||||
if isinstance(genre, str) and genre.strip():
|
if isinstance(genre, str) and genre.strip():
|
||||||
genre_counts[genre.strip()] = genre_counts.get(genre.strip(), 0) + 1
|
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]:
|
async def _genres_from_downloads(self, titles: set[str]) -> dict[str, int]:
|
||||||
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
|
"""Genres Kitsu des titres téléchargés (cache DB puis recherche)."""
|
||||||
|
|||||||
@@ -533,6 +533,10 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
await self._emit(download_id)
|
await self._emit(download_id)
|
||||||
logger.info("Téléchargement terminé (id=%s, %d octets)", download_id, size)
|
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
|
# ------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
|||||||
@@ -570,6 +570,34 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.explore-skel .rail-skel { margin-top: 0.2rem; }
|
.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 {
|
.rail-title {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -156,11 +156,26 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ------------------------------------------------ Pour toi -->
|
<!-- ------------------------------------------------ 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
|
<h2 class="rail-title">✨ Pour toi
|
||||||
<span class="rail-source" x-show="forYou.based_on.length"
|
<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>
|
x-text="forYou.based_on.length ? 'parce que tu aimes : ' + forYou.based_on.join(', ') : ''"></span>
|
||||||
</h2>
|
</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">
|
<div class="rail-wrap">
|
||||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||||
@click="scrollRail($el, -1)">‹</button>
|
@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
|
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()
|
service = DiscoverService()
|
||||||
result = await service.for_you(user_id=42)
|
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