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
+18
View File
@@ -132,6 +132,24 @@ async def test_latest(monkeypatch):
assert results[0].title == "The Drop - Saison 1"
async def test_browse(monkeypatch):
"""Parcours par genre : page du genre scrapée, type forcé sur les résultats."""
async def fake_fetch_soup(url, **kwargs):
assert url.endswith("/medical-series-/")
return _soup(LATEST_HTML)
monkeypatch.setattr("app.scrapers.sources.french_stream.fetch_soup", fake_fetch_soup)
results = await FrenchStreamScraper().browse("serie", "medical")
# LATEST_HTML contient un film sans « Saison » : le type est forcé à serie
assert [r.media_type for r in results] == ["serie", "serie"]
assert results[0].title == "The Drop - Saison 1"
async def test_browse_unknown_category():
with pytest.raises(ScrapeError, match="Catégorie inconnue"):
await FrenchStreamScraper().browse("film", "inexistant")
async def test_get_details_serie(monkeypatch):
async def fake_fetch_soup(url, **kwargs):
assert "newsid=9562" in url
+14 -5
View File
@@ -90,7 +90,7 @@ async def test_search_anime_filters_serie(client, admin_cookies, monkeypatch):
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
types = {x["media_type"] for x in r.json()["results"]}
assert types == {"anime", "film"} # les films restent avec les animés
assert types == {"anime"} # le mode animés reste sur l'animation (films réels → côté séries)
async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypatch):
@@ -112,21 +112,30 @@ async def test_search_serie_skips_anime_sources(client, admin_cookies, monkeypat
r = await client.get("/api/search", params={"q": "test"}, cookies=admin_cookies)
assert r.status_code == 200
assert queried == [] # source anime-only ignorée
assert {x["media_type"] for x in r.json()["results"]} == {"serie"}
assert {x["media_type"] for x in r.json()["results"]} == {"serie", "film"}
# ---------------------------------------------------------------- filtrage découverte
async def test_discover_latest_filtered_by_preference(client, admin_cookies, monkeypatch):
"""Rails par type : chaque mode n'expose que ses rails (les films suivent les séries)."""
await _patch_sources(monkeypatch, MixedSource())
r = await client.get("/api/discover", cookies=admin_cookies)
assert {i["media_type"] for i in r.json()["latest"]} == {"anime", "serie", "film"}
data = r.json()
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
await client.put("/auth/preferences", json={"content_preference": "serie"}, cookies=admin_cookies)
r = await client.get("/api/discover", cookies=admin_cookies)
assert {i["media_type"] for i in r.json()["latest"]} == {"serie"}
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
assert data["latest_anime"] == []
assert {i["media_type"] for i in data["latest_serie"]} == {"serie", "film"}
await client.put("/auth/preferences", json={"content_preference": "anime"}, cookies=admin_cookies)
data = (await client.get("/api/discover", cookies=admin_cookies)).json()
assert {i["media_type"] for i in data["latest_anime"]} == {"anime"}
assert data["latest_serie"] == []
async def test_discover_hides_kitsu_sections_in_serie_mode(client, admin_cookies, monkeypatch):
"""Incontournables et Pour toi (Kitsu = animés) disparaissent en mode séries."""