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:
+71
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user