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:
Roman
2026-09-25 14:48:29 +00:00
parent 248ec60d35
commit d747c574b8
12 changed files with 526 additions and 84 deletions
+71 -18
View File
@@ -154,7 +154,7 @@ async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
result = (await service.latest_by_type(limit=10))["anime"]
titles = [item["title"] for item in result]
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
@@ -186,14 +186,14 @@ async def test_latest_orders_by_kitsu_start_date(monkeypatch):
service = DiscoverService()
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
result = await service.latest(limit=10)
result = (await service.latest_by_type(limit=10))["anime"]
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
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)."""
async def test_latest_by_type_keeps_every_type(monkeypatch):
"""Chaque type garde son rail complet : les animés nombreux ne vicient pas
le rail séries (les séries n'existent pas chez Kitsu, donc sans date)."""
import app.services.discover as discover_module
animes = [
@@ -227,9 +227,11 @@ async def test_latest_serie_preference_keeps_undated_titles(monkeypatch):
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
rails = await service.latest_by_type(limit=24)
assert len(rails["anime"]) == 24 # tronqué à la limite
assert len(rails["serie"]) == 9 # les 9 séries intactes, malgré 30 animés
assert all(item["media_type"] == "serie" for item in rails["serie"])
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
@@ -253,8 +255,8 @@ async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
service = DiscoverService()
assert await service.latest() == []
assert await service.latest() == []
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert await service.latest_by_type() == {"anime": [], "serie": [], "film": []}
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
@@ -375,11 +377,18 @@ 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, 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},
]
async def fake_latest_by_type(limit: int = 24):
return {
"anime": [
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
"media_type": "anime", "start_date": "2026-01-01", "status": "current", "rating": 8.1},
],
"serie": [
{"source": "french_stream", "label": "French-Stream", "source_id": "b",
"title": "S", "media_type": "serie"},
],
"film": [],
}
async def fake_must_watch(limit: int = 20):
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
@@ -387,19 +396,63 @@ async def test_api_discover_sections(client, admin_cookies, monkeypatch):
async def fake_for_you(user_id: int, limit: int = 20):
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
monkeypatch.setattr(discover, "latest", fake_latest)
monkeypatch.setattr(discover, "latest_by_type", fake_latest_by_type)
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
monkeypatch.setattr(discover, "for_you", fake_for_you)
r = await client.get("/api/discover", cookies=admin_cookies)
assert r.status_code == 200
data = r.json()
assert data["latest"][0]["label"] == "Vostfree"
assert data["latest"][0]["start_date"] == "2026-01-01"
assert data["latest_anime"][0]["label"] == "Vostfree"
assert data["latest_anime"][0]["start_date"] == "2026-01-01"
assert data["latest_serie"][0]["label"] == "French-Stream"
assert data["must_watch"][0]["title"] == "Attack on Titan"
assert data["for_you"]["based_on"] == ["Action"]
async def test_api_browse(client, admin_cookies, monkeypatch):
"""Explorer : animés via Kitsu, séries via French-Stream, hors préférence → vide."""
async def fake_browse_anime(genre: str, limit: int = 20):
assert genre == "comedy"
return [{"kitsu_id": "1", "title": "Nichijou"}]
async def fake_browse_serie_film(media_type: str, genre: str, limit: int = 24):
assert (media_type, genre) == ("serie", "medical")
return [{"source": "french_stream", "source_id": "9", "title": "STAT - Saison 5",
"media_type": "serie"}]
monkeypatch.setattr(discover, "browse_anime", fake_browse_anime)
monkeypatch.setattr(discover, "browse_serie_film", fake_browse_serie_film)
r = await client.get(
"/api/discover/browse", params={"type": "anime", "genre": "comedy"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "Nichijou"
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"][0]["title"] == "STAT - Saison 5"
# Préférence animés : le parcours séries renvoie vide sans même scraper
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
r = await client.get(
"/api/discover/browse", params={"type": "serie", "genre": "medical"}, cookies=admin_cookies
)
assert r.json()["items"] == []
async def test_api_genres_catalog(client, admin_cookies):
"""Catalogue de genres : animés + séries/films French-Stream en mode « les deux »."""
r = await client.get("/api/discover/genres", cookies=admin_cookies)
assert r.status_code == 200
catalog = r.json()
assert {"anime", "serie", "film"} <= set(catalog)
assert {"key": "thriller", "label": "Thriller"} in catalog["serie"]
assert any(g["key"] == "comedies" for g in catalog["film"])
async def test_discover_page_renders(client, admin_cookies):
r = await client.get("/discover", cookies=admin_cookies)
assert r.status_code == 200