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