From d747c574b875903987ece87cb3d3419749792322 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 25 Sep 2026 14:48:29 +0000 Subject: [PATCH] =?UTF-8?q?D=C3=A9couvrir=20v2=20:=20rails=20par=20type,?= =?UTF-8?q?=20exploration=20par=20genre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rails Nouveautés séparés : 🆕 animés (tri date Kitsu) et 🆕 séries & films (French-Stream) — chaque type garde son rail, plus d'écrasement mutuel - Section 🎭 Explorer : chips type × genre — animés via catégories Kitsu, séries (/-series-/) et films (/films//) via French-Stream ; état dans l'URL (/discover?t=serie&g=medical), partageable - Scraper : browse(media_type, genre) + catalogue YAML surchargeable ; parsing div.short mutualisé avec les nouveautés - Films réels retirés du mode « Animés » (ils suivent le rail séries) - Endpoints /api/discover (latest_anime/latest_serie), /api/discover/genres, /api/discover/browse — parcours filtré par la préférence du compte - Warmup démarrage : latest_by_type() --- README.md | 9 +- app/main.py | 2 +- app/routers/discover.py | 57 +++++++- app/routers/search.py | 8 +- app/scrapers/configs/french_stream.yaml | 32 +++++ app/scrapers/sources/french_stream.py | 72 +++++++++- app/services/discover.py | 117 +++++++++++----- app/static/css/style.css | 18 +++ app/templates/discover.html | 169 ++++++++++++++++++++++-- tests/test_discover.py | 89 ++++++++++--- tests/test_french_stream.py | 18 +++ tests/test_preferences.py | 19 ++- 12 files changed, 526 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 91ff81f..7feae13 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) : navigation épisode suivant/précédent. - **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur, administration des comptes. -- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées - par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »), - 🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites +- **Découverte** (`/discover`) : 🎭 exploration par genre (animés via Kitsu, séries/films + 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). @@ -212,6 +213,6 @@ app/ ## Tests ```bash -uv run pytest # 126 tests +uv run pytest # 131 tests uv run ruff check . # lint ``` diff --git a/app/main.py b/app/main.py index fdf5dff..6cc0d80 100644 --- a/app/main.py +++ b/app/main.py @@ -45,7 +45,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await db.connect() await apply_source_base_urls() await download_manager.start() - warmup = asyncio.create_task(discover_service.latest()) + warmup = asyncio.create_task(discover_service.latest_by_type()) warmup.add_done_callback(_log_task_error) logger.info("%s prêt", settings.app_name) yield diff --git a/app/routers/discover.py b/app/routers/discover.py index a4830d7..8034e41 100644 --- a/app/routers/discover.py +++ b/app/routers/discover.py @@ -1,4 +1,4 @@ -"""Découverte : nouveautés des sources, incontournables, recommandations.""" +"""Découverte : nouveautés par type, incontournables, recommandations, exploration.""" import logging from typing import Annotated @@ -7,7 +7,8 @@ from fastapi import APIRouter, Depends, Query from app.routers.auth import CurrentUser, current_user from app.routers.search import _allowed_media_types -from app.services.discover import discover +from app.scrapers.base import ScrapeError, get_source +from app.services.discover import ANIME_GENRES, discover logger = logging.getLogger(__name__) @@ -21,9 +22,15 @@ async def get_discover( must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20, for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20, ) -> dict: - """Les trois sections de découverte en une requête (sections vides si source KO).""" + """Rails de découverte en une requête (sections vides si source KO). + + « latest_anime » et « latest_serie » (séries + films réels) sont des rails + indépendants, filtrés par la préférence de contenu du compte. + """ allowed = _allowed_media_types(user.content_preference) - latest = await discover.latest(latest_limit, allowed) + rails = await discover.latest_by_type(latest_limit) + latest_anime = rails["anime"] if "anime" in allowed else [] + latest_serie = (rails["serie"] + rails["film"]) if {"serie", "film"} & allowed else [] # Incontournables et Pour toi sont issus de Kitsu (catalogue animés) : # en mode séries, elles n'ont pas de sens — on ne les calcule même pas. if "anime" in allowed: @@ -32,4 +39,44 @@ async def get_discover( else: must_watch = [] for_you = {"based_on": [], "items": []} - return {"latest": latest, "must_watch": must_watch, "for_you": for_you} + return { + "latest_anime": latest_anime, + "latest_serie": latest_serie, + "must_watch": must_watch, + "for_you": for_you, + } + + +@router.get("/discover/genres") +async def get_genres(user: CurrentUser) -> dict: + """Catalogue des genres parcourables (page Explorer), selon la préférence.""" + allowed = _allowed_media_types(user.content_preference) + catalog: dict[str, list[dict]] = {} + if "anime" in allowed: + catalog["anime"] = [{"key": key, "label": label} for key, label in ANIME_GENRES.items()] + try: + french_stream = get_source("french_stream") + available = french_stream.browse_catalog() + except ScrapeError: + available = {} + for kind in ("serie", "film"): + if kind in allowed and kind in available: + catalog[kind] = [ + {"key": key, "label": label} for key, label in available[kind].items() + ] + return catalog + + +@router.get("/discover/browse") +async def get_browse( + user: CurrentUser, + type: Annotated[str, Query(pattern="^(anime|serie|film)$")], + genre: Annotated[str, Query(min_length=1, max_length=40)], + limit: Annotated[int, Query(ge=1, le=40)] = 24, +) -> dict: + """Titres d'un genre : animés via Kitsu, séries/films via French-Stream.""" + if type not in _allowed_media_types(user.content_preference): + return {"items": []} # hors préférence du compte + if type == "anime": + return {"items": await discover.browse_anime(genre, limit)} + return {"items": await discover.browse_serie_film(type, genre, limit)} diff --git a/app/routers/search.py b/app/routers/search.py index 546e649..c884e3d 100644 --- a/app/routers/search.py +++ b/app/routers/search.py @@ -27,11 +27,11 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current import_all_scrapers() kitsu = KitsuService() -# Types de médias gardés selon la préférence : les films restent avec les animés -# (cinéma d'animation), une future source séries les exclurait donc du mode « séries ». +# Types de médias gardés selon la préférence. Les films réels (French-Stream) +# accompagnent les séries — le mode animés reste sur l'animation. _PREFERENCE_MEDIA_TYPES = { - "anime": {"anime", "film"}, - "serie": {"serie"}, + "anime": {"anime"}, + "serie": {"serie", "film"}, "both": {"anime", "serie", "film"}, } diff --git a/app/scrapers/configs/french_stream.yaml b/app/scrapers/configs/french_stream.yaml index fe3b6de..dc134da 100644 --- a/app/scrapers/configs/french_stream.yaml +++ b/app/scrapers/configs/french_stream.yaml @@ -26,3 +26,35 @@ latest: item: "div.short" link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre image: "img" + +browse: # parcours par genre (page Explorer) — chemins vérifiés en live + serie: # pages /-series-/ (9 genres exposés par le site) + aventure: {path: "/aventure-series-/", label: "Aventure"} + familles: {path: "/familles-series-/", label: "Famille"} + fantastique: {path: "/fantastique-series-/", label: "Fantastique"} + judiciaire: {path: "/judiciare-series-/", label: "Judiciaire"} # coquille du site + medical: {path: "/medical-series-/", label: "Médical"} + romance: {path: "/romance-series-/", label: "Romance"} + science-fiction: {path: "/science-fiction-series-/", label: "Science-Fiction"} + thriller: {path: "/thriller-series-/", label: "Thriller"} + western: {path: "/western-series-/", label: "Western"} + film: # pages /films// + actions: {path: "/films/actions/", label: "Action"} + animations: {path: "/films/animations/", label: "Animation"} + aventures: {path: "/films/aventures/", label: "Aventure"} + biopics: {path: "/films/biopics/", label: "Biopic"} + comedies: {path: "/films/comedies/", label: "Comédie"} + cultes: {path: "/films/cultes/", label: "Culte"} + documentaires: {path: "/films/documentaires/", label: "Documentaire"} + drames: {path: "/films/drames/", label: "Drame"} + epouvante-horreurs: {path: "/films/epouvante-horreurs/", label: "Épouvante-Horreur"} + espionnages: {path: "/films/espionnages/", label: "Espionnage"} + familles: {path: "/films/familles/", label: "Famille"} + fantastiques: {path: "/films/fantastiques/", label: "Fantastique"} + guerres: {path: "/films/guerres/", label: "Guerre"} + historiques: {path: "/films/historiques/", label: "Historique"} + policiers: {path: "/films/policiers/", label: "Policier"} + romances: {path: "/films/romances/", label: "Romance"} + science-fictions: {path: "/films/science-fictions/", label: "Science-Fiction"} + thrillers: {path: "/films/thrillers/", label: "Thriller"} + westerns: {path: "/films/westerns/", label: "Western"} diff --git a/app/scrapers/sources/french_stream.py b/app/scrapers/sources/french_stream.py index 739be8e..3a78387 100644 --- a/app/scrapers/sources/french_stream.py +++ b/app/scrapers/sources/french_stream.py @@ -62,6 +62,42 @@ DEFAULT_CONFIG: dict = { "link": "a.short-poster", "image": "img", }, + # Parcours par genre (page Explorer) : type → clé → {path, label}. + # Chemins vérifiés en live : films = /films//, séries = /-series-/. + "browse": { + "serie": { + "aventure": {"path": "/aventure-series-/", "label": "Aventure"}, + "familles": {"path": "/familles-series-/", "label": "Famille"}, + "fantastique": {"path": "/fantastique-series-/", "label": "Fantastique"}, + "judiciaire": {"path": "/judiciare-series-/", "label": "Judiciaire"}, # coquille du site + "medical": {"path": "/medical-series-/", "label": "Médical"}, + "romance": {"path": "/romance-series-/", "label": "Romance"}, + "science-fiction": {"path": "/science-fiction-series-/", "label": "Science-Fiction"}, + "thriller": {"path": "/thriller-series-/", "label": "Thriller"}, + "western": {"path": "/western-series-/", "label": "Western"}, + }, + "film": { + "actions": {"path": "/films/actions/", "label": "Action"}, + "animations": {"path": "/films/animations/", "label": "Animation"}, + "aventures": {"path": "/films/aventures/", "label": "Aventure"}, + "biopics": {"path": "/films/biopics/", "label": "Biopic"}, + "comedies": {"path": "/films/comedies/", "label": "Comédie"}, + "cultes": {"path": "/films/cultes/", "label": "Culte"}, + "documentaires": {"path": "/films/documentaires/", "label": "Documentaire"}, + "drames": {"path": "/films/drames/", "label": "Drame"}, + "epouvante-horreurs": {"path": "/films/epouvante-horreurs/", "label": "Épouvante-Horreur"}, + "espionnages": {"path": "/films/espionnages/", "label": "Espionnage"}, + "familles": {"path": "/films/familles/", "label": "Famille"}, + "fantastiques": {"path": "/films/fantastiques/", "label": "Fantastique"}, + "guerres": {"path": "/films/guerres/", "label": "Guerre"}, + "historiques": {"path": "/films/historiques/", "label": "Historique"}, + "policiers": {"path": "/films/policiers/", "label": "Policier"}, + "romances": {"path": "/films/romances/", "label": "Romance"}, + "science-fictions": {"path": "/films/science-fictions/", "label": "Science-Fiction"}, + "thrillers": {"path": "/films/thrillers/", "label": "Thriller"}, + "westerns": {"path": "/films/westerns/", "label": "Western"}, + }, + }, } _NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)") @@ -158,8 +194,31 @@ class FrenchStreamScraper(SourceScraper): async def latest(self) -> list[SearchResult]: """Ajouts récents — page /series/ (mix films & séries).""" config = _merged_config() + soup = await fetch_soup(f"{self.base_url}{config['latest']['path']}") + results = self._short_blocks(soup) + logger.info("french_stream : %d nouveautés récupérées", len(results)) + return results + + async def browse(self, media_type: str, category: str) -> list[SearchResult]: + """Parcours par genre — pages /films// et /-series-/. + + ``media_type`` (« serie » | « film ») et ``category`` (clé du catalogue + YAML, ex. « thriller »). Le type est forcé sur les résultats : une page + genre séries ne liste que des séries, une page genre films que des films. + """ + catalog = _merged_config().get("browse", {}).get(media_type, {}) + entry = catalog.get(category) + if entry is None: + raise ScrapeError(f"Catégorie inconnue : {media_type}/{category}") + soup = await fetch_soup(f"{self.base_url}{entry['path']}") + results = self._short_blocks(soup, force_type=media_type) + logger.info("french_stream : %d titres dans %s/%s", len(results), media_type, category) + return results + + def _short_blocks(self, soup: BeautifulSoup, force_type: str | None = None) -> list[SearchResult]: + """Bloc `div.short` → SearchResult (structure partagée nouveautés/genres).""" + config = _merged_config() latest_cfg = config["latest"] - soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}") results: list[SearchResult] = [] for block in soup.select(latest_cfg["item"]): link = block.select_one(latest_cfg["link"]) @@ -179,12 +238,19 @@ class FrenchStreamScraper(SourceScraper): title=title, url=f"{self.base_url}{href}" if href.startswith("/") else href, image_url=image.get("src") if image else None, - media_type=_media_type(title, href), + media_type=force_type or _media_type(title, href), ) ) - logger.info("french_stream : %d nouveautés récupérées", len(results)) return results + def browse_catalog(self) -> dict[str, dict[str, str]]: + """Catalogue des genres parcourables : ``{type: {clé: libellé}}``.""" + catalog = _merged_config().get("browse", {}) + return { + media_type: {key: entry.get("label", key) for key, entry in genres.items()} + for media_type, genres in catalog.items() + } + # ------------------------------------------------------------- details async def get_details(self, source_id: str) -> TitleDetails: diff --git a/app/services/discover.py b/app/services/discover.py index 125e682..7b24d86 100644 --- a/app/services/discover.py +++ b/app/services/discover.py @@ -30,6 +30,7 @@ from app.scrapers.base import ( ScrapeError, SourceScraper, all_sources, + get_source, import_all_scrapers, ) from app.services.kitsu import KitsuService, normalize_title @@ -44,11 +45,32 @@ import_all_scrapers() _MAX_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris) _MAX_GENRES = 4 # genres retenus pour la requête Kitsu _KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400) -_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés) +# (les TTL des sections suivent, plus bas) + +# Genres animés proposés à l'exploration — slugs des catégories Kitsu officielles. +ANIME_GENRES: dict[str, str] = { + "action": "Action", + "adventure": "Aventure", + "comedy": "Comédie", + "drama": "Drame", + "fantasy": "Fantasy", + "science-fiction": "Science-Fiction", + "romance": "Romance", + "slice-of-life": "Tranche de vie", + "sports": "Sport", + "supernatural": "Surnaturel", + "mystery": "Mystère", + "psychological": "Psychologique", + "horror": "Horreur", + "mecha": "Mecha", + "isekai": "Isekai", + "music": "Musique", +} _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) +_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés) class _TTLCache: @@ -92,20 +114,17 @@ class DiscoverService: # ------------------------------------------------------------ nouveautés - async def latest(self, limit: int = 24, allowed: set[str] | None = None) -> list[dict]: - """Nouveautés toutes sources confondues, triées par date de sortie réelle. + async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]: + """Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``. - Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés), - enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus - récent au plus ancien — ce qui sort / vient de sortir en premier. - - ``allowed`` restreint aux types de médias autorisés (préférence du compte), - AVANT fusion/tri/troncature : sans lui, les titres hors Kitsu (séries, films - réel) — sans date de sortie — seraient évincés du rail par la troncature. - Le remplissage final équilibre les types présents (round-robin) pour qu'aucun - ne soit écrasé par les autres en mode « les deux ». + Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons + retirés) puis répartis en rails indépendants — chaque type garde sa place, + aucun n'évince les autres. Le rail animés est enrichi via Kitsu (date de + sortie, statut) et trié du plus récent au plus ancien ; les séries et films + réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »). + Chaque rail est tronqué à ``limit``. """ - cache_key = f"latest:{limit}:{','.join(sorted(allowed)) if allowed else 'all'}" + cache_key = f"latest_by_type:{limit}" cached = self._cache.get(cache_key) if cached is not None: return cached # type: ignore[return-value] @@ -113,8 +132,6 @@ class DiscoverService: sources = [s for s in all_sources() if await is_source_enabled(s.name)] outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources)) items = [item for outcome in outcomes for item in (outcome or [])] - if allowed is not None: - items = [item for item in items if item.get("media_type", "anime") in allowed] merged: dict[str, dict] = {} for item in items: @@ -122,28 +139,28 @@ class DiscoverService: existing = merged.get(key) if existing is None or (not existing.get("image_url") and item.get("image_url")): merged[key] = item + + by_type: dict[str, list[dict]] = {"anime": [], "serie": [], "film": []} + for item in merged.values(): + by_type.setdefault(item.get("media_type", "anime"), []).append(item) + + # Enrichissement Kitsu limité aux animés (seul catalogue couvert) — + # inutile de bombarder l'API pour des titres qui n'y figurent pas. semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY) async def bounded(item: dict) -> dict: async with semaphore: return await self._with_release_info(item) - enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) - by_type: dict[str, list[dict]] = {} - for item in sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True): - by_type.setdefault(item.get("media_type", "anime"), []).append(item) + enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"])) + by_type["anime"] = sorted( + enriched, key=lambda it: it.get("start_date") or "", reverse=True + )[:limit] + for kind in ("serie", "film"): + by_type[kind] = by_type[kind][:limit] - result: list[dict] = [] - pools = [list(pool) for pool in by_type.values()] - while len(result) < limit and pools: - for pool in pools[:]: - result.append(pool.pop(0)) - if len(result) >= limit: - break - if not pool: - pools.remove(pool) - self._cache.set(cache_key, result, _LATEST_TTL_SECONDS) - return result + self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS) + return by_type async def _latest_of(self, source: SourceScraper) -> list[dict] | None: """Items latest() d'une source, aplatis avec les infos de source ([] si KO).""" @@ -184,6 +201,46 @@ class DiscoverService: self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS) return items + + async def browse_serie_film(self, media_type: str, genre: str, limit: int = 24) -> list[dict]: + """Séries/films d'un genre French-Stream, aplatis comme les nouveautés. + + Liste vide si la catégorie est inconnue ou la source en échec. + """ + key = f"browse_fs:{media_type}:{genre}:{limit}" + cached = self._cache.get(key) + if cached is not None: + return cached # type: ignore[return-value] + try: + scraper = get_source("french_stream") + results = await scraper.browse(media_type, genre) + except ScrapeError as exc: + logger.warning("Parcours %s/%s indisponible : %s", media_type, genre, exc) + return [] + items = [ + {**dataclasses.asdict(r), "source": scraper.name, "label": scraper.label} + for r in results + ][:limit] + self._cache.set(key, items, _LATEST_TTL_SECONDS) + return items + async def browse_anime(self, genre: str, limit: int = _KITSU_PAGE_MAX) -> list[dict]: + """Animés populaires d'une catégorie Kitsu (genre = slug du catalogue). + + Liste vide si le genre est inconnu ou si Kitsu échoue (dégradation gracieuse). + """ + limit = min(limit, _KITSU_PAGE_MAX) + if genre not in ANIME_GENRES: + return [] + key = f"browse_anime:{genre}:{limit}" + cached = self._cache.get(key) + if cached is not None: + return cached # type: ignore[return-value] + items = await self._kitsu_anime( + {"filter[categories]": genre, "sort": "-userCount", "page[limit]": limit} + ) + self._cache.set(key, items, _FOR_YOU_TTL_SECONDS) + return items + # ------------------------------------------------------------ pour toi async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict: diff --git a/app/static/css/style.css b/app/static/css/style.css index 4d62208..aea4b2e 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -552,6 +552,24 @@ button { font-family: inherit; } .rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; } +/* ------------------------------------------------------------- Explorer */ + +.explore-section { animation-delay: 0s; } +.explore-filters { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; } +.chip-row { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.genre-chip { + background: var(--surface); color: var(--text-dim); + border: 1px solid var(--border); border-radius: 99px; + font: inherit; font-size: 0.82rem; font-weight: 600; + padding: 0.32rem 0.85rem; cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.genre-chip:hover { color: var(--text); border-color: var(--accent); } +.genre-chip.active { + background: var(--accent); border-color: var(--accent); color: #fff; +} +.explore-skel .rail-skel { margin-top: 0.2rem; } + .rail-title { font-size: 1.15rem; font-weight: 700; diff --git a/app/templates/discover.html b/app/templates/discover.html index c388b1b..ced4815 100644 --- a/app/templates/discover.html +++ b/app/templates/discover.html @@ -4,10 +4,61 @@ {% block content %}

Découvrir

-

Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.

+

Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.

+ +
+

🎭 Explorer + +

+
+
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+
+
+
@@ -28,14 +79,14 @@ - -
-

🆕 Nouveautés de tes sources, triées par date de sortie

+ +
+

🆕 Nouveautés animés triées par date de sortie

-