mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
feat: add AniList rankings and discovery
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.api.endpoints import anilist as anilist_endpoint
|
||||
from app.core.context import MediaInfo
|
||||
from app.modules.anilist import AniListModule
|
||||
from app.modules.anilist.anilist import AniListApi
|
||||
|
||||
|
||||
def _media_info(anilist_id: int = 154587) -> dict:
|
||||
"""
|
||||
构造 AniList 榜单测试媒体。
|
||||
|
||||
:param anilist_id: AniList 媒体 ID
|
||||
:return: AniList 媒体信息
|
||||
"""
|
||||
return {
|
||||
"id": anilist_id,
|
||||
"title": {
|
||||
"romaji": "Sousou no Frieren",
|
||||
"english": "Frieren: Beyond Journey's End",
|
||||
"native": "葬送のフリーレン",
|
||||
"chinese": "葬送的芙莉莲",
|
||||
},
|
||||
"synonyms": ["葬送的芙莉莲"],
|
||||
"format": "TV",
|
||||
"startDate": {"year": 2023, "month": 9, "day": 29},
|
||||
"coverImage": {"large": "https://img.example/poster.jpg"},
|
||||
"genres": ["Fantasy"],
|
||||
"averageScore": 91,
|
||||
"isAdult": False,
|
||||
}
|
||||
|
||||
|
||||
def test_anilist_client_uses_chinese_proxy_and_forwards_discover_filters() -> None:
|
||||
"""AniList 探索应通过中文代理并原样传递组合过滤条件。"""
|
||||
client = AniListApi()
|
||||
client._invoke = Mock(return_value={"Page": {"media": [_media_info()]}})
|
||||
|
||||
medias = client.discover(
|
||||
page=2,
|
||||
count=24,
|
||||
search="Frieren",
|
||||
genre="Fantasy",
|
||||
media_format="TV",
|
||||
season="FALL",
|
||||
season_year=2023,
|
||||
status="FINISHED",
|
||||
country="JP",
|
||||
sort="SCORE_DESC",
|
||||
)
|
||||
|
||||
assert client._base_url == "https://trace.moe/anilist/"
|
||||
assert medias[0]["id"] == 154587
|
||||
variables = client._invoke.call_args.args[1]
|
||||
assert variables == {
|
||||
"page": 2,
|
||||
"count": 24,
|
||||
"search": "Frieren",
|
||||
"genre": "Fantasy",
|
||||
"format": "TV",
|
||||
"season": "FALL",
|
||||
"seasonYear": 2023,
|
||||
"status": "FINISHED",
|
||||
"country": "JP",
|
||||
"sort": ["SCORE_DESC"],
|
||||
}
|
||||
assert "id" in client._page_query
|
||||
assert "synonyms" in client._page_query
|
||||
|
||||
|
||||
def test_anilist_client_falls_back_and_merges_chinese_dataset() -> None:
|
||||
"""中文代理不可用时应回退官方接口并合并项目中文数据。"""
|
||||
proxy_response = Mock(status_code=403)
|
||||
official_media = _media_info()
|
||||
official_media["title"].pop("chinese")
|
||||
official_media["synonyms"] = ["Official Alias"]
|
||||
official_response = Mock(status_code=200)
|
||||
official_response.json.return_value = {
|
||||
"data": {"Page": {"media": [official_media]}}
|
||||
}
|
||||
client = AniListApi()
|
||||
client._request = Mock()
|
||||
client._request.post_res.side_effect = [proxy_response, official_response]
|
||||
client._request.get_json.return_value = [
|
||||
{
|
||||
"id": 154587,
|
||||
"title": "葬送的芙莉莲",
|
||||
"synonyms": ["Frieren at the Funeral"],
|
||||
}
|
||||
]
|
||||
|
||||
result = client._invoke("query", {"page": 1})
|
||||
|
||||
assert result["Page"]["media"][0]["title"]["chinese"] == "葬送的芙莉莲"
|
||||
assert result["Page"]["media"][0]["synonyms"] == [
|
||||
"Official Alias",
|
||||
"Frieren at the Funeral",
|
||||
]
|
||||
assert [call.args[0] for call in client._request.post_res.call_args_list] == [
|
||||
client._base_url,
|
||||
client._official_url,
|
||||
]
|
||||
client._request.get_json.assert_called_once_with(client._translations_url)
|
||||
assert client._proxy_available is False
|
||||
|
||||
|
||||
def test_anilist_async_client_falls_back_without_real_network() -> None:
|
||||
"""异步中文代理失败时也应使用官方接口和同一中文数据集。"""
|
||||
proxy_response = Mock(status_code=403)
|
||||
official_media = _media_info()
|
||||
official_media["title"].pop("chinese")
|
||||
official_response = Mock(status_code=200)
|
||||
official_response.json.return_value = {
|
||||
"data": {"Page": {"media": [official_media]}}
|
||||
}
|
||||
client = AniListApi()
|
||||
client._async_request = Mock()
|
||||
client._async_request.post_res = AsyncMock(
|
||||
side_effect=[proxy_response, official_response]
|
||||
)
|
||||
client._async_request.get_json = AsyncMock(
|
||||
return_value=[
|
||||
{"id": 154587, "title": "葬送的芙莉莲", "synonyms": []}
|
||||
]
|
||||
)
|
||||
|
||||
result = asyncio.run(client._async_invoke("query", {"page": 1}))
|
||||
|
||||
assert result["Page"]["media"][0]["title"]["chinese"] == "葬送的芙莉莲"
|
||||
assert [call.args[0] for call in client._async_request.post_res.call_args_list] == [
|
||||
client._base_url,
|
||||
client._official_url,
|
||||
]
|
||||
client._async_request.get_json.assert_awaited_once_with(client._translations_url)
|
||||
|
||||
|
||||
def test_anilist_current_season_maps_calendar_quarters() -> None:
|
||||
"""AniList 本季榜应按自然季度映射四季枚举。"""
|
||||
assert AniListApi._current_season(date(2026, 1, 15)) == ("WINTER", 2026)
|
||||
assert AniListApi._current_season(date(2026, 4, 15)) == ("SPRING", 2026)
|
||||
assert AniListApi._current_season(date(2026, 7, 15)) == ("SUMMER", 2026)
|
||||
assert AniListApi._current_season(date(2026, 10, 15)) == ("FALL", 2026)
|
||||
|
||||
|
||||
def test_anilist_nested_media_relations_are_requeried_through_page() -> None:
|
||||
"""相关推荐和人物作品应通过根级 Page.media 回查以触发中文标题注入。"""
|
||||
recommendation_client = AniListApi()
|
||||
recommendation_client._invoke = Mock(
|
||||
side_effect=[
|
||||
{
|
||||
"Media": {
|
||||
"recommendations": {
|
||||
"nodes": [
|
||||
{"mediaRecommendation": {"id": 20}},
|
||||
{"mediaRecommendation": {"id": 10}},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"Page": {"media": [_media_info(10), _media_info(20)]}},
|
||||
]
|
||||
)
|
||||
|
||||
recommendations = recommendation_client.recommendations(154587)
|
||||
|
||||
assert [media["id"] for media in recommendations] == [20, 10]
|
||||
assert recommendation_client._invoke.call_args_list[1].args[1] == {
|
||||
"ids": [20, 10],
|
||||
"count": 2,
|
||||
}
|
||||
assert "Page(page: 1" in recommendation_client._invoke.call_args_list[1].args[0]
|
||||
|
||||
credits_client = AniListApi()
|
||||
credits_client._invoke = Mock(
|
||||
side_effect=[
|
||||
{"Staff": {"characterMedia": {"nodes": [{"id": 30}]}}},
|
||||
{"Page": {"media": [_media_info(30)]}},
|
||||
]
|
||||
)
|
||||
|
||||
credits = credits_client.person_credits(95075)
|
||||
|
||||
assert [media["id"] for media in credits] == [30]
|
||||
person_query = credits_client._invoke.call_args_list[0].args[0]
|
||||
assert "characterMedia" in person_query
|
||||
assert "staffMedia" not in person_query
|
||||
assert "Page(page: 1" in credits_client._invoke.call_args_list[1].args[0]
|
||||
|
||||
|
||||
def test_anilist_async_person_credits_uses_character_media() -> None:
|
||||
"""异步人物作品查询应读取配音角色关联,而不是制作岗位关联。"""
|
||||
client = AniListApi()
|
||||
client._async_invoke = AsyncMock(
|
||||
side_effect=[
|
||||
{"Staff": {"characterMedia": {"nodes": [{"id": 31}]}}},
|
||||
{"Page": {"media": [_media_info(31)]}},
|
||||
]
|
||||
)
|
||||
|
||||
credits = asyncio.run(client.async_person_credits(95076))
|
||||
|
||||
assert [media["id"] for media in credits] == [31]
|
||||
person_query = client._async_invoke.call_args_list[0].args[0]
|
||||
assert "characterMedia" in person_query
|
||||
assert "staffMedia" not in person_query
|
||||
assert "Page(page: 1" in client._async_invoke.call_args_list[1].args[0]
|
||||
|
||||
|
||||
def test_anilist_credits_and_recommendations_use_separate_caches() -> None:
|
||||
"""相同分页参数的演员与推荐查询不得互相命中缓存。"""
|
||||
client = AniListApi()
|
||||
client._invoke = Mock(
|
||||
side_effect=[
|
||||
{"Media": {"characters": {"edges": [{"role": "MAIN"}]}}},
|
||||
{
|
||||
"Media": {
|
||||
"recommendations": {
|
||||
"nodes": [{"mediaRecommendation": {"id": 40}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{"Page": {"media": [_media_info(40)]}},
|
||||
]
|
||||
)
|
||||
|
||||
credits = client.credits(987654, page=1, count=20)
|
||||
recommendations = client.recommendations(987654, page=1, count=20)
|
||||
|
||||
assert credits == [{"role": "MAIN"}]
|
||||
assert [media["id"] for media in recommendations] == [40]
|
||||
assert client._invoke.call_count == 3
|
||||
|
||||
|
||||
def test_anilist_module_normalizes_voice_actor_and_person_detail() -> None:
|
||||
"""AniList 模块应把配音关系和人物详情转换为前端通用人物结构。"""
|
||||
module = AniListModule()
|
||||
module.anilist_api = Mock()
|
||||
module.anilist_api.credits.return_value = [
|
||||
{
|
||||
"node": {"name": {"full": "Frieren", "native": "フリーレン"}},
|
||||
"voiceActors": [
|
||||
{
|
||||
"id": 95075,
|
||||
"name": {
|
||||
"full": "Atsumi Tanezaki",
|
||||
"native": "種﨑敦美",
|
||||
"alternative": [],
|
||||
},
|
||||
"image": {"large": "https://img.example/actor.jpg"},
|
||||
"siteUrl": "https://anilist.co/staff/95075",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
module.anilist_api.person_detail.return_value = {
|
||||
"id": 95075,
|
||||
"name": {
|
||||
"full": "Atsumi Tanezaki",
|
||||
"native": "種﨑敦美",
|
||||
"alternative": ["Atsumi Tanezaki"],
|
||||
},
|
||||
"image": {"large": "https://img.example/actor.jpg"},
|
||||
"description": "日本声优",
|
||||
"dateOfBirth": {"year": 1990, "month": 9, "day": 27},
|
||||
"homeTown": "Oita",
|
||||
"primaryOccupations": ["Voice Actor"],
|
||||
}
|
||||
|
||||
credits = module.anilist_credits(154587)
|
||||
person = module.anilist_person_detail(95075)
|
||||
|
||||
assert credits[0].source == "anilist"
|
||||
assert credits[0].name == "種﨑敦美"
|
||||
assert credits[0].character == "フリーレン"
|
||||
assert credits[0].images["large"] == "https://img.example/actor.jpg"
|
||||
assert person is not None
|
||||
assert person.birthday == "1990-09-27"
|
||||
assert person.career == ["Voice Actor"]
|
||||
|
||||
|
||||
def test_anilist_discover_endpoint_forwards_all_filters(monkeypatch) -> None:
|
||||
"""AniList 独立探索端点应把全部过滤条件交给处理链。"""
|
||||
captured = {}
|
||||
chain = Mock()
|
||||
chain.async_discover = AsyncMock(
|
||||
return_value=[MediaInfo(anilist_info=_media_info())]
|
||||
)
|
||||
monkeypatch.setattr(anilist_endpoint, "AniListChain", lambda: chain)
|
||||
|
||||
result = asyncio.run(
|
||||
anilist_endpoint.anilist_discover(
|
||||
page=3,
|
||||
count=16,
|
||||
search="Frieren",
|
||||
genre="Fantasy",
|
||||
media_format="TV",
|
||||
season="FALL",
|
||||
season_year=2023,
|
||||
status="FINISHED",
|
||||
country="JP",
|
||||
sort="TRENDING_DESC",
|
||||
_=None,
|
||||
)
|
||||
)
|
||||
captured.update(chain.async_discover.await_args.kwargs)
|
||||
|
||||
assert captured == {
|
||||
"page": 3,
|
||||
"count": 16,
|
||||
"search": "Frieren",
|
||||
"genre": "Fantasy",
|
||||
"media_format": "TV",
|
||||
"season": "FALL",
|
||||
"season_year": 2023,
|
||||
"status": "FINISHED",
|
||||
"country": "JP",
|
||||
"sort": "TRENDING_DESC",
|
||||
}
|
||||
assert result[0].anilist_id == 154587
|
||||
|
||||
|
||||
def test_anilist_router_exposes_browse_and_deep_navigation_paths() -> None:
|
||||
"""AniList 独立路由应覆盖榜单、探索、作品、演员和相关推荐。"""
|
||||
paths = {route.path for route in anilist_endpoint.router.routes}
|
||||
|
||||
assert paths == {
|
||||
"/trending",
|
||||
"/popular-this-season",
|
||||
"/discover",
|
||||
"/credits/{anilist_id}",
|
||||
"/recommend/{anilist_id}",
|
||||
"/person/{person_id}",
|
||||
"/person/credits/{person_id}",
|
||||
"/{anilist_id}",
|
||||
}
|
||||
Reference in New Issue
Block a user