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.
- **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
View File
@@ -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
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
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)}
+4 -4
View File
@@ -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"},
}
+32
View File
@@ -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"}
+69 -3
View File
@@ -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
View File
@@ -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:
+18
View File
@@ -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
View File
@@ -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 &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 -->
<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;
}
},
};
}
+71 -18
View File
@@ -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 [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
]
async def fake_latest_by_type(limit: int = 24):
return {
"anime": [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"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
+18
View File
@@ -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
+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)
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."""