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:
@@ -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
|
||||
```
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+52
-5
@@ -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)}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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 /<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"}
|
||||
|
||||
@@ -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/<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+)")
|
||||
@@ -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/<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"]
|
||||
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:
|
||||
|
||||
+87
-30
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
+155
-14
@@ -4,10 +4,61 @@
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
<!-- ------------------------------------------------ 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 -->
|
||||
<div x-show="loading">
|
||||
<section class="rail-section" x-data="{ n: 8 }">
|
||||
@@ -28,14 +79,14 @@
|
||||
<div class="login-error" x-text="error"></div>
|
||||
</template>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés -->
|
||||
<section class="rail-section" x-show="!loading && latest.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés <span class="rail-source">de tes sources, triées par date de sortie</span></h2>
|
||||
<!-- ------------------------------------------------ Nouveautés animés -->
|
||||
<section class="rail-section" x-show="!loading && latestAnime.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés animés <span class="rail-source">triées par date de sortie</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 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)}`">
|
||||
<img class="card-poster" :src="item.image_url || '/static/img/placeholder.svg'" :alt="item.title"
|
||||
loading="lazy" onerror="this.src='/static/img/placeholder.svg'">
|
||||
@@ -56,9 +107,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ Nouveautés séries & films -->
|
||||
<section class="rail-section" x-show="!loading && latestSerie.length > 0">
|
||||
<h2 class="rail-title">🆕 Nouveautés séries & 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 -->
|
||||
<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">
|
||||
<button class="rail-nav rail-prev" type="button" aria-label="Défiler à gauche"
|
||||
@click="scrollRail($el, -1)">‹</button>
|
||||
@@ -107,7 +182,7 @@
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
@@ -117,23 +192,89 @@
|
||||
<script>
|
||||
function discoverPage() {
|
||||
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,
|
||||
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) {
|
||||
const rail = el.closest('.rail-wrap').querySelector('.rail');
|
||||
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() {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const res = await fetch('/api/discover');
|
||||
if (!res.ok) throw new Error('Erreur ' + res.status);
|
||||
const data = await res.json();
|
||||
this.latest = data.latest;
|
||||
const [discoverRes, genresRes] = await Promise.all([
|
||||
fetch('/api/discover'), fetch('/api/discover/genres'),
|
||||
]);
|
||||
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.forYou = data.for_you;
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
this.genres = await genresRes.json();
|
||||
|
||||
// É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
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
|
||||
|
||||
service = DiscoverService()
|
||||
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]
|
||||
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()
|
||||
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 result[0]["rating"] is None
|
||||
|
||||
|
||||
async def test_latest_serie_preference_keeps_undated_titles(monkeypatch):
|
||||
"""Régression : en mode séries, les titres sans date Kitsu ne doivent pas être
|
||||
évincés du rail par la troncature (les séries n'existent pas chez Kitsu)."""
|
||||
async def test_latest_by_type_keeps_every_type(monkeypatch):
|
||||
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
|
||||
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
animes = [
|
||||
@@ -227,9 +227,11 @@ async def test_latest_serie_preference_keeps_undated_titles(monkeypatch):
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
|
||||
result = await service.latest(limit=24, allowed={"serie"})
|
||||
assert {item["media_type"] for item in result} == {"serie"}
|
||||
assert len(result) == 9 # les 9 séries, malgré 30 animés sans filtre interne
|
||||
rails = await service.latest_by_type(limit=24)
|
||||
assert len(rails["anime"]) == 24 # tronqué à la limite
|
||||
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):
|
||||
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
|
||||
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)
|
||||
|
||||
service = DiscoverService()
|
||||
assert await service.latest() == []
|
||||
assert await service.latest() == []
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
|
||||
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 fake_latest(limit: int = 24, allowed: set[str] | None = None):
|
||||
return [
|
||||
async def fake_latest_by_type(limit: int = 24):
|
||||
return {
|
||||
"anime": [
|
||||
{"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):
|
||||
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):
|
||||
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, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["latest"][0]["label"] == "Vostfree"
|
||||
assert data["latest"][0]["start_date"] == "2026-01-01"
|
||||
assert data["latest_anime"][0]["label"] == "Vostfree"
|
||||
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["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):
|
||||
r = await client.get("/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -132,6 +132,24 @@ async def test_latest(monkeypatch):
|
||||
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 fake_fetch_soup(url, **kwargs):
|
||||
assert "newsid=9562" in url
|
||||
|
||||
@@ -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)
|
||||
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
|
||||
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):
|
||||
@@ -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)
|
||||
assert r.status_code == 200
|
||||
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
|
||||
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert {i["media_type"] for i in r.json()["latest"]} == {"serie"}
|
||||
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
|
||||
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):
|
||||
"""Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries."""
|
||||
|
||||
Reference in New Issue
Block a user