"""IMDb 免 Key 数据接口客户端。""" import asyncio from typing import Any, Optional, TypeVar from urllib.parse import quote import requests from pydantic import BaseModel, ConfigDict, Field, ValidationError from app.adapters.network.http import AsyncRequestUtils, RequestUtils from app.runtime.cache import cached from app.runtime.settings import RuntimeSettingsCompat from app.runtime.tasks import get_task_registry settings = RuntimeSettingsCompat() from app.runtime.log import logger TModel = TypeVar("TModel", bound=BaseModel) def _is_graphql_error(value: Optional[dict]) -> bool: """避免把 IMDb GraphQL 业务错误写入长期缓存。""" return bool(value and value.get("errors")) class ImdbModel(BaseModel): """IMDb 响应模型基类,允许按字段名或外部别名构造。""" model_config = ConfigDict(populate_by_name=True) class ImdbImage(ImdbModel): """IMDb 图片信息。""" url: Optional[str] = None width: Optional[int] = None height: Optional[int] = None type: Optional[str] = None class ImdbRating(ImdbModel): """IMDb 聚合评分信息。""" aggregate_rating: Optional[float] = Field(None, alias="aggregateRating") vote_count: Optional[int] = Field(None, alias="voteCount") class ImdbDate(ImdbModel): """IMDb 可变精度日期。""" year: Optional[int] = None month: Optional[int] = None day: Optional[int] = None class ImdbCountry(ImdbModel): """IMDb 制作国家信息。""" code: Optional[str] = None name: Optional[str] = None class ImdbLanguage(ImdbModel): """IMDb 语言信息。""" code: Optional[str] = None name: Optional[str] = None class ImdbPerson(ImdbModel): """IMDb 演职人员信息。""" id: Optional[str] = None display_name: Optional[str] = Field(None, alias="displayName") primary_image: Optional[ImdbImage] = Field(None, alias="primaryImage") class ImdbTitle(ImdbModel): """IMDb 影视条目详情。""" id: str type: str is_adult: Optional[bool] = Field(None, alias="isAdult") primary_title: Optional[str] = Field(None, alias="primaryTitle") original_title: Optional[str] = Field(None, alias="originalTitle") primary_image: Optional[ImdbImage] = Field(None, alias="primaryImage") start_year: Optional[int] = Field(None, alias="startYear") end_year: Optional[int] = Field(None, alias="endYear") runtime_seconds: Optional[int] = Field(None, alias="runtimeSeconds") genres: list[str] = Field(default_factory=list) rating: Optional[ImdbRating] = None plot: Optional[str] = None directors: list[ImdbPerson] = Field(default_factory=list) writers: list[ImdbPerson] = Field(default_factory=list) stars: list[ImdbPerson] = Field(default_factory=list) origin_countries: list[ImdbCountry] = Field( default_factory=list, alias="originCountries" ) spoken_languages: list[ImdbLanguage] = Field( default_factory=list, alias="spokenLanguages" ) class ImdbAka(ImdbModel): """IMDb 条目别名。""" text: Optional[str] = None class ImdbEpisode(ImdbModel): """IMDb 单集信息。""" id: str title: Optional[str] = None primary_image: Optional[ImdbImage] = Field(None, alias="primaryImage") season: Optional[str] = None episode_number: Optional[int] = Field(None, alias="episodeNumber") runtime_seconds: Optional[int] = Field(None, alias="runtimeSeconds") plot: Optional[str] = None rating: Optional[ImdbRating] = None release_date: Optional[ImdbDate] = Field(None, alias="releaseDate") class ImdbSeason(ImdbModel): """IMDb 季信息。""" season: Optional[str] = None episode_count: Optional[int] = Field(None, alias="episodeCount") class ImdbCredit(ImdbModel): """IMDb 演职员表条目。""" name: Optional[ImdbPerson] = None category: Optional[str] = None characters: list[str] = Field(default_factory=list) _TITLE_QUERY = """ query TitleDetails($titles: [ID!]!) { titles(ids: $titles) { id titleText { text } titleType { id } releaseYear { year } originalTitleText { text } primaryImage { url width height } ratingsSummary { aggregateRating voteCount } plot { plotText { plainText } } runtime { seconds } titleGenres { genres { genre { text } } } countriesOfOrigin { countries { id text } } spokenLanguages { spokenLanguages { id text } } isAdult } } """ _AKAS_QUERY = """ query TitleAkas($titles: [ID!]!) { titles(ids: $titles) { akas(first: 50) { edges { node { text } } } } } """ _CREDITS_QUERY = """ query TitleCredits($titles: [ID!]!) { titles(ids: $titles) { directors: credits(first: 5, filter: {categories: ["director"]}) { edges { node { name { id nameText { text } primaryImage { url } } category { id } } } } cast: credits(first: 20, filter: {categories: ["actor", "actress"]}) { edges { node { name { id nameText { text } primaryImage { url } } category { id } ... on Cast { characters { name } } } } } } } """ _IMAGES_QUERY = """ query TitleImages($titles: [ID!]!) { titles(ids: $titles) { images(first: 50) { edges { node { url width height type } } } } } """ _SEASONS_QUERY = """ query TitleSeasons($titles: [ID!]!) { titles(ids: $titles) { episodes { seasons { number } } } } """ _EPISODES_QUERY = """ query TitleEpisodes($titles: [ID!]!, $first: Int!, $after: ID) { titles(ids: $titles) { episodes { episodes(first: $first, after: $after) { edges { node { id titleText { text } primaryImage { url width height } series { episodeNumber { episodeNumber seasonNumber } } runtime { seconds } plot { plotText { plainText } } ratingsSummary { aggregateRating voteCount } releaseDate { day month year } } } pageInfo { hasNextPage endCursor } } } } } """ class ImdbApi: """封装 IMDb 网页免 Key 接口的同步与异步只读访问。""" SEARCH_URL = "https://v2.sg.media-imdb.com/suggestion/x" GRAPHQL_URL = "https://caching.graphql.imdb.com/" def __init__(self, proxies: Optional[dict] = None) -> None: """按一次模块配置快照创建网络请求适配器。""" headers = { "User-Agent": settings.NORMAL_USER_AGENT, "Accept": "application/graphql+json, application/json", "Content-Type": "application/json", "x-imdb-client-name": "imdb-web-next-localized", } self._session = requests.Session() self._request = RequestUtils( headers=headers, proxies=proxies, session=self._session, timeout=30, ) self._async_request = AsyncRequestUtils( headers=headers, proxies=proxies, timeout=30, ) @classmethod def _freeze_value(cls, value: Any) -> Any: """递归冻结请求参数,生成跨同步和异步实现一致的缓存键。""" if isinstance(value, dict): return tuple( sorted((key, cls._freeze_value(item)) for key, item in value.items()) ) if isinstance(value, (list, tuple)): return tuple(cls._freeze_value(item) for item in value) return value @classmethod def _freeze_params(cls, params: Optional[dict]) -> tuple[tuple[str, Any], ...]: """把请求参数冻结为可用于缓存键的有序元组。""" return cls._freeze_value(params or {}) @cached( maxsize=settings.CONF.imdb, ttl=settings.CONF.meta, skip_none=True, shared_key="imdb_get", ) def _cached_get_json( self, url: str, params_key: tuple[tuple[str, Any], ...] ) -> Optional[dict]: """同步读取并缓存一个 IMDb JSON GET 响应。""" return self._request.get_json(url, params=dict(params_key)) @cached( maxsize=settings.CONF.imdb, ttl=settings.CONF.meta, skip_none=True, shared_key="imdb_get", ) async def _async_cached_get_json( self, url: str, params_key: tuple[tuple[str, Any], ...] ) -> Optional[dict]: """异步读取并缓存一个 IMDb JSON GET 响应。""" return await self._async_request.get_json(url, params=dict(params_key)) @cached( maxsize=settings.CONF.imdb, ttl=settings.CONF.meta, skip_none=True, shared_key="imdb_graphql", skip_if=_is_graphql_error, ) def _cached_graphql( self, query: str, variables_key: tuple[tuple[str, Any], ...] ) -> Optional[dict]: """同步读取并缓存一个 IMDb GraphQL 响应。""" return self._request.post_json( self.GRAPHQL_URL, json={"query": query, "variables": dict(variables_key)}, ) @cached( maxsize=settings.CONF.imdb, ttl=settings.CONF.meta, skip_none=True, shared_key="imdb_graphql", skip_if=_is_graphql_error, ) async def _async_cached_graphql( self, query: str, variables_key: tuple[tuple[str, Any], ...] ) -> Optional[dict]: """异步读取并缓存一个 IMDb GraphQL 响应。""" return await self._async_request.post_json( self.GRAPHQL_URL, json={"query": query, "variables": dict(variables_key)}, ) def _graphql(self, query: str, variables: dict) -> Optional[dict]: """同步执行 GraphQL 查询并提取 data 区域。""" response = self._cached_graphql(query, self._freeze_params(variables)) if response and response.get("errors"): logger.debug("IMDb GraphQL 查询失败:%s", response["errors"]) return None return response.get("data") if response else None async def _async_graphql(self, query: str, variables: dict) -> Optional[dict]: """异步执行 GraphQL 查询并提取 data 区域。""" response = await self._async_cached_graphql( query, self._freeze_params(variables) ) if response and response.get("errors"): logger.debug("IMDb GraphQL 异步查询失败:%s", response["errors"]) return None return response.get("data") if response else None def clear_cache(self) -> None: """清理同步缓存,并在当前或临时事件循环中清理异步缓存。""" self._cached_get_json.cache_clear() self._cached_graphql.cache_clear() try: asyncio.get_running_loop() except RuntimeError: asyncio.run(self.async_clear_cache()) else: # 同步 ABI 不能改成 async;运行中的循环交给宿主登记器收口,避免清理任务悬挂。 try: get_task_registry().create( self.async_clear_cache(), owner="module.imdb.cache_clear", ) except RuntimeError: # 兼容宿主已经进入关停阶段的同步调用:同步缓存已清理,异步缓存无需再启动新任务。 return async def async_clear_cache(self) -> None: """清理 IMDb 异步 GET 与 GraphQL 请求缓存区。""" await self._async_cached_get_json.cache_clear() await self._async_cached_graphql.cache_clear() def close(self) -> None: """关闭同步 HTTP Session,异步共享连接池由宿主统一关闭。""" self._session.close() @staticmethod def _parse( model: type[TModel], data: Optional[dict], operation: str ) -> Optional[TModel]: """把外部响应校验为内部模型,并隔离字段漂移错误。""" if not data: return None try: return model.model_validate(data) except (TypeError, ValueError, ValidationError) as err: logger.debug("解析 IMDb %s 响应失败:%s", operation, str(err)) return None @staticmethod def _first_title(data: Optional[dict]) -> Optional[dict]: """从 IMDb GraphQL data 中提取第一个标题对象。""" titles = data.get("titles") if data else None return titles[0] if titles else None @classmethod def _parse_search_titles(cls, data: Optional[dict], limit: int) -> list[ImdbTitle]: """把 IMDb 搜索建议响应转换为稳定标题模型。""" titles: list[ImdbTitle] = [] for item in (data or {}).get("d", []): imdb_id = item.get("id") title_type = item.get("qid") if not imdb_id or not imdb_id.startswith("tt") or not title_type: continue image = item.get("i") or {} title = cls._parse( ImdbTitle, { "id": imdb_id, "type": title_type, "primaryTitle": item.get("l"), "primaryImage": { "url": image.get("imageUrl"), "width": image.get("width"), "height": image.get("height"), }, "startYear": item.get("y"), }, "标题搜索", ) if title: titles.append(title) if len(titles) >= limit: break return titles @classmethod def _parse_title(cls, data: Optional[dict]) -> Optional[ImdbTitle]: """把 IMDb GraphQL 标题结构展平为内部详情模型。""" item = cls._first_title(data) if not item: return None genres = item.get("titleGenres", {}).get("genres") or [] countries = item.get("countriesOfOrigin", {}).get("countries") or [] languages = item.get("spokenLanguages", {}).get("spokenLanguages") or [] return cls._parse( ImdbTitle, { "id": item.get("id"), "type": item.get("titleType", {}).get("id"), "isAdult": item.get("isAdult"), "primaryTitle": item.get("titleText", {}).get("text"), "originalTitle": item.get("originalTitleText", {}).get("text"), "primaryImage": item.get("primaryImage"), "startYear": item.get("releaseYear", {}).get("year"), "runtimeSeconds": item.get("runtime", {}).get("seconds"), "genres": [ entry.get("genre", {}).get("text") for entry in genres if entry.get("genre", {}).get("text") ], "rating": item.get("ratingsSummary"), "plot": item.get("plot", {}).get("plotText", {}).get("plainText"), "originCountries": [ {"code": entry.get("id"), "name": entry.get("text")} for entry in countries ], "spokenLanguages": [ {"code": entry.get("id"), "name": entry.get("text")} for entry in languages ], }, "标题详情", ) @classmethod def _parse_akas(cls, data: Optional[dict]) -> list[ImdbAka]: """解析 GraphQL 标题别名边。""" title = cls._first_title(data) or {} edges = title.get("akas", {}).get("edges") or [] return [ aka for edge in edges if (aka := cls._parse(ImdbAka, edge.get("node"), "标题别名")) ] @classmethod def _parse_credits(cls, data: Optional[dict]) -> list[ImdbCredit]: """解析 GraphQL 导演和主演边。""" title = cls._first_title(data) or {} credits: list[ImdbCredit] = [] for field_name in ("directors", "cast"): for edge in title.get(field_name, {}).get("edges") or []: node = edge.get("node") or {} name = node.get("name") or {} credit = cls._parse( ImdbCredit, { "name": { "id": name.get("id"), "displayName": name.get("nameText", {}).get("text"), "primaryImage": name.get("primaryImage"), }, "category": node.get("category", {}).get("id"), "characters": [ item.get("name") for item in node.get("characters") or [] if item.get("name") ], }, "演职员", ) if credit: credits.append(credit) return credits @classmethod def _parse_images(cls, data: Optional[dict]) -> list[ImdbImage]: """解析 GraphQL 标题图片边。""" title = cls._first_title(data) or {} edges = title.get("images", {}).get("edges") or [] return [ image for edge in edges if (image := cls._parse(ImdbImage, edge.get("node"), "标题图片")) ] @classmethod def _parse_seasons(cls, data: Optional[dict]) -> list[ImdbSeason]: """解析 GraphQL 季编号列表。""" title = cls._first_title(data) or {} values = title.get("episodes", {}).get("seasons") or [] return [ImdbSeason(season=str(item["number"])) for item in values] @classmethod def _parse_episode_page( cls, data: Optional[dict] ) -> tuple[list[ImdbEpisode], Optional[str]]: """解析一页 GraphQL 单集边及下一页游标。""" title = cls._first_title(data) or {} connection = title.get("episodes", {}).get("episodes") or {} episodes: list[ImdbEpisode] = [] for edge in connection.get("edges") or []: node = edge.get("node") or {} episode_number = node.get("series", {}).get("episodeNumber") or {} episode = cls._parse( ImdbEpisode, { "id": node.get("id"), "title": node.get("titleText", {}).get("text"), "primaryImage": node.get("primaryImage"), "season": str(episode_number.get("seasonNumber")) if episode_number.get("seasonNumber") is not None else None, "episodeNumber": episode_number.get("episodeNumber"), "runtimeSeconds": node.get("runtime", {}).get("seconds"), "plot": node.get("plot", {}).get("plotText", {}).get("plainText"), "rating": node.get("ratingsSummary"), "releaseDate": node.get("releaseDate"), }, "单集详情", ) if episode: episodes.append(episode) page_info = connection.get("pageInfo") or {} next_cursor = ( page_info.get("endCursor") if page_info.get("hasNextPage") else None ) return episodes, next_cursor def search_titles(self, query: str, limit: int = 50) -> list[ImdbTitle]: """按标题关键字搜索 IMDb 影视条目。""" url = f"{self.SEARCH_URL}/{quote(query.strip(), safe='')}.json" data = self._cached_get_json(url, self._freeze_params(None)) return self._parse_search_titles(data, limit) async def async_search_titles( self, query: str, limit: int = 50 ) -> list[ImdbTitle]: """异步按标题关键字搜索 IMDb 影视条目。""" url = f"{self.SEARCH_URL}/{quote(query.strip(), safe='')}.json" data = await self._async_cached_get_json(url, self._freeze_params(None)) return self._parse_search_titles(data, limit) def get_title(self, imdb_id: str) -> Optional[ImdbTitle]: """按 IMDb ID 获取影视条目详情。""" return self._parse_title(self._graphql(_TITLE_QUERY, {"titles": [imdb_id]})) async def async_get_title(self, imdb_id: str) -> Optional[ImdbTitle]: """异步按 IMDb ID 获取影视条目详情。""" return self._parse_title( await self._async_graphql(_TITLE_QUERY, {"titles": [imdb_id]}) ) def list_akas(self, imdb_id: str) -> list[ImdbAka]: """获取影视条目的可用别名。""" return self._parse_akas(self._graphql(_AKAS_QUERY, {"titles": [imdb_id]})) async def async_list_akas(self, imdb_id: str) -> list[ImdbAka]: """异步获取影视条目的可用别名。""" return self._parse_akas( await self._async_graphql(_AKAS_QUERY, {"titles": [imdb_id]}) ) def list_episodes(self, imdb_id: str) -> list[ImdbEpisode]: """使用游标分页获取电视剧条目的全部单集。""" episodes: list[ImdbEpisode] = [] cursor: Optional[str] = None while True: data = self._graphql( _EPISODES_QUERY, {"titles": [imdb_id], "first": 100, "after": cursor}, ) page, cursor = self._parse_episode_page(data) episodes.extend(page) if not cursor: return episodes async def async_list_episodes(self, imdb_id: str) -> list[ImdbEpisode]: """异步使用游标分页获取电视剧条目的全部单集。""" episodes: list[ImdbEpisode] = [] cursor: Optional[str] = None while True: data = await self._async_graphql( _EPISODES_QUERY, {"titles": [imdb_id], "first": 100, "after": cursor}, ) page, cursor = self._parse_episode_page(data) episodes.extend(page) if not cursor: return episodes def list_seasons(self, imdb_id: str) -> list[ImdbSeason]: """获取电视剧条目的季列表。""" return self._parse_seasons( self._graphql(_SEASONS_QUERY, {"titles": [imdb_id]}) ) async def async_list_seasons(self, imdb_id: str) -> list[ImdbSeason]: """异步获取电视剧条目的季列表。""" return self._parse_seasons( await self._async_graphql(_SEASONS_QUERY, {"titles": [imdb_id]}) ) def list_credits(self, imdb_id: str) -> list[ImdbCredit]: """获取影视条目的导演与主要演员。""" return self._parse_credits( self._graphql(_CREDITS_QUERY, {"titles": [imdb_id]}) ) async def async_list_credits(self, imdb_id: str) -> list[ImdbCredit]: """异步获取影视条目的导演与主要演员。""" return self._parse_credits( await self._async_graphql(_CREDITS_QUERY, {"titles": [imdb_id]}) ) def list_images(self, imdb_id: str) -> list[ImdbImage]: """获取影视条目的主要图片。""" return self._parse_images( self._graphql(_IMAGES_QUERY, {"titles": [imdb_id]}) ) async def async_list_images(self, imdb_id: str) -> list[ImdbImage]: """异步获取影视条目的主要图片。""" return self._parse_images( await self._async_graphql(_IMAGES_QUERY, {"titles": [imdb_id]}) )