Files
MoviePilot/app/modules/anilist/anilist.py
T

800 lines
27 KiB
Python

from datetime import date
from typing import Optional
from app.runtime.cache import cached
from app.runtime.config import settings
from app.runtime.log import logger
from app.adapters.network.http import AsyncRequestUtils, RequestUtils
class AniListApi:
"""
AniList 中文 GraphQL API 客户端
"""
_base_url = "https://trace.moe/anilist/"
_official_url = "https://graphql.anilist.co"
_translations_url = (
"https://raw.githubusercontent.com/soruly/anilist-chinese/"
"master/anilist-chinese.json"
)
_media_summary_fields = """
id
idMal
title { romaji english native }
format
status
description(asHtml: false)
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
duration
countryOfOrigin
coverImage { extraLarge large }
bannerImage
genres
synonyms
averageScore
popularity
isAdult
siteUrl
studios(isMain: true) { nodes { name } }
"""
_media_fields = f"""
{_media_summary_fields}
staff(perPage: 25, sort: [RELEVANCE]) {{
edges {{ role node {{ id name {{ full native }} image {{ large }} siteUrl }} }}
}}
characters(perPage: 25, sort: [ROLE, RELEVANCE]) {{
edges {{
role
node {{ id name {{ full native }} image {{ large }} siteUrl }}
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {{
id
name {{ full native alternative }}
image {{ large medium }}
siteUrl
}}
}}
}}
externalLinks {{ site url type }}
"""
_page_query = f"""
query (
$page: Int!,
$count: Int!,
$search: String,
$genre: String,
$format: MediaFormat,
$season: MediaSeason,
$seasonYear: Int,
$status: MediaStatus,
$country: CountryCode,
$sort: [MediaSort]
) {{
Page(page: $page, perPage: $count) {{
media(
search: $search,
type: ANIME,
genre: $genre,
format: $format,
season: $season,
seasonYear: $seasonYear,
status: $status,
countryOfOrigin: $country,
isAdult: false,
sort: $sort
) {{ {_media_summary_fields} }}
}}
}}
"""
_media_by_ids_query = f"""
query ($ids: [Int!]!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(id_in: $ids, type: ANIME) {{ {_media_summary_fields} }}
}}
}}
"""
def __init__(self) -> None:
"""初始化同步与异步请求客户端"""
headers = {
"User-Agent": settings.NORMAL_USER_AGENT,
"Accept": "application/json",
"Content-Type": "application/json",
}
self._request = RequestUtils(
proxies=settings.PROXY,
headers=headers,
)
self._async_request = AsyncRequestUtils(
proxies=settings.PROXY,
headers=headers,
)
self._proxy_available = True
self._translations: Optional[dict[int, dict]] = None
@staticmethod
def _extract_response(response) -> Optional[dict]:
"""
提取 GraphQL 响应数据并统一处理上游错误。
:param response: HTTP 响应对象
:return: GraphQL data 字段
"""
if response is None or response.status_code != 200:
return None
try:
result = response.json()
except Exception as err:
logger.error(f"解析 AniList 响应失败:{str(err)}")
return None
if result.get("errors"):
logger.warning(f"AniList 接口返回错误:{result.get('errors')}")
return None
return result.get("data")
def _invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行同步 GraphQL 请求。
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
payload = {"query": query, "variables": variables}
if self._proxy_available:
response = self._request.post_res(self._base_url, json=payload)
result = self._extract_response(response)
if result is not None:
return self._inject_chinese(result, self._translation_map())
self._disable_proxy(response)
response = self._request.post_res(self._official_url, json=payload)
result = self._extract_response(response)
return self._inject_chinese(result, self._translation_map()) if result else result
async def _async_invoke(self, query: str, variables: dict) -> Optional[dict]:
"""
执行异步 GraphQL 请求。
:param query: GraphQL 查询
:param variables: 查询变量
:return: GraphQL data 字段
"""
payload = {"query": query, "variables": variables}
if self._proxy_available:
response = await self._async_request.post_res(self._base_url, json=payload)
result = self._extract_response(response)
if result is not None:
translations = await self._async_translation_map()
return self._inject_chinese(result, translations)
self._disable_proxy(response)
response = await self._async_request.post_res(self._official_url, json=payload)
result = self._extract_response(response)
if not result:
return result
translations = await self._async_translation_map()
return self._inject_chinese(result, translations)
def _disable_proxy(self, response) -> None:
"""
标记中文代理不可用,避免当前进程持续请求已失效的上游。
:param response: 中文代理响应对象
"""
self._proxy_available = False
status_code = getattr(response, "status_code", None)
logger.warning(
f"anilist-chinese 代理不可用(HTTP {status_code}),"
"改用 AniList 官方接口并合并中文数据集"
)
@staticmethod
def _build_translation_map(items) -> dict[int, dict]:
"""
将 anilist-chinese 数据集转换为按 AniList ID 索引的字典。
:param items: anilist-chinese JSON 数据
:return: 中文标题数据索引
"""
if not isinstance(items, list):
return {}
return {
item.get("id"): item
for item in items
if isinstance(item, dict) and item.get("id")
}
def _translation_map(self) -> dict[int, dict]:
"""
同步加载并复用 anilist-chinese 中文标题数据。
:return: 中文标题数据索引
"""
if self._translations is None:
items = self._request.get_json(self._translations_url)
self._translations = self._build_translation_map(items)
if not self._translations:
logger.warning("加载 anilist-chinese 中文数据集失败")
return self._translations
async def _async_translation_map(self) -> dict[int, dict]:
"""
异步加载并复用 anilist-chinese 中文标题数据。
:return: 中文标题数据索引
"""
if self._translations is None:
items = await self._async_request.get_json(self._translations_url)
self._translations = self._build_translation_map(items)
if not self._translations:
logger.warning("加载 anilist-chinese 中文数据集失败")
return self._translations
@classmethod
def _inject_chinese(cls, value, translations: dict[int, dict]):
"""
递归合并 anilist-chinese 标题,覆盖代理不会处理的嵌套媒体。
:param value: AniList GraphQL data 字段或其子节点
:param translations: 中文标题数据索引
:return: 合并中文标题后的原数据结构
"""
if isinstance(value, list):
for item in value:
cls._inject_chinese(item, translations)
return value
if not isinstance(value, dict):
return value
translation = translations.get(value.get("id"))
title = value.get("title")
if translation and isinstance(title, dict):
title["chinese"] = translation.get("title")
synonyms = value.get("synonyms")
if translation and isinstance(synonyms, list):
value["synonyms"] = list(
dict.fromkeys([*synonyms, *(translation.get("synonyms") or [])])
)
for child in value.values():
cls._inject_chinese(child, translations)
return value
@staticmethod
def _page_variables(
page: int,
count: int,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> dict:
"""
构造 AniList 分页媒体查询变量。
:return: 去除空值后的 GraphQL 变量
"""
variables = {
"page": page,
"count": count,
"search": search,
"genre": genre,
"format": media_format,
"season": season,
"seasonYear": season_year,
"status": status,
"country": country,
"sort": [sort] if sort else ["POPULARITY_DESC"],
}
return {key: value for key, value in variables.items() if value is not None}
@staticmethod
def _page_medias(result: Optional[dict]) -> list[dict]:
"""
从分页响应中提取媒体列表。
:param result: GraphQL data 字段
:return: AniList 媒体列表
"""
return result.get("Page", {}).get("media") or [] if result else []
@staticmethod
def _ordered_medias(media_ids: list[int], medias: list[dict]) -> list[dict]:
"""
按上游关系顺序重排批量查询返回的媒体。
:param media_ids: 关系查询返回的 AniList 媒体 ID
:param medias: Page.media 批量查询结果
:return: 保持原关系顺序的媒体列表
"""
media_map = {media.get("id"): media for media in medias if media.get("id")}
return [media_map[media_id] for media_id in media_ids if media_id in media_map]
def _medias_by_ids(self, media_ids: list[int]) -> list[dict]:
"""
通过根级 Page.media 批量查询媒体,使中文代理能够注入标题。
:param media_ids: AniList 媒体 ID 列表
:return: 按输入顺序排列的媒体列表
"""
unique_ids = list(dict.fromkeys(media_id for media_id in media_ids if media_id))
if not unique_ids:
return []
result = self._invoke(
self._media_by_ids_query,
{"ids": unique_ids, "count": len(unique_ids)},
)
return self._ordered_medias(media_ids, self._page_medias(result))
async def _async_medias_by_ids(self, media_ids: list[int]) -> list[dict]:
"""
异步通过根级 Page.media 批量查询媒体,使中文代理能够注入标题。
:param media_ids: AniList 媒体 ID 列表
:return: 按输入顺序排列的媒体列表
"""
unique_ids = list(dict.fromkeys(media_id for media_id in media_ids if media_id))
if not unique_ids:
return []
result = await self._async_invoke(
self._media_by_ids_query,
{"ids": unique_ids, "count": len(unique_ids)},
)
return self._ordered_medias(media_ids, self._page_medias(result))
@staticmethod
def _current_season(today: Optional[date] = None) -> tuple[str, int]:
"""
根据当前日期计算 AniList 季度与年份。
:param today: 用于测试或指定季度的日期
:return: AniList 季度枚举和年份
"""
current = today or date.today()
seasons = ("WINTER", "SPRING", "SUMMER", "FALL")
return seasons[(current.month - 1) // 3], current.year
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="detail",
)
def detail(self, anilist_id: int) -> Optional[dict]:
"""
根据 AniList ID 获取动画详情。
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = self._invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="detail",
)
async def async_detail(self, anilist_id: int) -> Optional[dict]:
"""
异步根据 AniList ID 获取动画详情。
:param anilist_id: AniList 媒体 ID
:return: AniList 媒体详情
"""
query = f"query ($id: Int!) {{ Media(id: $id, type: ANIME) {{ {self._media_fields} }} }}"
result = await self._async_invoke(query, {"id": anilist_id})
return result.get("Media") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="search",
)
def search(self, name: str, count: int = 20) -> list[dict]:
"""
按标题搜索 AniList 动画。
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = self._invoke(query, {"search": name, "count": count})
return self._page_medias(result)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="search",
)
async def async_search(self, name: str, count: int = 20) -> list[dict]:
"""
异步按标题搜索 AniList 动画。
:param name: 动画标题
:param count: 返回条数
:return: AniList 媒体列表
"""
query = f"""
query ($search: String!, $count: Int!) {{
Page(page: 1, perPage: $count) {{
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {{ {self._media_fields} }}
}}
}}
"""
result = await self._async_invoke(query, {"search": name, "count": count})
return self._page_medias(result)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="discover",
)
def discover(
self,
page: int = 1,
count: int = 20,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> list[dict]:
"""
按组合条件探索 AniList 动画。
:return: AniList 媒体列表
"""
variables = self._page_variables(
page=page,
count=count,
search=search,
genre=genre,
media_format=media_format,
season=season,
season_year=season_year,
status=status,
country=country,
sort=sort,
)
return self._page_medias(self._invoke(self._page_query, variables))
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="discover",
)
async def async_discover(
self,
page: int = 1,
count: int = 20,
search: Optional[str] = None,
genre: Optional[str] = None,
media_format: Optional[str] = None,
season: Optional[str] = None,
season_year: Optional[int] = None,
status: Optional[str] = None,
country: Optional[str] = None,
sort: Optional[str] = None,
) -> list[dict]:
"""
异步按组合条件探索 AniList 动画。
:return: AniList 媒体列表
"""
variables = self._page_variables(
page=page,
count=count,
search=search,
genre=genre,
media_format=media_format,
season=season,
season_year=season_year,
status=status,
country=country,
sort=sort,
)
result = await self._async_invoke(self._page_query, variables)
return self._page_medias(result)
def trending(self, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 当前趋势榜。
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
return self.discover(page=page, count=count, sort="TRENDING_DESC")
async def async_trending(self, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 当前趋势榜。
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
return await self.async_discover(page=page, count=count, sort="TRENDING_DESC")
def popular_this_season(self, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 本季热门榜。
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
season, season_year = self._current_season()
return self.discover(
page=page,
count=count,
season=season,
season_year=season_year,
sort="POPULARITY_DESC",
)
async def async_popular_this_season(self, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 本季热门榜。
:param page: 页码
:param count: 每页条数
:return: AniList 媒体列表
"""
season, season_year = self._current_season()
return await self.async_discover(
page=page,
count=count,
season=season,
season_year=season_year,
sort="POPULARITY_DESC",
)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="credits",
)
def credits(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 动画的日语配音演员。
:return: AniList 人物边列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
characters(page: $page, perPage: $count, sort: [ROLE, RELEVANCE]) {
edges {
role
node { id name { full native } }
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
id name { full native alternative } image { large medium } siteUrl
}
}
}
}
}
"""
result = self._invoke(query, {"id": anilist_id, "page": page, "count": count})
return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else []
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="credits",
)
async def async_credits(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 动画的日语配音演员。
:return: AniList 人物边列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
characters(page: $page, perPage: $count, sort: [ROLE, RELEVANCE]) {
edges {
role
node { id name { full native } }
voiceActors(language: JAPANESE, sort: [RELEVANCE]) {
id name { full native alternative } image { large medium } siteUrl
}
}
}
}
}
"""
result = await self._async_invoke(query, {"id": anilist_id, "page": page, "count": count})
return result.get("Media", {}).get("characters", {}).get("edges") or [] if result else []
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="recommendations",
)
def recommendations(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 动画相关推荐。
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
recommendations(page: $page, perPage: $count, sort: [RATING_DESC, ID]) {
nodes { mediaRecommendation { id } }
}
}
}
"""
result = self._invoke(query, {"id": anilist_id, "page": page, "count": count})
nodes = result.get("Media", {}).get("recommendations", {}).get("nodes") or [] if result else []
media_ids = [node.get("mediaRecommendation", {}).get("id") for node in nodes]
return self._medias_by_ids(media_ids)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="recommendations",
)
async def async_recommendations(self, anilist_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 动画相关推荐。
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Media(id: $id, type: ANIME) {
recommendations(page: $page, perPage: $count, sort: [RATING_DESC, ID]) {
nodes { mediaRecommendation { id } }
}
}
}
"""
result = await self._async_invoke(query, {"id": anilist_id, "page": page, "count": count})
nodes = result.get("Media", {}).get("recommendations", {}).get("nodes") or [] if result else []
media_ids = [node.get("mediaRecommendation", {}).get("id") for node in nodes]
return await self._async_medias_by_ids(media_ids)
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_detail",
)
def person_detail(self, person_id: int) -> Optional[dict]:
"""
获取 AniList 演员详情。
:param person_id: AniList 人物 ID
:return: AniList 人物详情
"""
query = """
query ($id: Int!) {
Staff(id: $id) {
id name { full native alternative } image { large medium }
description(asHtml: false) dateOfBirth { year month day }
dateOfDeath { year month day } gender homeTown primaryOccupations siteUrl
}
}
"""
result = self._invoke(query, {"id": person_id})
return result.get("Staff") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_detail",
)
async def async_person_detail(self, person_id: int) -> Optional[dict]:
"""
异步获取 AniList 演员详情。
:param person_id: AniList 人物 ID
:return: AniList 人物详情
"""
query = """
query ($id: Int!) {
Staff(id: $id) {
id name { full native alternative } image { large medium }
description(asHtml: false) dateOfBirth { year month day }
dateOfDeath { year month day } gender homeTown primaryOccupations siteUrl
}
}
"""
result = await self._async_invoke(query, {"id": person_id})
return result.get("Staff") if result else None
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_credits",
)
def person_credits(self, person_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
获取 AniList 演员参与的动画作品。
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Staff(id: $id) {
characterMedia(page: $page, perPage: $count, sort: [POPULARITY_DESC]) {
nodes { id }
}
}
}
"""
result = self._invoke(query, {"id": person_id, "page": page, "count": count})
nodes = result.get("Staff", {}).get("characterMedia", {}).get("nodes") or [] if result else []
return self._medias_by_ids([node.get("id") for node in nodes])
@cached(
maxsize=settings.CONF.anilist,
ttl=settings.CONF.meta,
skip_empty=True,
shared_key="person_credits",
)
async def async_person_credits(self, person_id: int, page: int = 1, count: int = 20) -> list[dict]:
"""
异步获取 AniList 演员参与的动画作品。
:return: AniList 媒体列表
"""
query = """
query ($id: Int!, $page: Int!, $count: Int!) {
Staff(id: $id) {
characterMedia(page: $page, perPage: $count, sort: [POPULARITY_DESC]) {
nodes { id }
}
}
}
"""
result = await self._async_invoke(query, {"id": person_id, "page": page, "count": count})
nodes = result.get("Staff", {}).get("characterMedia", {}).get("nodes") or [] if result else []
return await self._async_medias_by_ids([node.get("id") for node in nodes])
def clear_cache(self) -> None:
"""清理 AniList 接口缓存"""
for method in (
self.detail,
self.search,
self.discover,
self.credits,
self.recommendations,
self.person_detail,
self.person_credits,
):
method.cache_clear()