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:
+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:
|
||||
|
||||
Reference in New Issue
Block a user