diff --git a/app/api/apiv1.py b/app/api/apiv1.py index 2c177c2f6..0ecad8e44 100644 --- a/app/api/apiv1.py +++ b/app/api/apiv1.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.endpoints import auth, login, user, webhook, message, agent, site, subscribe, \ +from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \ transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification @@ -29,6 +29,7 @@ api_router.include_router(storage.router, prefix="/storage", tags=["storage"]) api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"]) api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"]) api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"]) +api_router.include_router(anilist.router, prefix="/anilist", tags=["anilist"]) api_router.include_router(discover.router, prefix="/discover", tags=["discover"]) api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"]) api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"]) diff --git a/app/api/endpoints/anilist.py b/app/api/endpoints/anilist.py new file mode 100644 index 000000000..32dee065f --- /dev/null +++ b/app/api/endpoints/anilist.py @@ -0,0 +1,169 @@ +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, Query + +from app import schemas +from app.chain.anilist import AniListChain +from app.core.context import MediaInfo +from app.core.security import verify_token + +router = APIRouter() + +PageParam = Annotated[int, Query(ge=1)] +CountParam = Annotated[int, Query(ge=1, le=50)] + + +def _serialize_medias(medias: list[MediaInfo]) -> list[schemas.MediaInfo]: + """ + 将内部媒体对象转换为 REST 响应模型。 + + :param medias: 统一媒体信息列表 + :return: REST 媒体响应列表 + """ + return [schemas.MediaInfo(**media.to_dict()) for media in medias] + + +@router.get( + "/trending", + summary="查询 AniList 当前趋势榜", + response_model=list[schemas.MediaInfo], +) +async def anilist_trending( + page: PageParam = 1, + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaInfo]: + """查询 AniList TRENDING NOW 榜单""" + medias = await AniListChain().async_trending(page=page, count=count) + return _serialize_medias(medias) + + +@router.get( + "/popular-this-season", + summary="查询 AniList 本季热门榜", + response_model=list[schemas.MediaInfo], +) +async def anilist_popular_this_season( + page: PageParam = 1, + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaInfo]: + """查询 AniList POPULAR THIS SEASON 榜单""" + medias = await AniListChain().async_popular_this_season(page=page, count=count) + return _serialize_medias(medias) + + +@router.get( + "/discover", + summary="探索 AniList 动画", + response_model=list[schemas.MediaInfo], +) +async def anilist_discover( + page: PageParam = 1, + count: CountParam = 20, + search: Optional[str] = None, + genre: Optional[str] = None, + media_format: Optional[str] = Query(None, alias="format"), + season: Optional[str] = None, + season_year: Optional[int] = None, + status: Optional[str] = None, + country: Optional[str] = None, + sort: Optional[str] = None, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaInfo]: + """按标题、类型、风格、季度、年份、状态、地区和排序探索 AniList 动画""" + medias = await AniListChain().async_discover( + 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 _serialize_medias(medias) + + +@router.get( + "/credits/{anilist_id}", + summary="查询 AniList 配音演员", + response_model=list[schemas.MediaPerson], +) +async def anilist_credits( + anilist_id: int, + page: PageParam = 1, + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaPerson]: + """查询 AniList 动画的日语配音演员""" + return await AniListChain().async_credits( + anilist_id=anilist_id, page=page, count=count + ) + + +@router.get( + "/recommend/{anilist_id}", + summary="查询 AniList 相关推荐", + response_model=list[schemas.MediaInfo], +) +async def anilist_recommendations( + anilist_id: int, + page: PageParam = 1, + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaInfo]: + """查询 AniList 动画相关推荐""" + medias = await AniListChain().async_recommendations( + anilist_id=anilist_id, page=page, count=count + ) + return _serialize_medias(medias) + + +@router.get( + "/person/{person_id}", + summary="查询 AniList 人物详情", + response_model=schemas.MediaPerson, +) +async def anilist_person( + person_id: int, + _: schemas.TokenPayload = Depends(verify_token), +) -> Optional[schemas.MediaPerson]: + """根据 AniList 人物 ID 查询详情""" + return await AniListChain().async_person_detail(person_id=person_id) + + +@router.get( + "/person/credits/{person_id}", + summary="查询 AniList 人物作品", + response_model=list[schemas.MediaInfo], +) +async def anilist_person_credits( + person_id: int, + page: PageParam = 1, + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MediaInfo]: + """查询 AniList 人物参与的动画作品""" + medias = await AniListChain().async_person_credits( + person_id=person_id, page=page, count=count + ) + return _serialize_medias(medias) + + +@router.get( + "/{anilist_id}", + summary="查询 AniList 动画详情", + response_model=schemas.MediaInfo, +) +async def anilist_info( + anilist_id: int, + _: schemas.TokenPayload = Depends(verify_token), +) -> schemas.MediaInfo: + """根据 AniList 媒体 ID 查询动画详情""" + info = await AniListChain().async_info(anilist_id) + if not info: + return schemas.MediaInfo() + return schemas.MediaInfo(**MediaInfo(anilist_info=info).to_dict()) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 65bed9ad8..3c39b6130 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -160,7 +160,15 @@ async def search( _: schemas.TokenPayload = Depends(verify_token), ) -> Any: """ - 模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息 + 模糊搜索媒体、合集或人物信息列表。 + + :param title: 搜索关键词 + :param type: 搜索类型,支持 media、collection、person + :param page: 页码 + :param count: 每页数量 + :param source: 请求级搜索数据源 + :param _: Token校验 + :return: 搜索结果列表 """ def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]): @@ -176,12 +184,14 @@ async def search( _, medias = await media_chain.async_search(title=title, source=source) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": - collections = await media_chain.async_search_collections(name=title) + collections = await media_chain.async_search_collections( + name=title, source=source + ) result = ( [collection.to_dict() for collection in collections] if collections else [] ) else: # person - persons = await media_chain.async_search_persons(name=title) + persons = await media_chain.async_search_persons(name=title, source=source) result = [person.model_dump() for person in persons] if persons else [] if not result: diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 20d4ede9a..d1d93f692 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -1122,33 +1122,53 @@ class ChainBase(metaclass=ABCMeta): "async_search_medias", meta=meta, source=source ) - def search_persons(self, name: str) -> Optional[List[MediaPerson]]: + def search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - return self.run_module("search_persons", name=name) + return self.run_module("search_persons", name=name, source=source) - async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]: + async def async_search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - return await self.async_run_module("async_search_persons", name=name) + return await self.async_run_module( + "async_search_persons", name=name, source=source + ) - def search_collections(self, name: str) -> Optional[List[MediaInfo]]: + def search_collections( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaInfo]]: """ 搜索集合信息 :param name: 集合名称 + :param source: 请求级搜索数据源 + :return: 合集信息列表 """ - return self.run_module("search_collections", name=name) + return self.run_module("search_collections", name=name, source=source) - async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]: + async def async_search_collections( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaInfo]]: """ 搜索集合信息(异步版本) :param name: 集合名称 + :param source: 请求级搜索数据源 + :return: 合集信息列表 """ - return await self.async_run_module("async_search_collections", name=name) + return await self.async_run_module( + "async_search_collections", name=name, source=source + ) def get_search_page_size( self, diff --git a/app/chain/anilist.py b/app/chain/anilist.py new file mode 100644 index 000000000..3cd77bbeb --- /dev/null +++ b/app/chain/anilist.py @@ -0,0 +1,181 @@ +from typing import Optional + +from app import schemas +from app.chain import ChainBase +from app.core.context import MediaInfo + + +class AniListChain(ChainBase): + """ + AniList 榜单、探索与深度浏览处理链 + """ + + def info(self, anilist_id: int) -> Optional[dict]: + """ + 获取 AniList 动画详情。 + + :param anilist_id: AniList 媒体 ID + :return: AniList 媒体详情 + """ + return self.run_module("anilist_info", anilist_id=anilist_id) + + async def async_info(self, anilist_id: int) -> Optional[dict]: + """ + 异步获取 AniList 动画详情。 + + :param anilist_id: AniList 媒体 ID + :return: AniList 媒体详情 + """ + return await self.async_run_module("async_anilist_info", anilist_id=anilist_id) + + def trending(self, page: int = 1, count: int = 20) -> list[MediaInfo]: + """ + 获取 AniList 当前趋势榜。 + + :return: 统一媒体信息列表 + """ + return self.run_module("anilist_trending", page=page, count=count) or [] + + async def async_trending(self, page: int = 1, count: int = 20) -> list[MediaInfo]: + """ + 异步获取 AniList 当前趋势榜。 + + :return: 统一媒体信息列表 + """ + return await self.async_run_module( + "async_anilist_trending", page=page, count=count + ) or [] + + def popular_this_season(self, page: int = 1, count: int = 20) -> list[MediaInfo]: + """ + 获取 AniList 本季热门榜。 + + :return: 统一媒体信息列表 + """ + return self.run_module( + "anilist_popular_this_season", page=page, count=count + ) or [] + + async def async_popular_this_season( + self, page: int = 1, count: int = 20 + ) -> list[MediaInfo]: + """ + 异步获取 AniList 本季热门榜。 + + :return: 统一媒体信息列表 + """ + return await self.async_run_module( + "async_anilist_popular_this_season", page=page, count=count + ) or [] + + def discover(self, **kwargs) -> list[MediaInfo]: + """ + 按组合条件探索 AniList 动画。 + + :return: 统一媒体信息列表 + """ + return self.run_module("anilist_discover", **kwargs) or [] + + async def async_discover(self, **kwargs) -> list[MediaInfo]: + """ + 异步按组合条件探索 AniList 动画。 + + :return: 统一媒体信息列表 + """ + return await self.async_run_module("async_anilist_discover", **kwargs) or [] + + def credits( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> list[schemas.MediaPerson]: + """ + 获取 AniList 动画配音演员。 + + :return: 媒体人物列表 + """ + return self.run_module( + "anilist_credits", anilist_id=anilist_id, page=page, count=count + ) or [] + + async def async_credits( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> list[schemas.MediaPerson]: + """ + 异步获取 AniList 动画配音演员。 + + :return: 媒体人物列表 + """ + return await self.async_run_module( + "async_anilist_credits", anilist_id=anilist_id, page=page, count=count + ) or [] + + def recommendations( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> list[MediaInfo]: + """ + 获取 AniList 动画相关推荐。 + + :return: 统一媒体信息列表 + """ + return self.run_module( + "anilist_recommendations", anilist_id=anilist_id, page=page, count=count + ) or [] + + async def async_recommendations( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> list[MediaInfo]: + """ + 异步获取 AniList 动画相关推荐。 + + :return: 统一媒体信息列表 + """ + return await self.async_run_module( + "async_anilist_recommendations", + anilist_id=anilist_id, + page=page, + count=count, + ) or [] + + def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + """ + 获取 AniList 人物详情。 + + :return: 媒体人物信息 + """ + return self.run_module("anilist_person_detail", person_id=person_id) + + async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + """ + 异步获取 AniList 人物详情。 + + :return: 媒体人物信息 + """ + return await self.async_run_module( + "async_anilist_person_detail", person_id=person_id + ) + + def person_credits( + self, person_id: int, page: int = 1, count: int = 20 + ) -> list[MediaInfo]: + """ + 获取 AniList 人物参与的动画作品。 + + :return: 统一媒体信息列表 + """ + return self.run_module( + "anilist_person_credits", person_id=person_id, page=page, count=count + ) or [] + + async def async_person_credits( + self, person_id: int, page: int = 1, count: int = 20 + ) -> list[MediaInfo]: + """ + 异步获取 AniList 人物参与的动画作品。 + + :return: 统一媒体信息列表 + """ + return await self.async_run_module( + "async_anilist_person_credits", + person_id=person_id, + page=page, + count=count, + ) or [] diff --git a/app/core/context.py b/app/core/context.py index 85cd8d292..13419c2da 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -11,6 +11,8 @@ from app.utils.string import StringUtils BANGUMI_MOVIE_PLATFORMS = frozenset({"movie", "电影", "剧场版"}) ANILIST_MOVIE_FORMATS = frozenset({"MOVIE"}) +ANILIST_CHINESE_TITLE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") +ANILIST_JAPANESE_KANA_PATTERN = re.compile(r"[\u3040-\u30ff]") @dataclass @@ -885,6 +887,30 @@ class MediaInfo: values.append(str(date_info.get("day")).zfill(2)) return "-".join(values) + @staticmethod + def _anilist_chinese_title(info: dict) -> Optional[str]: + """ + 从 anilist-chinese 注入的标题和别名中选择中文标题。 + + :param info: AniList 媒体信息 + :return: 中文标题,未找到时返回 None + """ + translated_title = (info.get("title") or {}).get("chinese") + if not translated_title: + return None + if ( + ANILIST_CHINESE_TITLE_PATTERN.search(str(translated_title)) + and not ANILIST_JAPANESE_KANA_PATTERN.search(str(translated_title)) + ): + return str(translated_title) + for synonym in reversed(info.get("synonyms") or []): + if ( + ANILIST_CHINESE_TITLE_PATTERN.search(str(synonym)) + and not ANILIST_JAPANESE_KANA_PATTERN.search(str(synonym)) + ): + return str(synonym) + return str(translated_title) + def set_anilist_info(self, info: dict) -> None: """ 初始化 AniList 媒体信息。 @@ -899,7 +925,13 @@ class MediaInfo: self.type = self.type or self.get_anilist_media_type(info) titles = info.get("title") or {} - self.title = self.title or titles.get("english") or titles.get("romaji") or titles.get("native") + self.title = ( + self.title + or self._anilist_chinese_title(info) + or titles.get("native") + or titles.get("romaji") + or titles.get("english") + ) self.en_title = self.en_title or titles.get("english") self.original_title = self.original_title or titles.get("native") or titles.get("romaji") self.names = list( diff --git a/app/modules/anilist/__init__.py b/app/modules/anilist/__init__.py index 67b33ed25..37a1222fa 100644 --- a/app/modules/anilist/__init__.py +++ b/app/modules/anilist/__init__.py @@ -1,5 +1,6 @@ from typing import List, Optional, Tuple, Union +from app import schemas from app.core.config import settings from app.core.context import MediaInfo from app.core.meta import MetaBase @@ -111,6 +112,7 @@ class AniListModule(_ModuleBase): continue actors.append( { + "id": actor.get("id"), "name": actor_name, "character": character.get("name", {}).get("full") or character.get("name", {}).get("native"), @@ -128,6 +130,7 @@ class AniListModule(_ModuleBase): staff = edge.get("node") or {} directors.append( { + "id": staff.get("id"), "name": staff.get("name", {}).get("full"), "job": role, "avatar": {"large": staff.get("image", {}).get("large")}, @@ -137,6 +140,79 @@ class AniListModule(_ModuleBase): enriched["directors"] = directors return enriched + @staticmethod + def _person_name(name_info: dict) -> Optional[str]: + """ + 按原语言、通用名顺序选择 AniList 人物姓名。 + + :param name_info: AniList 人物姓名字段 + :return: 可展示姓名 + """ + return name_info.get("native") or name_info.get("full") + + @staticmethod + def _person_date(date_info: dict) -> Optional[str]: + """ + 将 AniList 人物模糊日期转换为标准日期文本。 + + :param date_info: AniList FuzzyDate 字段 + :return: 日期文本 + """ + return MediaInfo._anilist_date(date_info) + + @classmethod + def _build_credit_person(cls, edge: dict) -> Optional[schemas.MediaPerson]: + """ + 将 AniList 角色配音关系转换为统一人物信息。 + + :param edge: AniList 角色关系边 + :return: 媒体人物信息 + """ + actor = next(iter(edge.get("voiceActors") or []), None) + if not actor: + return None + name_info = actor.get("name") or {} + character_name = (edge.get("node") or {}).get("name") or {} + images = actor.get("image") or {} + return schemas.MediaPerson( + source="anilist", + id=actor.get("id"), + name=cls._person_name(name_info), + original_name=name_info.get("full"), + also_known_as=name_info.get("alternative") or [], + character=character_name.get("native") or character_name.get("full"), + images=images, + avatar=images, + url=actor.get("siteUrl"), + ) + + @classmethod + def _build_person_detail(cls, info: dict) -> schemas.MediaPerson: + """ + 将 AniList 人物详情转换为统一人物信息。 + + :param info: AniList 人物详情 + :return: 媒体人物信息 + """ + name_info = info.get("name") or {} + images = info.get("image") or {} + return schemas.MediaPerson( + source="anilist", + id=info.get("id"), + name=cls._person_name(name_info), + original_name=name_info.get("full"), + also_known_as=name_info.get("alternative") or [], + images=images, + avatar=images, + biography=info.get("description"), + birthday=cls._person_date(info.get("dateOfBirth") or {}), + deathday=cls._person_date(info.get("dateOfDeath") or {}), + gender=info.get("gender"), + place_of_birth=info.get("homeTown"), + career=info.get("primaryOccupations") or [], + url=info.get("siteUrl"), + ) + def recognize_media( self, meta: MetaBase = None, @@ -267,6 +343,186 @@ class AniListModule(_ModuleBase): if self._matches_meta(meta, info) ] + def anilist_info(self, anilist_id: int) -> Optional[dict]: + """ + 获取 AniList 动画详情。 + + :param anilist_id: AniList 媒体 ID + :return: AniList 媒体详情 + """ + return self.anilist_api.detail(anilist_id) if anilist_id else None + + async def async_anilist_info(self, anilist_id: int) -> Optional[dict]: + """ + 异步获取 AniList 动画详情。 + + :param anilist_id: AniList 媒体 ID + :return: AniList 媒体详情 + """ + return await self.anilist_api.async_detail(anilist_id) if anilist_id else None + + def anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]: + """ + 获取 AniList 当前趋势榜。 + + :return: 统一媒体信息列表 + """ + return [ + MediaInfo(anilist_info=info) + for info in self.anilist_api.trending(page=page, count=count) + ] + + async def async_anilist_trending(self, page: int = 1, count: int = 20) -> List[MediaInfo]: + """ + 异步获取 AniList 当前趋势榜。 + + :return: 统一媒体信息列表 + """ + return [ + MediaInfo(anilist_info=info) + for info in await self.anilist_api.async_trending(page=page, count=count) + ] + + def anilist_popular_this_season(self, page: int = 1, count: int = 20) -> List[MediaInfo]: + """ + 获取 AniList 本季热门榜。 + + :return: 统一媒体信息列表 + """ + return [ + MediaInfo(anilist_info=info) + for info in self.anilist_api.popular_this_season(page=page, count=count) + ] + + async def async_anilist_popular_this_season( + self, page: int = 1, count: int = 20 + ) -> List[MediaInfo]: + """ + 异步获取 AniList 本季热门榜。 + + :return: 统一媒体信息列表 + """ + infos = await self.anilist_api.async_popular_this_season(page=page, count=count) + return [MediaInfo(anilist_info=info) for info in infos] + + def anilist_discover(self, **kwargs) -> List[MediaInfo]: + """ + 按组合条件探索 AniList 动画。 + + :return: 统一媒体信息列表 + """ + return [ + MediaInfo(anilist_info=info) + for info in self.anilist_api.discover(**kwargs) + ] + + async def async_anilist_discover(self, **kwargs) -> List[MediaInfo]: + """ + 异步按组合条件探索 AniList 动画。 + + :return: 统一媒体信息列表 + """ + return [ + MediaInfo(anilist_info=info) + for info in await self.anilist_api.async_discover(**kwargs) + ] + + def anilist_credits( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> List[schemas.MediaPerson]: + """ + 获取 AniList 动画配音演员。 + + :return: 媒体人物列表 + """ + persons = ( + self._build_credit_person(edge) + for edge in self.anilist_api.credits(anilist_id, page=page, count=count) + ) + return [person for person in persons if person] + + async def async_anilist_credits( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> List[schemas.MediaPerson]: + """ + 异步获取 AniList 动画配音演员。 + + :return: 媒体人物列表 + """ + edges = await self.anilist_api.async_credits(anilist_id, page=page, count=count) + persons = (self._build_credit_person(edge) for edge in edges) + return [person for person in persons if person] + + def anilist_recommendations( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> List[MediaInfo]: + """ + 获取 AniList 动画相关推荐。 + + :return: 统一媒体信息列表 + """ + infos = self.anilist_api.recommendations(anilist_id, page=page, count=count) + return [MediaInfo(anilist_info=info) for info in infos] + + async def async_anilist_recommendations( + self, anilist_id: int, page: int = 1, count: int = 20 + ) -> List[MediaInfo]: + """ + 异步获取 AniList 动画相关推荐。 + + :return: 统一媒体信息列表 + """ + infos = await self.anilist_api.async_recommendations( + anilist_id, page=page, count=count + ) + return [MediaInfo(anilist_info=info) for info in infos] + + def anilist_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]: + """ + 获取 AniList 人物详情。 + + :param person_id: AniList 人物 ID + :return: 媒体人物信息 + """ + info = self.anilist_api.person_detail(person_id) + return self._build_person_detail(info) if info else None + + async def async_anilist_person_detail( + self, person_id: int + ) -> Optional[schemas.MediaPerson]: + """ + 异步获取 AniList 人物详情。 + + :param person_id: AniList 人物 ID + :return: 媒体人物信息 + """ + info = await self.anilist_api.async_person_detail(person_id) + return self._build_person_detail(info) if info else None + + def anilist_person_credits( + self, person_id: int, page: int = 1, count: int = 20 + ) -> List[MediaInfo]: + """ + 获取 AniList 人物参与的动画作品。 + + :return: 统一媒体信息列表 + """ + infos = self.anilist_api.person_credits(person_id, page=page, count=count) + return [MediaInfo(anilist_info=info) for info in infos] + + async def async_anilist_person_credits( + self, person_id: int, page: int = 1, count: int = 20 + ) -> List[MediaInfo]: + """ + 异步获取 AniList 人物参与的动画作品。 + + :return: 统一媒体信息列表 + """ + infos = await self.anilist_api.async_person_credits( + person_id, page=page, count=count + ) + return [MediaInfo(anilist_info=info) for info in infos] + def metadata_nfo( self, mediainfo: MediaInfo, diff --git a/app/modules/anilist/anilist.py b/app/modules/anilist/anilist.py index c20135815..4856fa25f 100644 --- a/app/modules/anilist/anilist.py +++ b/app/modules/anilist/anilist.py @@ -1,3 +1,4 @@ +from datetime import date from typing import Optional from app.core.cache import cached @@ -8,11 +9,16 @@ from app.utils.http import AsyncRequestUtils, RequestUtils class AniListApi: """ - AniList GraphQL API 客户端 + AniList 中文 GraphQL API 客户端 """ - _base_url = "https://graphql.anilist.co" - _media_fields = """ + _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 } @@ -21,6 +27,7 @@ class AniListApi: description(asHtml: false) startDate { year month day } endDate { year month day } + season seasonYear episodes duration @@ -34,21 +41,61 @@ class AniListApi: isAdult siteUrl studios(isMain: true) { nodes { name } } - staff(perPage: 25, sort: [RELEVANCE]) { - edges { role node { name { full } image { large } siteUrl } } - } - characters(perPage: 25, sort: [ROLE]) { - edges { + """ + _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 { name { full native } image { large } siteUrl } - voiceActors(language: JAPANESE, sort: [RELEVANCE]) { - name { full } - image { large } + 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 } + }} + }} + }} + 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: @@ -66,6 +113,8 @@ class AniListApi: proxies=settings.PROXY, headers=headers, ) + self._proxy_available = True + self._translations: Optional[dict[int, dict]] = None @staticmethod def _extract_response(response) -> Optional[dict]: @@ -95,11 +144,16 @@ class AniListApi: :param variables: 查询变量 :return: GraphQL data 字段 """ - response = self._request.post_res( - self._base_url, - json={"query": query, "variables": variables}, - ) - return self._extract_response(response) + 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]: """ @@ -109,13 +163,209 @@ class AniListApi: :param variables: 查询变量 :return: GraphQL data 字段 """ - response = await self._async_request.post_res( - self._base_url, - json={"query": query, "variables": variables}, - ) - return self._extract_response(response) + 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) - @cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get") + 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 获取动画详情。 @@ -127,7 +377,12 @@ class AniListApi: result = self._invoke(query, {"id": anilist_id}) return result.get("Media") if result else None - @cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get") + @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 获取动画详情。 @@ -139,7 +394,12 @@ class AniListApi: 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, shared_key="get") + @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 动画。 @@ -156,9 +416,14 @@ class AniListApi: }} """ result = self._invoke(query, {"search": name, "count": count}) - return result.get("Page", {}).get("media") or [] if result else [] + return self._page_medias(result) - @cached(maxsize=settings.CONF.anilist, ttl=settings.CONF.meta, shared_key="get") + @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 动画。 @@ -175,11 +440,360 @@ class AniListApi: }} """ result = await self._async_invoke(query, {"search": name, "count": count}) - return result.get("Page", {}).get("media") or [] if result else [] + 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 详情与搜索缓存""" - self.detail.cache_clear() - self.async_detail.cache_clear() - self.search.cache_clear() - self.async_search.cache_clear() + """清理 AniList 接口缓存""" + for method in ( + self.detail, + self.search, + self.discover, + self.credits, + self.recommendations, + self.person_detail, + self.person_credits, + ): + method.cache_clear() diff --git a/app/modules/douban/__init__.py b/app/modules/douban/__init__.py index 47c8ee5f5..8506d1dc1 100644 --- a/app/modules/douban/__init__.py +++ b/app/modules/douban/__init__.py @@ -975,11 +975,18 @@ class DoubanModule(_ModuleBase): # 返回数据 return self._build_search_medias_result(meta, result.get("items")) - def search_persons(self, name: str) -> Optional[List[MediaPerson]]: + def search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 + :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if source and source != "douban": + return None + if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not name: return [] @@ -995,11 +1002,18 @@ class DoubanModule(_ModuleBase): }) for item in result.get('items') if name in item.get('target', {}).get('title')] return [] - async def async_search_persons(self, name: str) -> Optional[List[MediaPerson]]: + async def async_search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) + :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: + if source and source != "douban": + return None + if not source and settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE: return None if not name: return [] diff --git a/app/modules/themoviedb/__init__.py b/app/modules/themoviedb/__init__.py index 6dae3abad..609041f17 100644 --- a/app/modules/themoviedb/__init__.py +++ b/app/modules/themoviedb/__init__.py @@ -769,11 +769,18 @@ class TheMovieDbModule(_ModuleBase): # 将搜索词中的季写入标题中 return self._build_search_medias_result(meta, results) - def search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]: + def search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[schemas.MediaPerson]]: """ 搜索人物信息 + :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if source and source != "themoviedb": + return None + if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not name: return [] @@ -782,11 +789,18 @@ class TheMovieDbModule(_ModuleBase): return [schemas.MediaPerson(source='themoviedb', **person) for person in results] return [] - async def async_search_persons(self, name: str) -> Optional[List[schemas.MediaPerson]]: + async def async_search_persons( + self, name: str, source: Optional[str] = None + ) -> Optional[List[schemas.MediaPerson]]: """ 异步搜索人物信息 + :param name: 人物名称 + :param source: 请求级搜索数据源 + :return: 人物信息列表 """ - if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: + if source and source != "themoviedb": + return None + if not source and settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE: return None if not name: return [] @@ -795,10 +809,17 @@ class TheMovieDbModule(_ModuleBase): return [schemas.MediaPerson(source='themoviedb', **person) for person in results] return [] - def search_collections(self, name: str) -> Optional[List[MediaInfo]]: + def search_collections( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaInfo]]: """ 搜索集合信息 + :param name: 合集名称 + :param source: 请求级搜索数据源 + :return: 合集信息列表 """ + if source and source != "themoviedb": + return None if not name: return [] results = self.tmdb.search_collections(name) @@ -806,10 +827,17 @@ class TheMovieDbModule(_ModuleBase): return [MediaInfo(tmdb_info=info) for info in results] return [] - async def async_search_collections(self, name: str) -> Optional[List[MediaInfo]]: + async def async_search_collections( + self, name: str, source: Optional[str] = None + ) -> Optional[List[MediaInfo]]: """ 异步搜索集合信息 + :param name: 合集名称 + :param source: 请求级搜索数据源 + :return: 合集信息列表 """ + if source and source != "themoviedb": + return None if not name: return [] results = await self.tmdb.async_search_collections(name) diff --git a/app/schemas/context.py b/app/schemas/context.py index d3e8b65f7..e9d8c0a99 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -353,7 +353,7 @@ class MediaPerson(BaseModel): """ 媒体人物信息 """ - # 来源:themoviedb、douban、bangumi + # 来源:themoviedb、douban、bangumi、anilist source: Optional[str] = None # 公共 id: Optional[int] = None diff --git a/docs/mcp-api.md b/docs/mcp-api.md index ff70e2091..0e02599e0 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -118,7 +118,7 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/media/search` | 按标题搜索媒体,参数:`title`、`type`、`page`、`count`,可选 `source` | +| GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title`、`type`、`page`、`count`,可选 `source`;`media` 支持 `themoviedb`、`douban`、`bangumi`、`anilist`,`collection` 支持 `themoviedb`,`person` 支持 `themoviedb`、`douban` | | GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source` | | GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` | | GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 及插件自定义来源前缀 | @@ -142,6 +142,21 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返 | GET | `/api/v1/search/last/context` | 获取上一次搜索结果及可复用搜索参数,`params.result_type` 为 `torrent` 或 `subtitle` | | POST | `/api/v1/search/recommend` | 获取 AI 推荐资源,请求体:`filtered_indices`、`check_only`、`force` | +#### AniList 榜单 / 探索 + +AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-chinese` 代理查询。代理不可用时自动回退 AniList 官方 GraphQL,并合并 `anilist-chinese` 每日数据集;媒体标题优先使用项目提供的中文标题,未提供中文标题时回退 AniList 原语言标题。 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/anilist/trending` | 查询 TRENDING NOW 榜单,参数:`page`、`count` | +| GET | `/api/v1/anilist/popular-this-season` | 查询 POPULAR THIS SEASON 榜单,参数:`page`、`count` | +| GET | `/api/v1/anilist/discover` | 组合探索动画,参数:`search`、`genre`、`format`、`season`、`season_year`、`status`、`country`、`sort`、`page`、`count` | +| GET | `/api/v1/anilist/{anilist_id}` | 查询动画详情 | +| GET | `/api/v1/anilist/credits/{anilist_id}` | 查询日语配音演员,参数:`page`、`count` | +| GET | `/api/v1/anilist/recommend/{anilist_id}` | 查询相关推荐,参数:`page`、`count` | +| GET | `/api/v1/anilist/person/{person_id}` | 查询人物详情 | +| GET | `/api/v1/anilist/person/credits/{person_id}` | 查询人物参与的动画作品,参数:`page`、`count` | + #### 下载 | 方法 | 路径 | 说明 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 687a83a68..0e6f61568 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 2 +version: 4 description: >- Use this skill when you need to call MoviePilot REST API endpoints directly with the bundled Python client. Covers MoviePilot HTTP endpoints across media @@ -91,7 +91,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/media/search` | Search media/person by title. Params: `title` (required), `type`, `page`, `count`, optional `source` (`themoviedb`, `douban`, `bangumi`, `anilist`) | +| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` | | GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` | | GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` | | GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` | @@ -138,6 +138,21 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as ` | GET | `/api/v1/bangumi/person/{person_id}` | Person detail | | GET | `/api/v1/bangumi/person/credits/{person_id}` | Person filmography. Params: `page`, `count` | +### AniList (8 endpoints) + +AniList endpoints prefer the `anilist-chinese` proxy and fall back to official AniList GraphQL plus the project's daily translation dataset when the public proxy is unavailable. Media titles prefer the provided Chinese title and fall back to the native-language title. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/anilist/trending` | TRENDING NOW. Params: `page`, `count` | +| GET | `/api/v1/anilist/popular-this-season` | POPULAR THIS SEASON. Params: `page`, `count` | +| GET | `/api/v1/anilist/discover` | Explore anime. Params: `search`, `genre`, `format`, `season`, `season_year`, `status`, `country`, `sort`, `page`, `count` | +| GET | `/api/v1/anilist/{anilist_id}` | AniList media detail | +| GET | `/api/v1/anilist/credits/{anilist_id}` | Japanese voice cast. Params: `page`, `count` | +| GET | `/api/v1/anilist/recommend/{anilist_id}` | Recommendations. Params: `page`, `count` | +| GET | `/api/v1/anilist/person/{person_id}` | Staff detail | +| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` | + ### Search / Torrents / Subtitles (11 endpoints) | Method | Path | Description | diff --git a/tests/test_anilist_browse.py b/tests/test_anilist_browse.py new file mode 100644 index 000000000..a9299c695 --- /dev/null +++ b/tests/test_anilist_browse.py @@ -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}", + } diff --git a/tests/test_anilist_media_source.py b/tests/test_anilist_media_source.py index 505b71cd0..892166517 100644 --- a/tests/test_anilist_media_source.py +++ b/tests/test_anilist_media_source.py @@ -21,6 +21,7 @@ def anilist_info() -> dict: "romaji": "Sousou no Frieren", "english": "Frieren: Beyond Journey's End", "native": "葬送のフリーレン", + "chinese": "葬送的芙莉莲", }, "format": "TV", "status": "FINISHED", @@ -33,7 +34,7 @@ def anilist_info() -> dict: "coverImage": {"extraLarge": "https://img.example/poster.jpg"}, "bannerImage": "https://img.example/backdrop.png", "genres": ["Adventure", "Fantasy"], - "synonyms": ["Frieren"], + "synonyms": ["葬送的芙莉莲", "Frieren"], "averageScore": 91, "popularity": 300000, "isAdult": False, @@ -81,6 +82,7 @@ def test_anilist_id_recognition_normalizes_media_info(anilist_info: dict) -> Non assert media is not None assert media.source == "anilist" assert media.anilist_id == 154587 + assert media.title == "葬送的芙莉莲" assert media.anidb_id == 17617 assert media.type == MediaType.TV assert media.year == "2023" @@ -160,3 +162,25 @@ def test_anilist_api_extracts_graphql_errors_without_network() -> None: response.json.return_value = {"errors": [{"message": "invalid"}]} assert AniListApi._extract_response(response) is None + + +def test_anilist_title_falls_back_to_native_language(anilist_info: dict) -> None: + """anilist-chinese 未注入中文标题时应优先回退原语言标题。""" + anilist_info["synonyms"] = ["Frieren"] + anilist_info["title"].pop("chinese") + + media = MediaInfo(anilist_info=anilist_info) + + assert media.title == "葬送のフリーレン" + + +def test_anilist_title_uses_injected_chinese_synonym_for_latin_title( + anilist_info: dict, +) -> None: + """代理主标题仍为拉丁字母时应选择其追加的中文别名。""" + anilist_info["title"]["chinese"] = "Frieren" + anilist_info["synonyms"] = ["Official Alias", "葬送的芙莉莲"] + + media = MediaInfo(anilist_info=anilist_info) + + assert media.title == "葬送的芙莉莲" diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 2bdbe845f..c724e5b22 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -22,7 +22,7 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: """本次修改过的内置技能必须递增版本,确保用户端同步更新。""" expected_versions = { "database-operation": "3", - "moviepilot-api": "2", + "moviepilot-api": "4", "moviepilot-cli": "6", "moviepilot-update": "3", } diff --git a/tests/test_media_search_source_selection.py b/tests/test_media_search_source_selection.py new file mode 100644 index 000000000..00dad0e03 --- /dev/null +++ b/tests/test_media_search_source_selection.py @@ -0,0 +1,122 @@ +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from app.api.endpoints.media import search +from app.chain import ChainBase +from app.modules.douban import DoubanModule +from app.modules.themoviedb import TheMovieDbModule + + +@pytest.mark.parametrize( + ("search_type", "method_name", "source"), + [ + ("collection", "async_search_collections", "themoviedb"), + ("person", "async_search_persons", "douban"), + ], +) +def test_media_search_endpoint_forwards_source( + search_type: str, method_name: str, source: str +) -> None: + """媒体搜索接口应将合集和人物的数据源下传到处理链。""" + chain = Mock() + search_method = AsyncMock(return_value=[]) + setattr(chain, method_name, search_method) + + with patch("app.api.endpoints.media.MediaChain", return_value=chain): + result = asyncio.run( + search( + title="测试", + type=search_type, + source=source, + _=Mock(), + ) + ) + + assert result == [] + search_method.assert_awaited_once_with(name="测试", source=source) + + +@pytest.mark.parametrize( + ("method_name", "module_method_name"), + [ + ("async_search_persons", "async_search_persons"), + ("async_search_collections", "async_search_collections"), + ], +) +def test_chain_forwards_source_to_modules( + method_name: str, module_method_name: str +) -> None: + """处理链应将人物和合集的请求级数据源传递给媒体模块。""" + chain = Mock(spec=ChainBase) + chain.async_run_module = AsyncMock(return_value=[]) + + result = asyncio.run( + getattr(ChainBase, method_name)( + chain, + name="测试", + source="themoviedb", + ) + ) + + assert result == [] + chain.async_run_module.assert_awaited_once_with( + module_method_name, + name="测试", + source="themoviedb", + ) + + +def test_tmdb_person_search_respects_explicit_source(monkeypatch) -> None: + """TMDB人物搜索应拒绝其他来源,并允许显式选择覆盖系统默认来源。""" + monkeypatch.setattr("app.modules.themoviedb.settings.SEARCH_SOURCE", "douban") + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.async_search_persons = AsyncMock(return_value=[]) + + skipped = asyncio.run( + module.async_search_persons(name="测试", source="douban") + ) + result = asyncio.run( + module.async_search_persons(name="测试", source="themoviedb") + ) + + assert skipped is None + assert result == [] + module.tmdb.async_search_persons.assert_awaited_once_with("测试") + + +def test_douban_person_search_respects_explicit_source(monkeypatch) -> None: + """豆瓣人物搜索应拒绝其他来源,并允许显式选择覆盖系统默认来源。""" + monkeypatch.setattr( + "app.modules.douban.settings.SEARCH_SOURCE", "themoviedb" + ) + module = DoubanModule() + module.doubanapi = Mock() + module.doubanapi.async_person_search = AsyncMock(return_value={}) + + skipped = asyncio.run( + module.async_search_persons(name="测试", source="themoviedb") + ) + result = asyncio.run( + module.async_search_persons(name="测试", source="douban") + ) + + assert skipped is None + assert result == [] + module.doubanapi.async_person_search.assert_awaited_once_with(keyword="测试") + + +def test_tmdb_collection_search_rejects_unsupported_source() -> None: + """TMDB合集搜索不应处理非TMDB来源请求。""" + module = TheMovieDbModule() + module.tmdb = Mock() + module.tmdb.async_search_collections = AsyncMock(return_value=[]) + + result = asyncio.run( + module.async_search_collections(name="测试", source="douban") + ) + + assert result is None + module.tmdb.async_search_collections.assert_not_awaited()