Découverte : le filtre par préférence s'applique avant la troncature du rail

En mode séries, le rail Nouveautés était vide : discover.latest() triait par
date de sortie Kitsu (catalogue animés uniquement) puis tronquait à 24 AVANT
le filtrage — les séries et films réels, absents de Kitsu (donc sans date),
étaient systématiquement évincés.

- allowed (types autorisés par préférence) descend dans discover.latest()
  et filtre dès la collecte des sources
- remplissage du rail équilibré par type (round-robin) : en mode « les deux »,
  8 animés / 8 séries / 8 films au lieu de 24 animés
- test de non-régression : 9 séries conservées face à 30 animés datés
This commit is contained in:
Roman
2026-09-25 14:22:10 +00:00
parent 79a44297fd
commit 248ec60d35
3 changed files with 66 additions and 7 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ async def get_discover(
) -> dict: ) -> dict:
"""Les trois sections de découverte en une requête (sections vides si source KO).""" """Les trois sections de découverte en une requête (sections vides si source KO)."""
allowed = _allowed_media_types(user.content_preference) 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) : # Incontournables et Pour toi sont issus de Kitsu (catalogue animés) :
# en mode séries, elles n'ont pas de sens — on ne les calcule même pas. # en mode séries, elles n'ont pas de sens — on ne les calcule même pas.
if "anime" in allowed: if "anime" in allowed:
+25 -5
View File
@@ -92,20 +92,29 @@ class DiscoverService:
# ------------------------------------------------------------ nouveautés # ------------------------------------------------------------ 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. """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), 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 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. 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: if cached is not None:
return cached # type: ignore[return-value] return cached # type: ignore[return-value]
sources = [s for s in all_sources() if await is_source_enabled(s.name)] sources = [s for s in all_sources() if await is_source_enabled(s.name)]
outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources)) outcomes = await asyncio.gather(*(self._latest_of(source) for source in sources))
items = [item for outcome in outcomes for item in (outcome or [])] items = [item for outcome in outcomes for item in (outcome or [])]
if allowed is not None:
items = [item for item in items if item.get("media_type", "anime") in allowed]
merged: dict[str, dict] = {} merged: dict[str, dict] = {}
for item in items: for item in items:
@@ -120,9 +129,20 @@ class DiscoverService:
return await self._with_release_info(item) return await self._with_release_info(item)
enriched = await asyncio.gather(*(bounded(item) for item in merged.values())) enriched = await asyncio.gather(*(bounded(item) for item in merged.values()))
result = sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True) by_type: dict[str, list[dict]] = {}
result = result[:limit] for item in sorted(enriched, key=lambda it: it.get("start_date") or "", reverse=True):
self._cache.set(f"latest:{limit}", result, _LATEST_TTL_SECONDS) 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 return result
async def _latest_of(self, source: SourceScraper) -> list[dict] | None: async def _latest_of(self, source: SourceScraper) -> list[dict] | None:
+40 -1
View File
@@ -191,6 +191,45 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
assert result[0]["rating"] is None 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): async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes.""" """Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
import app.services.discover as discover_module import app.services.discover as discover_module
@@ -336,7 +375,7 @@ async def test_api_discover_requires_auth(client):
async def test_api_discover_sections(client, admin_cookies, monkeypatch): async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_latest(limit: int = 24): async def fake_latest(limit: int = 24, allowed: set[str] | None = None):
return [ return [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T", {"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"start_date": "2026-01-01", "status": "current", "rating": 8.1}, "start_date": "2026-01-01", "status": "current", "rating": 8.1},