Découvrir v2 : rails par type, exploration par genre

- 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 (/<genre>-series-/) et films (/films/<genre>/) 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()
This commit is contained in:
Roman
2026-09-25 14:48:29 +00:00
parent 248ec60d35
commit d747c574b8
12 changed files with 526 additions and 84 deletions
+5 -4
View File
@@ -128,9 +128,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
navigation épisode suivant/précédent. navigation épisode suivant/précédent.
- **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur, - **Comptes** : JWT court + refresh token (rotation), rôles admin/utilisateur,
administration des comptes. administration des comptes.
- **Découverte** (`/discover`) : 🆕 nouveautés fusionnées de toutes les sources, triées - **Découverte** (`/discover`) : 🎭 exploration par genre (animés via Kitsu, séries/films
par date de sortie réelle (enrichissement Kitsu, badge « en cours de diffusion »), via French-Stream — état dans l'URL, partageable), 🆕 nouveautés en rails séparés
🔥 incontournables (top popularité Kitsu) et ✨ recommandations par genres déduites (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).
@@ -212,6 +213,6 @@ app/
## Tests ## Tests
```bash ```bash
uv run pytest # 126 tests uv run pytest # 131 tests
uv run ruff check . # lint uv run ruff check . # lint
``` ```
+1 -1
View File
@@ -45,7 +45,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await db.connect() await db.connect()
await apply_source_base_urls() await apply_source_base_urls()
await download_manager.start() 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) warmup.add_done_callback(_log_task_error)
logger.info("%s prêt", settings.app_name) logger.info("%s prêt", settings.app_name)
yield yield
+52 -5
View File
@@ -1,4 +1,4 @@
"""Découverte : nouveautés des sources, incontournables, recommandations.""" """Découverte : nouveautés par type, incontournables, recommandations, exploration."""
import logging import logging
from typing import Annotated 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.auth import CurrentUser, current_user
from app.routers.search import _allowed_media_types 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__) logger = logging.getLogger(__name__)
@@ -21,9 +22,15 @@ async def get_discover(
must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20, must_watch_limit: Annotated[int, Query(ge=1, le=20)] = 20,
for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20, for_you_limit: Annotated[int, Query(ge=1, le=20)] = 20,
) -> dict: ) -> 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) 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) : # 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. # en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
if "anime" in allowed: if "anime" in allowed:
@@ -32,4 +39,44 @@ async def get_discover(
else: else:
must_watch = [] must_watch = []
for_you = {"based_on": [], "items": []} 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)}
+4 -4
View File
@@ -27,11 +27,11 @@ router = APIRouter(prefix="/api", tags=["search"], dependencies=[Depends(current
import_all_scrapers() import_all_scrapers()
kitsu = KitsuService() kitsu = KitsuService()
# Types de médias gardés selon la préférence : les films restent avec les animés # Types de médias gardés selon la préférence. Les films réels (French-Stream)
# (cinéma d'animation), une future source séries les exclurait donc du mode « séries ». # accompagnent les séries — le mode animés reste sur l'animation.
_PREFERENCE_MEDIA_TYPES = { _PREFERENCE_MEDIA_TYPES = {
"anime": {"anime", "film"}, "anime": {"anime"},
"serie": {"serie"}, "serie": {"serie", "film"},
"both": {"anime", "serie", "film"}, "both": {"anime", "serie", "film"},
} }
+32
View File
@@ -26,3 +26,35 @@ latest:
item: "div.short" item: "div.short"
link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre link: "a.short-poster" # href = fiche (/index.php?newsid=N), alt = titre
image: "img" image: "img"
browse: # parcours par genre (page Explorer) — chemins vérifiés en live
serie: # pages /<genre>-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/<genre>/
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"}
+69 -3
View File
@@ -62,6 +62,42 @@ DEFAULT_CONFIG: dict = {
"link": "a.short-poster", "link": "a.short-poster",
"image": "img", "image": "img",
}, },
# Parcours par genre (page Explorer) : type → clé → {path, label}.
# Chemins vérifiés en live : films = /films/<genre>/, séries = /<genre>-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+)") _NEWSID_RE = re.compile(r"(?:newsid=|/)(\d+)(?:-[^/]*)?\.?html?$|(?:newsid=)(\d+)")
@@ -158,8 +194,31 @@ class FrenchStreamScraper(SourceScraper):
async def latest(self) -> list[SearchResult]: async def latest(self) -> list[SearchResult]:
"""Ajouts récents — page /series/ (mix films & séries).""" """Ajouts récents — page /series/ (mix films & séries)."""
config = _merged_config() 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/<genre>/ et /<genre>-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"] latest_cfg = config["latest"]
soup = await fetch_soup(f"{self.base_url}{latest_cfg['path']}")
results: list[SearchResult] = [] results: list[SearchResult] = []
for block in soup.select(latest_cfg["item"]): for block in soup.select(latest_cfg["item"]):
link = block.select_one(latest_cfg["link"]) link = block.select_one(latest_cfg["link"])
@@ -179,12 +238,19 @@ class FrenchStreamScraper(SourceScraper):
title=title, title=title,
url=f"{self.base_url}{href}" if href.startswith("/") else href, url=f"{self.base_url}{href}" if href.startswith("/") else href,
image_url=image.get("src") if image else None, 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 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 # ------------------------------------------------------------- details
async def get_details(self, source_id: str) -> TitleDetails: async def get_details(self, source_id: str) -> TitleDetails:
+87 -30
View File
@@ -30,6 +30,7 @@ from app.scrapers.base import (
ScrapeError, ScrapeError,
SourceScraper, SourceScraper,
all_sources, all_sources,
get_source,
import_all_scrapers, import_all_scrapers,
) )
from app.services.kitsu import KitsuService, normalize_title 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_HISTORY_TITLES = 12 # titres récents analysés (téléchargements + favoris)
_MAX_GENRES = 4 # genres retenus pour la requête Kitsu _MAX_GENRES = 4 # genres retenus pour la requête Kitsu
_KITSU_PAGE_MAX = 20 # limite dure de l'API Kitsu (page[limit] > 20 → 400) _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 _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)
_ENRICH_CONCURRENCY = 6 # enrichissements Kitsu parallèles max (nouveautés)
class _TTLCache: class _TTLCache:
@@ -92,20 +114,17 @@ class DiscoverService:
# ------------------------------------------------------------ nouveautés # ------------------------------------------------------------ nouveautés
async def latest(self, limit: int = 24, allowed: set[str] | None = None) -> list[dict]: async def latest_by_type(self, limit: int = 24) -> dict[str, list[dict]]:
"""Nouveautés toutes sources confondues, triées par date de sortie réelle. """Nouveautés par type de média : ``{"anime": [...], "serie": [...], "film": [...]}``.
Les « récemment ajoutés » de chaque source sont fusionnés (doublons retirés), Les « récemment ajoutés » de chaque source activée sont fusionnés (doublons
enrichis via Kitsu (date de début, statut de diffusion) puis triés du plus retirés) puis répartis en rails indépendants — chaque type garde sa place,
récent au plus ancien — ce qui sort / vient de sortir en premier. 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
``allowed`` restreint aux types de médias autorisés (préférence du compte), réels, absents de Kitsu, gardent l'ordre du site (déjà « récents d'abord »).
AVANT fusion/tri/troncature : sans lui, les titres hors Kitsu (séries, films Chaque rail est tronqué à ``limit``.
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 ».
""" """
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) cached = self._cache.get(cache_key)
if cached is not None: if cached is not None:
return cached # type: ignore[return-value] 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)] 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)) outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources))
items = [item for outcome in outcomes for item in (outcome or [])] 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] = {} merged: dict[str, dict] = {}
for item in items: for item in items:
@@ -122,28 +139,28 @@ class DiscoverService:
existing = merged.get(key) existing = merged.get(key)
if existing is None or (not existing.get("image_url") and item.get("image_url")): if existing is None or (not existing.get("image_url") and item.get("image_url")):
merged[key] = item 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) semaphore = asyncio.Semaphore(_ENRICH_CONCURRENCY)
async def bounded(item: dict) -> dict: async def bounded(item: dict) -> dict:
async with semaphore: async with semaphore:
return await self._with_release_info(item) return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) enriched = await asyncio.gather(*(bounded(item) for item in by_type["anime"]))
by_type: dict[str, list[dict]] = {} by_type["anime"] = sorted(
for item in sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True): enriched, key=lambda it: it.get("start_date") or "", reverse=True
by_type.setdefault(item.get("media_type", "anime"), []).append(item) )[:limit]
for kind in ("serie", "film"):
by_type[kind] = by_type[kind][:limit]
result: list[dict] = [] self._cache.set(cache_key, by_type, _LATEST_TTL_SECONDS)
pools = [list(pool) for pool in by_type.values()] return by_type
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
async def _latest_of(self, source: SourceScraper) -> list[dict] | None: async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
"""Items latest() d'une source, aplatis avec les infos de source ([] si KO).""" """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) self._cache.set(key, items, _MUST_WATCH_TTL_SECONDS)
return items 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 # ------------------------------------------------------------ pour toi
async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict: async def for_you(self, user_id: int, limit: int = _KITSU_PAGE_MAX) -> dict:
+18
View File
@@ -552,6 +552,24 @@ button { font-family: inherit; }
.rail-section { margin-bottom: 2.4rem; animation: rise 0.5s ease backwards; } .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 { .rail-title {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
+155 -14
View File
@@ -4,10 +4,61 @@
{% block content %} {% block content %}
<h1 class="page-title">Découvrir</h1> <h1 class="page-title">Découvrir</h1>
<p class="page-sub">Nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p> <p class="page-sub">Explore par genre, nouveautés de tes sources, incontournables et suggestions basées sur tes téléchargements.</p>
<div x-data="discoverPage()" x-init="load()" x-cloak> <div x-data="discoverPage()" x-init="load()" x-cloak>
<!-- ------------------------------------------------ Explorer par genre -->
<section class="rail-section explore-section" x-show="!loading && types.length > 0">
<h2 class="rail-title">🎭 Explorer
<span class="rail-source" x-text="exploreSubtitle"></span>
</h2>
<div class="explore-filters">
<div class="chip-row" role="tablist" aria-label="Type de contenu">
<template x-for="t in types" :key="t">
<button class="genre-chip" :class="{ active: exploreType === t }" role="tab"
:aria-selected="exploreType === t" @click="setType(t)"
x-text="typeLabels[t]"></button>
</template>
</div>
<div class="chip-row" role="tablist" aria-label="Genre" x-show="currentGenres.length > 0">
<template x-for="g in currentGenres" :key="g.key">
<button class="genre-chip" :class="{ active: exploreGenre === g.key }" role="tab"
:aria-selected="exploreGenre === g.key" @click="setGenre(g.key)"
x-text="g.label"></button>
</template>
</div>
</div>
<div class="rail-wrap" x-show="!exploreLoading">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in exploreItems" :key="'ex' + i + (item.source_id || item.kitsu_id || item.title)">
<a class="card rail-card" :style="`--i:${i}`" :href="exploreHref(item)"
:title="item.source ? item.title : `Rechercher « ${item.title} »`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-show="item.label" x-text="item.label"></span>
<span class="card-chip" x-show="!item.label && item.rating" x-text="item.rating ? '★ ' + item.rating : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
<div class="card-meta">
<span class="badge badge-type" x-text="item.start_date ? item.start_date.slice(0, 4) : (item.year || '')"></span>
</div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
<div class="explore-skel" x-show="exploreLoading">
<div class="rail rail-skel">
<template x-for="i in 8" :key="'sk' + i"><div class="rail-card skel skel-card"></div></template>
</div>
</div>
</section>
<!-- ------------------------------------------------ Skeletons de chargement --> <!-- ------------------------------------------------ Skeletons de chargement -->
<div x-show="loading"> <div x-show="loading">
<section class="rail-section" x-data="{ n: 8 }"> <section class="rail-section" x-data="{ n: 8 }">
@@ -28,14 +79,14 @@
<div class="login-error" x-text="error"></div> <div class="login-error" x-text="error"></div>
</template> </template>
<!-- ------------------------------------------------ Nouveautés --> <!-- ------------------------------------------------ Nouveautés animés -->
<section class="rail-section" x-show="!loading && latest.length > 0"> <section class="rail-section" x-show="!loading && latestAnime.length > 0">
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2> <h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</span></h2>
<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>
<div class="rail"> <div class="rail">
<template x-for="(item, i) in latest" :key="item.source + item.source_id"> <template x-for="(item, i) in latestAnime" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`"> <a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title" <img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'"> loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
@@ -56,9 +107,33 @@
</div> </div>
</section> </section>
<!-- ------------------------------------------------ Nouveautés séries & films -->
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
<h2 class="rail-title">🆕 Nouveautés séries &amp; films <span class="rail-source">ajouts récents de French-Stream</span></h2>
<div class="rail-wrap">
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
@click="scrollRail($el, -1)">‹</button>
<div class="rail">
<template x-for="(item, i) in latestSerie" :key="item.source + item.source_id">
<a class="card rail-card" :style="`--i:${i}`" :href="`/title/${item.source}/${encodeURIComponent(item.source_id)}`">
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
<span class="card-chip" x-text="item.label"></span>
<span class="card-year" x-text="item.media_type === 'film' ? 'Film' : ''"></span>
<div class="card-overlay">
<div class="card-title" x-text="item.title"></div>
</div>
</a>
</template>
</div>
<button class="rail-nav rail-next" type="button" aria-label="Défiler à droite"
@click="scrollRail($el, 1)">›</button>
</div>
</section>
<!-- ------------------------------------------------ Incontournables --> <!-- ------------------------------------------------ Incontournables -->
<section class="rail-section" x-show="!loading && mustWatch.length > 0"> <section class="rail-section" x-show="!loading && mustWatch.length > 0">
<h2 class="rail-title">🔥 Incontournables <span class="rail-source">les classiques les mieux notés</span></h2> <h2 class="rail-title">🔥 Incontournables animés <span class="rail-source">les classiques les mieux notés</span></h2>
<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>
@@ -107,7 +182,7 @@
</div> </div>
</section> </section>
<template x-if="!loading && !error && latest.length === 0 && mustWatch.length === 0 && forYou.items.length === 0"> <template x-if="!loading && !error && latestAnime.length === 0 && latestSerie.length === 0 && mustWatch.length === 0 && forYou.items.length === 0 && exploreItems.length === 0">
<div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div> <div class="empty-state"><div class="big">🏜️</div>Rien à afficher pour le moment — réessaie plus tard.</div>
</template> </template>
</div> </div>
@@ -117,23 +192,89 @@
<script> <script>
function discoverPage() { function discoverPage() {
return { return {
latest: [], mustWatch: [], forYou: { based_on: [], items: [] }, latestAnime: [], latestSerie: [], mustWatch: [], forYou: { based_on: [], items: [] },
genres: {}, exploreType: null, exploreGenre: null,
exploreItems: [], exploreLoading: false,
loading: true, error: null, loading: true, error: null,
typeLabels: { anime: '⛩ Animés', serie: '📺 Séries', film: '🎬 Films' },
get types() { return Object.keys(this.genres); },
get currentGenres() { return this.genres[this.exploreType] || []; },
get exploreSubtitle() {
const genre = (this.currentGenres.find(g => g.key === this.exploreGenre) || {}).label;
return genre ? `${this.typeLabels[this.exploreType] || ''} · ${genre}` : 'choisis un genre';
},
scrollRail(el, dir) { scrollRail(el, dir) {
const rail = el.closest('.rail-wrap').querySelector('.rail'); const rail = el.closest('.rail-wrap').querySelector('.rail');
rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' }); rail.scrollBy({ left: dir * rail.clientWidth * 0.85, behavior: 'smooth' });
}, },
exploreHref(item) {
return item.source
? `/title/${item.source}/${encodeURIComponent(item.source_id)}`
: `/?q=${encodeURIComponent(item.title)}`;
},
setType(type) {
this.exploreType = type;
this.exploreGenre = (this.genres[type][0] || {}).key || null;
this.browse();
},
setGenre(genre) {
this.exploreGenre = genre;
this.browse();
},
async browse() {
if (!this.exploreType || !this.exploreGenre) return;
this.exploreLoading = true;
const params = new URLSearchParams({ type: this.exploreType, genre: this.exploreGenre });
const url = `${location.pathname}?${params}`;
history.replaceState(null, '', url); // état partageable
try {
const res = await fetch(`/api/discover/browse?${params}`);
if (!res.ok) throw new Error('Erreur ' + res.status);
this.exploreItems = (await res.json()).items;
} catch (e) {
this.exploreItems = [];
console.error('Explorer :', e);
} finally {
this.exploreLoading = false;
}
},
async load() { async load() {
this.loading = true; this.error = null; this.loading = true; this.error = null;
try { try {
const res = await fetch('/api/discover'); const [discoverRes, genresRes] = await Promise.all([
if (!res.ok) throw new Error('Erreur ' + res.status); fetch('/api/discover'), fetch('/api/discover/genres'),
const data = await res.json(); ]);
this.latest = data.latest; if (!discoverRes.ok) throw new Error('Erreur ' + discoverRes.status);
const data = await discoverRes.json();
this.latestAnime = data.latest_anime;
this.latestSerie = data.latest_serie;
this.mustWatch = data.must_watch; this.mustWatch = data.must_watch;
this.forYou = data.for_you; this.forYou = data.for_you;
} catch (e) { this.error = e.message; } this.genres = await genresRes.json();
finally { this.loading = false; }
// État initial : paramètres d'URL (?t=&g=) sinon premier type/genre dispo
const params = new URLSearchParams(location.search);
let type = params.get('t');
if (!this.genres[type]) type = this.types[0];
let genre = params.get('g');
if (!(this.genres[type] || []).some(g => g.key === genre)) {
genre = ((this.genres[type] || [])[0] || {}).key;
}
this.exploreType = type || null;
this.exploreGenre = genre || null;
await this.browse();
} catch (e) {
this.error = e.message;
} finally {
this.loading = false;
}
}, },
}; };
} }
+70 -17
View File
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
service = DiscoverService() service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10) result = (await service.latest_by_type(limit=10))["anime"]
titles = [item["title"] for item in result] titles = [item["title"] for item in result]
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
@@ -186,14 +186,14 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
service = DiscoverService() service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10) result = (await service.latest_by_type(limit=10))["anime"]
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"] assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
assert result[0]["rating"] is None assert result[0]["rating"] is None
async def test_latest_serie_preference_keeps_undated_titles(monkeypatch): async def test_latest_by_type_keeps_every_type(monkeypatch):
"""Régression : en mode séries, les titres sans date Kitsu ne doivent pas être """Chaque type garde son rail complet : les animés nombreux ne vicient pas
évincés du rail par la troncature (les séries n'existent pas chez Kitsu).""" le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
import app.services.discover as discover_module import app.services.discover as discover_module
animes = [ animes = [
@@ -227,9 +227,11 @@ async def test_latest_serie_preference_keeps_undated_titles(monkeypatch):
service = DiscoverService() service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match) monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=24, allowed={"serie"}) rails = await service.latest_by_type(limit=24)
assert {item["media_type"] for item in result} == {"serie"} assert len(rails["anime"]) == 24 # tronqué à la limite
assert len(result) == 9 # les 9 séries, malgré 30 animés sans filtre interne assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
assert all(item["media_type"] == "serie" for item in rails["serie"])
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch): async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes.""" """Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
import app.services.discover as discover_module import app.services.discover as discover_module
@@ -253,8 +255,8 @@ async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
monkeypatch.setattr(discover_module, "is_source_enabled", enabled) monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
service = DiscoverService() service = DiscoverService()
assert await service.latest() == [] assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert await service.latest() == [] assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
@@ -375,11 +377,18 @@ async def test_api_discover_requires_auth(client):
async def test_api_discover_sections(client, admin_cookies, monkeypatch): async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_latest(limit: int = 24, allowed: set[str] | None = None): async def fake_latest_by_type(limit: int = 24):
return [ return {
"anime": [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T", {"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"start_date": "2026-01-01", "status": "current", "rating": 8.1}, "media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
] ],
"serie": [
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
"title": "S", "media_type": "serie"},
],
"film": [],
}
async def fake_must_watch(limit: int = 20): async def fake_must_watch(limit: int = 20):
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}] return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
@@ -387,19 +396,63 @@ async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_for_you(user_id: int, limit: int = 20): async def fake_for_you(user_id: int, limit: int = 20):
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]} return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
monkeypatch.setattr(discover, "latest", fake_latest) monkeypatch.setattr(discover, "latest_by_type", fake_latest_by_type)
monkeypatch.setattr(discover, "must_watch", fake_must_watch) monkeypatch.setattr(discover, "must_watch", fake_must_watch)
monkeypatch.setattr(discover, "for_you", fake_for_you) monkeypatch.setattr(discover, "for_you", fake_for_you)
r = await client.get("/api/discover", cookies=admin_cookies) r = await client.get("/api/discover", cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["latest"][0]["label"] == "Vostfree" assert data["latest_anime"][0]["label"] == "Vostfree"
assert data["latest"][0]["start_date"] == "2026-01-01" assert data["latest_anime"][0]["start_date"] == "2026-01-01"
assert data["latest_serie"][0]["label"] == "French-Stream"
assert data["must_watch"][0]["title"] == "Attack on Titan" assert data["must_watch"][0]["title"] == "Attack on Titan"
assert data["for_you"]["based_on"] == ["Action"] assert data["for_you"]["based_on"] == ["Action"]
async def test_api_browse(client, admin_cookies, monkeypatch):
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
async def fake_browse_anime(genre: str, limit: int = 20):
assert genre == "comedy"
return [{"kitsu_id": "1", "title": "Nichijou"}]
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
assert (media_type, genre) == ("serie", "medical")
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
"media_type": "serie"}]
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
r = await client.get(
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "Nichijou"
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
# Préférence animés : le parcours séries renvoie vide sans même scraper
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"] == []
async def test_api_genres_catalog(client, admin_cookies):
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
r = await client.get("/api/discover/genres", cookies=admin_cookies)
assert r.status_code == 200
catalog = r.json()
assert {"anime", "serie", "film"} <= set(catalog)
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
assert any(g["key"] == "comedies" for g in catalog["film"])
async def test_discover_page_renders(client, admin_cookies): async def test_discover_page_renders(client, admin_cookies):
r = await client.get("/discover", cookies=admin_cookies) r = await client.get("/discover", cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
+18
View File
@@ -132,6 +132,24 @@ async def test_latest(monkeypatch):
assert results[0].title == "The Drop - Saison 1" assert results[0].title == "The Drop - Saison 1"
async def test_browse(monkeypatch):
"""Parcours par genre : page du genre scrapée, type forcé sur les résultats."""
async def fake_fetch_soup(url, **kwargs):
assert url.endswith("/medical-series-/")
return _soup(LATEST_HTML)
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
results = await FrenchStreamScraper().browse("serie", "medical")
# LATEST_HTML contient un film sans « Saison » : le type est forcé à serie
assert [r.media_type for r in results] == ["serie", "serie"]
assert results[0].title == "The Drop - Saison 1"
async def test_browse_unknown_category():
with pytest.raises(ScrapeError, match="Catégorie inconnue"):
await FrenchStreamScraper().browse("film", "inexistant")
async def test_get_details_serie(monkeypatch): async def test_get_details_serie(monkeypatch):
async def fake_fetch_soup(url, **kwargs): async def fake_fetch_soup(url, **kwargs):
assert "newsid=9562" in url assert "newsid=9562" in url
+14 -5
View File
@@ -90,7 +90,7 @@ async def test_search_anime_filters_serie(client, admin_cookies, monkeypatch):
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies) await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies) r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
types = {x["media_type"] for x in r.json()["results"]} types = {x["media_type"] for x in r.json()["results"]}
assert types == {"anime", "film"} # les films restent avec les animés assert types == {"anime"} # le mode animés reste sur l'animation (films réels → côté séries)
async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch): async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch):
@@ -112,21 +112,30 @@ async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypat
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies) r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
assert r.status_code == 200 assert r.status_code == 200
assert queried == [] # source anime-only ignorée assert queried == [] # source anime-only ignorée
assert {x["media_type"] for x in r.json()["results"]} == {"serie"} assert {x["media_type"] for x in r.json()["results"]} == {"serie", "film"}
# ---------------------------------------------------------------- filtrage découverte # ---------------------------------------------------------------- filtrage découverte
async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch): async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch):
"""Rails par type : chaque mode n'expose que ses rails (les films suivent les séries)."""
await _patch_sources(monkeypatch, MixedSource()) await _patch_sources(monkeypatch, MixedSource())
r = await client.get("/api/discover", cookies=admin_cookies) r = await client.get("/api/discover", cookies=admin_cookies)
assert {i["media_type"] for i in r.json()["latest"]} == {"anime", "serie", "film"} data = r.json()
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies) await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
r = await client.get("/api/discover", cookies=admin_cookies) data = (await client.get("/api/discover", cookies=admin_cookies)).json()
assert {i["media_type"] for i in r.json()["latest"]} == {"serie"} assert data["latest_anime"] == []
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
assert data["latest_serie"] == []
async def test_discover_hides_kitsu_sections_in_serie_mode(client, admin_cookies, monkeypatch): async def test_discover_hides_kitsu_sections_in_serie_mode(client, admin_cookies, monkeypatch):
"""Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries.""" """Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries."""