From d5fa546dd777070b4fd6dbe236b95ee779cc326b Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 25 Sep 2026 15:14:14 +0000 Subject: [PATCH] =?UTF-8?q?Pour=20toi=20:=20fallback=20sans=20Sonarr=20et?= =?UTF-8?q?=20amor=C3=A7age=20sans=20historique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 6 +- app/scrapers/sources/french_stream.py | 15 +++++ app/services/discover.py | 79 ++++++++++++++++++++++++--- app/services/downloads.py | 4 ++ app/static/css/style.css | 28 ++++++++++ app/templates/discover.html | 17 +++++- tests/test_discover.py | 37 ++++++++++++- 7 files changed, 174 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7feae13..27db327 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/scrapers/sources/french_stream.py b/app/scrapers/sources/french_stream.py index 3a78387..09b66b2 100644 --- a/app/scrapers/sources/french_stream.py +++ b/app/scrapers/sources/french_stream.py @@ -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: diff --git a/app/services/discover.py b/app/services/discover.py index 7b24d86..bada26a 100644 --- a/app/services/discover.py +++ b/app/services/discover.py @@ -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).""" diff --git a/app/services/downloads.py b/app/services/downloads.py index 16f21e3..dd3596e 100644 --- a/app/services/downloads.py +++ b/app/services/downloads.py @@ -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 diff --git a/app/static/css/style.css b/app/static/css/style.css index aea4b2e..faa42ac 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; diff --git a/app/templates/discover.html b/app/templates/discover.html index ced4815..cb36123 100644 --- a/app/templates/discover.html +++ b/app/templates/discover.html @@ -156,11 +156,26 @@ -
+

✨ Pour toi

+ + + +
+
🧭
+
+
On ne te connaît pas encore…
+

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.

+ +
+
diff --git a/tests/test_discover.py b/tests/test_discover.py index fc52402..5223baf 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -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