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:
"""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:
+25 -5
View File
@@ -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:
+40 -1
View File
@@ -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},