diff --git a/app/routers/discover.py b/app/routers/discover.py index 9820dbb..a4830d7 100644 --- a/app/routers/discover.py +++ b/app/routers/discover.py @@ -23,7 +23,7 @@ async def get_discover( ) -> dict: """Les trois sections de découverte en une requête (sections vides si source KO).""" allowed = _allowed_media_types(user.content_preference) - latest = [i for i in await discover.latest(latest_limit) if i.get("media_type", "anime") in allowed] + latest = await discover.latest(latest_limit, allowed) # 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: diff --git a/app/services/discover.py b/app/services/discover.py index 8b7484b..125e682 100644 --- a/app/services/discover.py +++ b/app/services/discover.py @@ -92,20 +92,29 @@ class DiscoverService: # ------------------------------------------------------------ nouveautés - async def latest(self, limit: int = 24) -> list[dict]: + 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. 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 ». """ - cached = self._cache.get(f"latest:{limit}") + cache_key = f"latest:{limit}:{','.join(sorted(allowed)) if allowed else 'all'}" + cached = self._cache.get(cache_key) if cached is not None: return cached # type: ignore[return-value] 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: @@ -120,9 +129,20 @@ class DiscoverService: return await self._with_release_info(item) enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) - result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True) - result = result[:limit] - self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS) + 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) + + 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 async def _latest_of(self, source: SourceScraper) -> list[dict] | None: diff --git a/tests/test_discover.py b/tests/test_discover.py index 24d7e0e..06f1706 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -191,6 +191,45 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch): 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).""" + import app.services.discover as discover_module + + animes = [ + SearchResult(source="vostfree", source_id=str(n), title=f"Animé {n}", url=f"https://a/{n}") + for n in range(1, 31) + ] + series = [ + SearchResult( + source="french_stream", source_id=str(n), title=f"Série {n}", + url=f"https://s/{n}", media_type="serie", + ) + for n in range(1, 10) + ] + + class AnimeSource(_FakeSource): + name, label, base_url, media_types = "vostfree", "Vostfree", "https://a", ("anime",) + + class SerieSource(_FakeSource): + name, label, base_url, media_types = "french_stream", "French-Stream", "https://s", ("serie", "film") + + monkeypatch.setattr(discover_module, "all_sources", lambda: [AnimeSource(animes), SerieSource(series)]) + + async def enabled(name: str) -> bool: + return True + + monkeypatch.setattr(discover_module, "is_source_enabled", enabled) + + async def fake_match(title: str): + return None # rien chez Kitsu → aucune date de sortie + + 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 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 @@ -336,7 +375,7 @@ 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): + 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},