mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-01 21:47:50 +08:00
feat(media): add native IMDb source module
This commit is contained in:
@@ -25,6 +25,9 @@
|
||||
"FilterModule": {
|
||||
"name": "Filter"
|
||||
},
|
||||
"ImdbModule": {
|
||||
"name": "IMDb"
|
||||
},
|
||||
"IndexerModule": {
|
||||
"name": "Site Indexer"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"FilterModule": {
|
||||
"name": "过滤器"
|
||||
},
|
||||
"ImdbModule": {
|
||||
"name": "IMDb"
|
||||
},
|
||||
"IndexerModule": {
|
||||
"name": "站点索引"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"FilterModule": {
|
||||
"name": "過濾器"
|
||||
},
|
||||
"ImdbModule": {
|
||||
"name": "IMDb"
|
||||
},
|
||||
"IndexerModule": {
|
||||
"name": "站點索引"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
"""IMDb 原生媒体数据源 Module。"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Tuple, Union
|
||||
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.media import is_media_source_enabled
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.scraper import MediaScraperHelper
|
||||
from app.foundation.text import convert as zhconv_convert
|
||||
from app.modules import _ModuleBase
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.context import MediaCredit, MediaImageSet
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.types import (
|
||||
MediaRecognizeType,
|
||||
MediaSource,
|
||||
MediaSourceSelection,
|
||||
MediaType,
|
||||
ModuleType,
|
||||
)
|
||||
|
||||
from .api import (
|
||||
ImdbAka,
|
||||
ImdbApi,
|
||||
ImdbCredit,
|
||||
ImdbEpisode,
|
||||
ImdbImage,
|
||||
ImdbSeason,
|
||||
ImdbTitle,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImdbConfigSnapshot:
|
||||
"""IMDb Module 一次配置 generation 使用的网络快照。"""
|
||||
|
||||
proxy: Any
|
||||
|
||||
|
||||
class ImdbModule(_ModuleBase):
|
||||
"""提供 IMDb 搜索、识别、详情补全与刮削能力。"""
|
||||
|
||||
CONFIG_WATCH = {"PROXY_HOST"}
|
||||
_IMDB_ID_PATTERN = re.compile(r"^tt\d+$", re.IGNORECASE)
|
||||
_MOVIE_TYPES = frozenset({"movie", "tvMovie"})
|
||||
_TV_TYPES = frozenset({"tvSeries", "tvMiniSeries", "tvShort", "tvSpecial"})
|
||||
|
||||
imdb_api: Optional[ImdbApi] = None
|
||||
scraper: Optional[MediaScraperHelper] = None
|
||||
_config = ImdbConfigSnapshot(proxy=None)
|
||||
|
||||
def init_module(self) -> None:
|
||||
"""按当前代理配置初始化 IMDb 客户端和通用刮削器。"""
|
||||
self._config = ImdbConfigSnapshot(proxy=settings.PROXY)
|
||||
self.imdb_api = ImdbApi(proxies=self._config.proxy)
|
||||
self.scraper = MediaScraperHelper()
|
||||
|
||||
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
|
||||
"""IMDb 随宿主启动,无需独立模块开关。"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回模块展示名称。"""
|
||||
return "IMDb"
|
||||
|
||||
@staticmethod
|
||||
def get_type() -> ModuleType:
|
||||
"""返回模块所属的媒体识别类型。"""
|
||||
return ModuleType.MediaRecognize
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> MediaRecognizeType:
|
||||
"""返回 IMDb 媒体识别子类型。"""
|
||||
return MediaRecognizeType.IMDb
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回模块调度优先级。"""
|
||||
return 4
|
||||
|
||||
def stop(self) -> None:
|
||||
"""释放当前 generation 持有的客户端引用。"""
|
||||
if self.imdb_api:
|
||||
self.imdb_api.close()
|
||||
self.imdb_api = None
|
||||
self.scraper = None
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""通过最小标题搜索测试 IMDb 数据服务连通性。"""
|
||||
if not self.imdb_api:
|
||||
return False, "IMDb 模块未初始化"
|
||||
results = self.imdb_api.search_titles("The Shawshank Redemption", limit=1)
|
||||
return (True, "") if results else (False, "IMDb 数据服务未返回搜索结果")
|
||||
|
||||
@classmethod
|
||||
def _media_type(cls, title_type: str) -> MediaType:
|
||||
"""把 IMDb 标题类型转换为 MoviePilot 媒体类型。"""
|
||||
if title_type in cls._MOVIE_TYPES:
|
||||
return MediaType.MOVIE
|
||||
if title_type in cls._TV_TYPES:
|
||||
return MediaType.TV
|
||||
return MediaType.UNKNOWN
|
||||
|
||||
@staticmethod
|
||||
def _normalize_name(value: Optional[str]) -> str:
|
||||
"""移除标题标点与空白,生成用于精确比较的稳定文本。"""
|
||||
return re.sub(r"[\W_]+", "", value or "", flags=re.UNICODE).casefold()
|
||||
|
||||
@classmethod
|
||||
def _name_matches(cls, query: str, names: list[Optional[str]]) -> bool:
|
||||
"""判断查询标题是否与任一 IMDb 标题或别名精确匹配。"""
|
||||
normalized_query = cls._normalize_name(query)
|
||||
return bool(
|
||||
normalized_query
|
||||
and any(
|
||||
cls._normalize_name(candidate) == normalized_query
|
||||
for candidate in names
|
||||
if candidate
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _normalize_imdb_id(cls, media_id: object) -> Optional[str]:
|
||||
"""校验并规范化 IMDb 的 ``tt`` 数字 ID。"""
|
||||
value = str(media_id or "").strip().lower()
|
||||
return value if cls._IMDB_ID_PATTERN.fullmatch(value) else None
|
||||
|
||||
@classmethod
|
||||
def _supports_request_type(
|
||||
cls, meta: Optional[MetaBase], mtype: Optional[MediaType]
|
||||
) -> bool:
|
||||
"""限制 IMDb Module 只处理电影和电视剧请求。"""
|
||||
requested_type = mtype or getattr(meta, "type", None)
|
||||
return requested_type not in {MediaType.MUSIC, MediaType.MUSIC.value, "music"}
|
||||
|
||||
@staticmethod
|
||||
def _recognize_names(meta: MetaBase) -> list[str]:
|
||||
"""按中文、简体中文、英文和解析主标题顺序生成识别词。"""
|
||||
simplified_name = (
|
||||
zhconv_convert(meta.cn_name, "zh-hans") if meta.cn_name else None
|
||||
)
|
||||
names = [meta.cn_name, simplified_name, meta.en_name, meta.name]
|
||||
return list(dict.fromkeys(name for name in names if isinstance(name, str) and name))
|
||||
|
||||
@classmethod
|
||||
def _candidate_titles(
|
||||
cls,
|
||||
titles: list[ImdbTitle],
|
||||
mtype: Optional[MediaType],
|
||||
year: Optional[str],
|
||||
) -> list[ImdbTitle]:
|
||||
"""按支持类型、请求类型和年份给 IMDb 候选项排序。"""
|
||||
candidates = [
|
||||
title
|
||||
for title in titles
|
||||
if cls._media_type(title.type) in {MediaType.MOVIE, MediaType.TV}
|
||||
]
|
||||
if mtype in {MediaType.MOVIE, MediaType.TV}:
|
||||
candidates = [
|
||||
title for title in candidates if cls._media_type(title.type) == mtype
|
||||
]
|
||||
if year and str(year).isdigit():
|
||||
requested_year = int(year)
|
||||
candidates.sort(
|
||||
key=lambda title: (
|
||||
abs((title.start_year or requested_year + 99) - requested_year),
|
||||
-(title.start_year or 0),
|
||||
)
|
||||
)
|
||||
else:
|
||||
candidates.sort(key=lambda title: title.start_year or 0, reverse=True)
|
||||
return candidates
|
||||
|
||||
def _pick_title(
|
||||
self,
|
||||
query: str,
|
||||
titles: list[ImdbTitle],
|
||||
mtype: Optional[MediaType],
|
||||
year: Optional[str],
|
||||
) -> Optional[ImdbTitle]:
|
||||
"""同步选取标题、原名或别名精确命中的 IMDb 候选项。"""
|
||||
candidates = self._candidate_titles(titles, mtype, year)
|
||||
for title in candidates:
|
||||
if self._name_matches(query, [title.primary_title, title.original_title]):
|
||||
return title
|
||||
if not self.imdb_api:
|
||||
return None
|
||||
for title in candidates[:10]:
|
||||
akas = self.imdb_api.list_akas(title.id)
|
||||
if self._name_matches(query, [aka.text for aka in akas]):
|
||||
return title
|
||||
return None
|
||||
|
||||
async def _async_pick_title(
|
||||
self,
|
||||
query: str,
|
||||
titles: list[ImdbTitle],
|
||||
mtype: Optional[MediaType],
|
||||
year: Optional[str],
|
||||
) -> Optional[ImdbTitle]:
|
||||
"""异步选取标题、原名或别名精确命中的 IMDb 候选项。"""
|
||||
candidates = self._candidate_titles(titles, mtype, year)
|
||||
for title in candidates:
|
||||
if self._name_matches(query, [title.primary_title, title.original_title]):
|
||||
return title
|
||||
if not self.imdb_api:
|
||||
return None
|
||||
for title in candidates[:10]:
|
||||
akas = await self.imdb_api.async_list_akas(title.id)
|
||||
if self._name_matches(query, [aka.text for aka in akas]):
|
||||
return title
|
||||
return None
|
||||
|
||||
def _match_by_meta(
|
||||
self, meta: MetaBase, mtype: Optional[MediaType]
|
||||
) -> Optional[ImdbTitle]:
|
||||
"""同步按解析元数据搜索并匹配一个 IMDb 条目。"""
|
||||
if not self.imdb_api:
|
||||
return None
|
||||
requested_type = mtype or meta.type
|
||||
for name in self._recognize_names(meta):
|
||||
logger.info("正在使用 IMDb 识别:%s ...", name)
|
||||
titles = self.imdb_api.search_titles(name)
|
||||
title = self._pick_title(name, titles, requested_type, meta.year)
|
||||
if title:
|
||||
return title
|
||||
return None
|
||||
|
||||
async def _async_match_by_meta(
|
||||
self, meta: MetaBase, mtype: Optional[MediaType]
|
||||
) -> Optional[ImdbTitle]:
|
||||
"""异步按解析元数据搜索并匹配一个 IMDb 条目。"""
|
||||
if not self.imdb_api:
|
||||
return None
|
||||
requested_type = mtype or meta.type
|
||||
for name in self._recognize_names(meta):
|
||||
logger.info("正在使用 IMDb 识别:%s ...", name)
|
||||
titles = await self.imdb_api.async_search_titles(name)
|
||||
title = await self._async_pick_title(
|
||||
name, titles, requested_type, meta.year
|
||||
)
|
||||
if title:
|
||||
return title
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _person_credit(
|
||||
credit: ImdbCredit, *, director: bool = False
|
||||
) -> Optional[dict]:
|
||||
"""把 IMDb 演职员条目转换为统一演职员摘要。"""
|
||||
if not credit.name or not credit.name.display_name:
|
||||
return None
|
||||
image_url = credit.name.primary_image.url if credit.name.primary_image else None
|
||||
media_credit = MediaCredit(
|
||||
id=credit.name.id,
|
||||
name=credit.name.display_name,
|
||||
character=credit.characters[0] if credit.characters else None,
|
||||
job="Director" if director else None,
|
||||
profile_path=image_url,
|
||||
url=(
|
||||
f"https://www.imdb.com/name/{credit.name.id}/"
|
||||
if credit.name.id
|
||||
else None
|
||||
),
|
||||
avatar=image_url,
|
||||
images=MediaImageSet(large=image_url) if image_url else None,
|
||||
)
|
||||
return media_credit.model_dump(exclude_none=True)
|
||||
|
||||
@staticmethod
|
||||
def _backdrop_url(images: list[ImdbImage]) -> Optional[str]:
|
||||
"""按剧照、幕后照顺序选择一张 IMDb 背景图。"""
|
||||
for image_type in ("still_frame", "behind_the_scenes"):
|
||||
if image := next(
|
||||
(item for item in images if item.type == image_type and item.url),
|
||||
None,
|
||||
):
|
||||
return image.url
|
||||
return next((item.url for item in images if item.url), None)
|
||||
|
||||
@classmethod
|
||||
def _category(cls, title: ImdbTitle, mtype: MediaType) -> str:
|
||||
"""按 IMDb 类型、风格、国家和语言生成 MoviePilot 二级分类。"""
|
||||
genres = {genre.casefold() for genre in title.genres}
|
||||
countries = {item.code for item in title.origin_countries if item.code}
|
||||
languages = {item.code for item in title.spoken_languages if item.code}
|
||||
if mtype == MediaType.MOVIE:
|
||||
if "animation" in genres:
|
||||
return "动画电影"
|
||||
if languages.intersection({"zho", "cmn", "yue", "nan"}):
|
||||
return "华语电影"
|
||||
return "外语电影"
|
||||
if "animation" in genres and countries.intersection({"CN", "TW", "HK"}):
|
||||
return "国漫"
|
||||
if "animation" in genres and "JP" in countries:
|
||||
return "日番"
|
||||
if "documentary" in genres:
|
||||
return "纪录片"
|
||||
if genres.intersection({"reality-tv", "game-show"}):
|
||||
return "综艺"
|
||||
if countries.intersection({"CN", "TW", "HK"}):
|
||||
return "国产剧"
|
||||
if countries.intersection({"JP", "KP", "KR", "TH", "IN", "SG"}):
|
||||
return "日韩剧"
|
||||
if countries.intersection({"US", "FR", "GB", "DE", "ES", "IT", "NL", "PT", "RU", "UK"}):
|
||||
return "欧美剧"
|
||||
return "未分类"
|
||||
|
||||
@classmethod
|
||||
def _to_media_info(
|
||||
cls,
|
||||
title: ImdbTitle,
|
||||
akas: Optional[list[ImdbAka]] = None,
|
||||
credits: Optional[list[ImdbCredit]] = None,
|
||||
episodes: Optional[list[ImdbEpisode]] = None,
|
||||
seasons: Optional[list[ImdbSeason]] = None,
|
||||
images: Optional[list[ImdbImage]] = None,
|
||||
) -> MediaInfo:
|
||||
"""把 IMDb 详情及关联资源转换为统一媒体信息。"""
|
||||
akas = akas or []
|
||||
credits = credits or []
|
||||
episodes = episodes or []
|
||||
seasons = seasons or []
|
||||
images = images or []
|
||||
media_type = cls._media_type(title.type)
|
||||
names = list(
|
||||
dict.fromkeys(
|
||||
name
|
||||
for name in (
|
||||
title.primary_title,
|
||||
title.original_title,
|
||||
*(aka.text for aka in akas),
|
||||
)
|
||||
if name
|
||||
)
|
||||
)
|
||||
media_info = MediaInfo()
|
||||
media_info.media_source = MediaSource.IMDb
|
||||
media_info.media_id = title.id
|
||||
media_info.imdb_id = title.id
|
||||
media_info.type = media_type
|
||||
media_info.title = title.primary_title or title.original_title or ""
|
||||
media_info.en_title = title.primary_title
|
||||
media_info.original_title = title.original_title
|
||||
media_info.original_name = title.original_title
|
||||
media_info.names = names
|
||||
media_info.year = str(title.start_year) if title.start_year else ""
|
||||
media_info.overview = title.plot or ""
|
||||
media_info.adult = bool(title.is_adult)
|
||||
media_info.poster_path = title.primary_image.url if title.primary_image else None
|
||||
media_info.backdrop_path = cls._backdrop_url(images)
|
||||
media_info.genres = [
|
||||
{"id": genre, "name": genre} for genre in title.genres
|
||||
]
|
||||
media_info.origin_country = [
|
||||
item.code for item in title.origin_countries if item.code
|
||||
]
|
||||
media_info.production_countries = [
|
||||
{"name": item.name or item.code}
|
||||
for item in title.origin_countries
|
||||
if item.name or item.code
|
||||
]
|
||||
media_info.spoken_languages = [
|
||||
{"iso_639_1": item.code, "name": item.name or item.code}
|
||||
for item in title.spoken_languages
|
||||
if item.code
|
||||
]
|
||||
if title.spoken_languages:
|
||||
media_info.original_language = title.spoken_languages[0].code
|
||||
if title.rating:
|
||||
media_info.vote_average = title.rating.aggregate_rating
|
||||
media_info.vote_count = title.rating.vote_count
|
||||
if title.runtime_seconds:
|
||||
media_info.runtime = max(1, round(title.runtime_seconds / 60))
|
||||
media_info.episode_run_time = [media_info.runtime]
|
||||
|
||||
directors: list[dict] = []
|
||||
actors: list[dict] = []
|
||||
for credit in credits:
|
||||
category = (credit.category or "").upper()
|
||||
if category == "DIRECTOR":
|
||||
if item := cls._person_credit(credit, director=True):
|
||||
directors.append(item)
|
||||
elif category in {"CAST", "ACTOR", "ACTRESS"}:
|
||||
if item := cls._person_credit(credit):
|
||||
actors.append(item)
|
||||
if not directors:
|
||||
directors = [
|
||||
item
|
||||
for person in title.directors
|
||||
if (item := cls._person_credit(ImdbCredit(name=person), director=True))
|
||||
]
|
||||
if not actors:
|
||||
actors = [
|
||||
item
|
||||
for person in title.stars
|
||||
if (item := cls._person_credit(ImdbCredit(name=person)))
|
||||
]
|
||||
media_info.directors = directors[:3]
|
||||
media_info.actors = actors[:12]
|
||||
|
||||
season_info: dict[int, dict] = {}
|
||||
for season in seasons:
|
||||
if not season.season or not season.season.isdigit():
|
||||
continue
|
||||
season_number = int(season.season)
|
||||
season_info[season_number] = {
|
||||
"season_number": season_number,
|
||||
"episode_count": season.episode_count,
|
||||
"name": season.season,
|
||||
}
|
||||
for episode in episodes:
|
||||
if not episode.season or not episode.season.isdigit():
|
||||
continue
|
||||
season_number = int(episode.season)
|
||||
media_info.seasons.setdefault(season_number, []).append(
|
||||
episode.episode_number or 0
|
||||
)
|
||||
if (
|
||||
season_number not in media_info.season_years
|
||||
and episode.release_date
|
||||
and episode.release_date.year
|
||||
):
|
||||
media_info.season_years[season_number] = str(episode.release_date.year)
|
||||
season_info.setdefault(
|
||||
season_number,
|
||||
{
|
||||
"season_number": season_number,
|
||||
"episode_count": None,
|
||||
"name": str(season_number),
|
||||
},
|
||||
)
|
||||
media_info.season_info = list(season_info.values())
|
||||
if media_type == MediaType.TV:
|
||||
media_info.number_of_seasons = len(season_info)
|
||||
media_info.number_of_episodes = len(episodes)
|
||||
media_info.set_category(cls._category(title, media_type))
|
||||
return media_info
|
||||
|
||||
def _load_media_info(self, title: ImdbTitle) -> MediaInfo:
|
||||
"""同步补齐一个 IMDb 条目的别名、演职员、剧集和图片。"""
|
||||
if not self.imdb_api:
|
||||
return self._to_media_info(title)
|
||||
details = self.imdb_api.get_title(title.id) or title
|
||||
akas = self.imdb_api.list_akas(title.id)
|
||||
credits = self.imdb_api.list_credits(title.id)
|
||||
images = self.imdb_api.list_images(title.id)
|
||||
if self._media_type(details.type) == MediaType.TV:
|
||||
episodes = self.imdb_api.list_episodes(title.id)
|
||||
seasons = self.imdb_api.list_seasons(title.id)
|
||||
else:
|
||||
episodes = []
|
||||
seasons = []
|
||||
return self._to_media_info(
|
||||
details,
|
||||
akas=akas,
|
||||
credits=credits,
|
||||
episodes=episodes,
|
||||
seasons=seasons,
|
||||
images=images,
|
||||
)
|
||||
|
||||
async def _async_load_media_info(self, title: ImdbTitle) -> MediaInfo:
|
||||
"""并发补齐一个 IMDb 条目的别名、演职员、剧集和图片。"""
|
||||
if not self.imdb_api:
|
||||
return self._to_media_info(title)
|
||||
is_tv = self._media_type(title.type) == MediaType.TV
|
||||
results = await asyncio.gather(
|
||||
self.imdb_api.async_get_title(title.id),
|
||||
self.imdb_api.async_list_akas(title.id),
|
||||
self.imdb_api.async_list_credits(title.id),
|
||||
self.imdb_api.async_list_images(title.id),
|
||||
self.imdb_api.async_list_episodes(title.id) if is_tv else asyncio.sleep(0, result=[]),
|
||||
self.imdb_api.async_list_seasons(title.id) if is_tv else asyncio.sleep(0, result=[]),
|
||||
return_exceptions=True,
|
||||
)
|
||||
details = results[0] if isinstance(results[0], ImdbTitle) else title
|
||||
return self._to_media_info(
|
||||
details,
|
||||
akas=results[1] if isinstance(results[1], list) else [],
|
||||
credits=results[2] if isinstance(results[2], list) else [],
|
||||
images=results[3] if isinstance(results[3], list) else [],
|
||||
episodes=results[4] if isinstance(results[4], list) else [],
|
||||
seasons=results[5] if isinstance(results[5], list) else [],
|
||||
)
|
||||
|
||||
def recognize_media(
|
||||
self,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按 IMDb 显式身份或标题元数据同步识别影视信息。"""
|
||||
del episode_group, cache, kwargs
|
||||
if not self._supports_request_type(meta, mtype) or not self.imdb_api:
|
||||
return None
|
||||
requested_source = normalize_media_source(media_source)
|
||||
if media_id is not None:
|
||||
imdb_id = self._normalize_imdb_id(media_id)
|
||||
if requested_source != MediaSource.IMDb or not imdb_id:
|
||||
return None
|
||||
title = self.imdb_api.get_title(imdb_id)
|
||||
else:
|
||||
if requested_source not in {None, MediaSource.IMDb}:
|
||||
return None
|
||||
selected_source = requested_source or normalize_media_source(
|
||||
settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
if selected_source != MediaSource.IMDb or not meta or not meta.name:
|
||||
return None
|
||||
title = self._match_by_meta(meta, mtype)
|
||||
if not title:
|
||||
return None
|
||||
media_info = self._load_media_info(title)
|
||||
if meta and meta.begin_season is not None:
|
||||
media_info.season = meta.begin_season
|
||||
logger.info(
|
||||
"IMDb 识别结果:%s %s %s:%s",
|
||||
media_info.type.value,
|
||||
media_info.title_year,
|
||||
media_info.media_source,
|
||||
media_info.media_id,
|
||||
)
|
||||
return media_info
|
||||
|
||||
async def async_recognize_media(
|
||||
self,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
episode_group: Optional[str] = None,
|
||||
cache: bool = True,
|
||||
**kwargs,
|
||||
) -> Optional[MediaInfo]:
|
||||
"""按 IMDb 显式身份或标题元数据异步识别影视信息。"""
|
||||
del episode_group, cache, kwargs
|
||||
if not self._supports_request_type(meta, mtype) or not self.imdb_api:
|
||||
return None
|
||||
requested_source = normalize_media_source(media_source)
|
||||
if media_id is not None:
|
||||
imdb_id = self._normalize_imdb_id(media_id)
|
||||
if requested_source != MediaSource.IMDb or not imdb_id:
|
||||
return None
|
||||
title = await self.imdb_api.async_get_title(imdb_id)
|
||||
else:
|
||||
if requested_source not in {None, MediaSource.IMDb}:
|
||||
return None
|
||||
selected_source = requested_source or normalize_media_source(
|
||||
settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
if selected_source != MediaSource.IMDb or not meta or not meta.name:
|
||||
return None
|
||||
title = await self._async_match_by_meta(meta, mtype)
|
||||
if not title:
|
||||
return None
|
||||
media_info = await self._async_load_media_info(title)
|
||||
if meta and meta.begin_season is not None:
|
||||
media_info.season = meta.begin_season
|
||||
logger.info(
|
||||
"IMDb 异步识别结果:%s %s %s:%s",
|
||||
media_info.type.value,
|
||||
media_info.title_year,
|
||||
media_info.media_source,
|
||||
media_info.media_id,
|
||||
)
|
||||
return media_info
|
||||
|
||||
def search_medias(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
) -> Optional[list[MediaInfo]]:
|
||||
"""按请求级来源选择同步搜索 IMDb 影视条目。"""
|
||||
if not is_media_source_enabled(media_source, MediaSource.IMDb):
|
||||
return None
|
||||
if not self.imdb_api or not meta.name:
|
||||
return []
|
||||
return [
|
||||
self._to_media_info(title)
|
||||
for title in self._candidate_titles(
|
||||
self.imdb_api.search_titles(meta.name), meta.type, meta.year
|
||||
)
|
||||
]
|
||||
|
||||
async def async_search_medias(
|
||||
self,
|
||||
meta: MetaBase,
|
||||
media_source: Optional[MediaSourceSelection] = None,
|
||||
) -> Optional[list[MediaInfo]]:
|
||||
"""按请求级来源选择异步搜索 IMDb 影视条目。"""
|
||||
if not is_media_source_enabled(media_source, MediaSource.IMDb):
|
||||
return None
|
||||
if not self.imdb_api or not meta.name:
|
||||
return []
|
||||
titles = await self.imdb_api.async_search_titles(meta.name)
|
||||
return [
|
||||
self._to_media_info(title)
|
||||
for title in self._candidate_titles(titles, meta.type, meta.year)
|
||||
]
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清理 IMDb 请求缓存并记录统一模块日志。"""
|
||||
if not self.imdb_api:
|
||||
return
|
||||
logger.info("开始清除 IMDb 缓存 ...")
|
||||
self.imdb_api.clear_cache()
|
||||
logger.info("IMDb 缓存清除完成")
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""为 IMDb 媒体信息同步补充缺失的背景图片。"""
|
||||
if mediainfo.media_source != MediaSource.IMDb or not self.imdb_api:
|
||||
return None
|
||||
if mediainfo.backdrop_path:
|
||||
return mediainfo
|
||||
imdb_id = self._normalize_imdb_id(mediainfo.media_id)
|
||||
if not imdb_id:
|
||||
return None
|
||||
mediainfo.backdrop_path = self._backdrop_url(
|
||||
self.imdb_api.list_images(imdb_id)
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""为 IMDb 媒体信息异步补充缺失的背景图片。"""
|
||||
if mediainfo.media_source != MediaSource.IMDb or not self.imdb_api:
|
||||
return None
|
||||
if mediainfo.backdrop_path:
|
||||
return mediainfo
|
||||
imdb_id = self._normalize_imdb_id(mediainfo.media_id)
|
||||
if not imdb_id:
|
||||
return None
|
||||
mediainfo.backdrop_path = self._backdrop_url(
|
||||
await self.imdb_api.async_list_images(imdb_id)
|
||||
)
|
||||
return mediainfo
|
||||
|
||||
def metadata_nfo(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> Optional[str]:
|
||||
"""生成 IMDb 来源的 NFO 元数据文本。"""
|
||||
del kwargs
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != MediaSource.IMDb.value:
|
||||
return None
|
||||
if not self.scraper:
|
||||
return None
|
||||
return self.scraper.get_metadata_nfo(
|
||||
mediainfo, season=season, episode=episode
|
||||
)
|
||||
|
||||
def metadata_img(
|
||||
self,
|
||||
mediainfo: MediaInfo,
|
||||
season: Optional[int] = None,
|
||||
episode: Optional[int] = None,
|
||||
) -> Optional[dict]:
|
||||
"""生成 IMDb 来源的图片文件名与下载地址映射。"""
|
||||
if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != MediaSource.IMDb.value:
|
||||
return None
|
||||
if not self.scraper:
|
||||
return None
|
||||
return self.scraper.get_metadata_img(
|
||||
mediainfo, season=season, episode=episode
|
||||
)
|
||||
@@ -0,0 +1,635 @@
|
||||
"""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.config import settings
|
||||
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:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
asyncio.run(self.async_clear_cache())
|
||||
else:
|
||||
loop.create_task(self.async_clear_cache())
|
||||
|
||||
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]})
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
schema_version = 1
|
||||
id = "ImdbModule"
|
||||
kind = "host_module"
|
||||
entrypoint = "app.modules.imdb:ImdbModule"
|
||||
depends_on = []
|
||||
|
||||
[metadata]
|
||||
name = "IMDb"
|
||||
type = "mediarecognize"
|
||||
subtype = "IMDb"
|
||||
priority = 4
|
||||
|
||||
[activation]
|
||||
policy = "bootstrap"
|
||||
watch = ["PROXY_HOST"]
|
||||
@@ -53,6 +53,8 @@ class SystemConfModel(BaseModel):
|
||||
bangumi: int = 0
|
||||
# AniList请求缓存数量
|
||||
anilist: int = 0
|
||||
# IMDb请求缓存数量
|
||||
imdb: int = 0
|
||||
# Fanart请求缓存数量
|
||||
fanart: int = 0
|
||||
# MusicBrainz请求缓存数量
|
||||
@@ -260,11 +262,11 @@ class ConfigModel(BaseModel):
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
|
||||
# ==================== 媒体元数据配置 ====================
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb"
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
# 媒体识别来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist/musicbrainz/theaudiodb/doubanmusic
|
||||
# 刮削来源 themoviedb/douban/bangumi/anilist/imdb/musicbrainz/theaudiodb/doubanmusic
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
@@ -1118,6 +1120,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
tmdb=1024,
|
||||
douban=512,
|
||||
bangumi=512,
|
||||
imdb=512,
|
||||
fanart=512,
|
||||
musicbrainz=512,
|
||||
theaudiodb=512,
|
||||
@@ -1132,6 +1135,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
tmdb=256,
|
||||
douban=256,
|
||||
bangumi=256,
|
||||
imdb=256,
|
||||
fanart=128,
|
||||
musicbrainz=256,
|
||||
theaudiodb=256,
|
||||
|
||||
@@ -605,6 +605,8 @@ class MediaRecognizeType(Enum):
|
||||
Bangumi = "Bangumi"
|
||||
# AniList
|
||||
AniList = "AniList"
|
||||
# IMDb
|
||||
IMDb = "IMDb"
|
||||
# MusicBrainz
|
||||
MusicBrainz = "MusicBrainz"
|
||||
# TheAudioDB
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"root": "app"
|
||||
},
|
||||
"settings_imports": {
|
||||
"count": 164,
|
||||
"count": 166,
|
||||
"files": [
|
||||
"app/adapters/cache/backends.py",
|
||||
"app/adapters/cache/redis.py",
|
||||
@@ -112,6 +112,8 @@
|
||||
"app/modules/filemanager/storages/smb.py",
|
||||
"app/modules/filemanager/storages/u115.py",
|
||||
"app/modules/filemanager/transhandler.py",
|
||||
"app/modules/imdb/__init__.py",
|
||||
"app/modules/imdb/api.py",
|
||||
"app/modules/indexer/parser/__init__.py",
|
||||
"app/modules/indexer/parser/rousi.py",
|
||||
"app/modules/indexer/spider/__init__.py",
|
||||
|
||||
+29
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6317,
|
||||
"edge_sha256": "11978842b2fd9bd6ccec86c01c5f1b9389d959bc53aab5e16e29e3c121b3dab9",
|
||||
"edge_count": 6341,
|
||||
"edge_sha256": "073824a6b2c8b3e9baf4755ce4dccf94693fb943d1baba0f621d3a84b17030c0",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -4213,6 +4213,30 @@
|
||||
"app.modules.filter -> app.runtime.log",
|
||||
"app.modules.filter -> app.schemas",
|
||||
"app.modules.filter -> app.schemas.types",
|
||||
"app.modules.imdb -> app.domain",
|
||||
"app.modules.imdb -> app.domain.context",
|
||||
"app.modules.imdb -> app.domain.media",
|
||||
"app.modules.imdb -> app.domain.meta",
|
||||
"app.modules.imdb -> app.domain.meta.metabase",
|
||||
"app.modules.imdb -> app.domain.scraper",
|
||||
"app.modules.imdb -> app.foundation",
|
||||
"app.modules.imdb -> app.foundation.text",
|
||||
"app.modules.imdb -> app.modules",
|
||||
"app.modules.imdb -> app.modules.imdb.api",
|
||||
"app.modules.imdb -> app.runtime",
|
||||
"app.modules.imdb -> app.runtime.config",
|
||||
"app.modules.imdb -> app.runtime.log",
|
||||
"app.modules.imdb -> app.schemas",
|
||||
"app.modules.imdb -> app.schemas.context",
|
||||
"app.modules.imdb -> app.schemas.media",
|
||||
"app.modules.imdb -> app.schemas.types",
|
||||
"app.modules.imdb.api -> app.adapters",
|
||||
"app.modules.imdb.api -> app.adapters.network",
|
||||
"app.modules.imdb.api -> app.adapters.network.http",
|
||||
"app.modules.imdb.api -> app.runtime",
|
||||
"app.modules.imdb.api -> app.runtime.cache",
|
||||
"app.modules.imdb.api -> app.runtime.config",
|
||||
"app.modules.imdb.api -> app.runtime.log",
|
||||
"app.modules.indexer -> app.application",
|
||||
"app.modules.indexer -> app.application.site",
|
||||
"app.modules.indexer -> app.application.site.health",
|
||||
@@ -6334,7 +6358,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 785,
|
||||
"module_count": 787,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6828,6 +6852,8 @@
|
||||
"app.modules.filemanager.storages.u115",
|
||||
"app.modules.filemanager.transhandler",
|
||||
"app.modules.filter",
|
||||
"app.modules.imdb",
|
||||
"app.modules.imdb.api",
|
||||
"app.modules.indexer",
|
||||
"app.modules.indexer.parser",
|
||||
"app.modules.indexer.parser.bitpt",
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
"""IMDb 原生媒体数据源 Module 测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules.imdb import ImdbModule
|
||||
from app.modules.imdb.api import (
|
||||
ImdbAka,
|
||||
ImdbApi,
|
||||
ImdbCredit,
|
||||
ImdbDate,
|
||||
ImdbEpisode,
|
||||
ImdbImage,
|
||||
ImdbPerson,
|
||||
ImdbSeason,
|
||||
ImdbTitle,
|
||||
)
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.types import MediaRecognizeType, MediaSource, MediaType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imdb_title() -> ImdbTitle:
|
||||
"""构造一条包含完整基础字段的 IMDb 电视剧条目。"""
|
||||
return ImdbTitle(
|
||||
id="tt0903747",
|
||||
type="tvSeries",
|
||||
primaryTitle="Breaking Bad",
|
||||
originalTitle="Breaking Bad",
|
||||
primaryImage={"url": "https://image.example/poster.jpg", "type": "poster"},
|
||||
startYear=2008,
|
||||
runtimeSeconds=2820,
|
||||
genres=["Crime", "Drama"],
|
||||
rating={"aggregateRating": 9.5, "voteCount": 2200000},
|
||||
plot="A chemistry teacher becomes a methamphetamine producer.",
|
||||
originCountries=[{"code": "US", "name": "United States"}],
|
||||
spokenLanguages=[{"code": "eng", "name": "English"}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imdb_api(imdb_title: ImdbTitle) -> Mock:
|
||||
"""构造同步和异步方法行为一致的 IMDb API 替身。"""
|
||||
api = Mock(spec=ImdbApi)
|
||||
api.search_titles.return_value = [imdb_title]
|
||||
api.get_title.return_value = imdb_title
|
||||
api.list_akas.return_value = [ImdbAka(text="绝命毒师")]
|
||||
api.list_credits.return_value = [
|
||||
ImdbCredit(
|
||||
name=ImdbPerson(
|
||||
id="nm0533713",
|
||||
displayName="Vince Gilligan",
|
||||
primaryImage=ImdbImage(url="https://image.example/director.jpg"),
|
||||
),
|
||||
category="DIRECTOR",
|
||||
),
|
||||
ImdbCredit(
|
||||
name=ImdbPerson(
|
||||
id="nm0186505",
|
||||
displayName="Bryan Cranston",
|
||||
primaryImage=ImdbImage(url="https://image.example/actor.jpg"),
|
||||
),
|
||||
category="ACTOR",
|
||||
characters=["Walter White"],
|
||||
),
|
||||
]
|
||||
api.list_images.return_value = [
|
||||
ImdbImage(url="https://image.example/backdrop.jpg", type="still_frame")
|
||||
]
|
||||
api.list_episodes.return_value = [
|
||||
ImdbEpisode(
|
||||
id="tt0959621",
|
||||
season="1",
|
||||
episodeNumber=1,
|
||||
releaseDate=ImdbDate(year=2008, month=1, day=20),
|
||||
)
|
||||
]
|
||||
api.list_seasons.return_value = [ImdbSeason(season="1", episodeCount=7)]
|
||||
api.async_search_titles = AsyncMock(return_value=[imdb_title])
|
||||
api.async_get_title = AsyncMock(return_value=imdb_title)
|
||||
api.async_list_akas = AsyncMock(return_value=api.list_akas.return_value)
|
||||
api.async_list_credits = AsyncMock(return_value=api.list_credits.return_value)
|
||||
api.async_list_images = AsyncMock(return_value=api.list_images.return_value)
|
||||
api.async_list_episodes = AsyncMock(return_value=api.list_episodes.return_value)
|
||||
api.async_list_seasons = AsyncMock(return_value=api.list_seasons.return_value)
|
||||
return api
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imdb_module(imdb_api: Mock) -> ImdbModule:
|
||||
"""构造注入离线 API 替身的 IMDb Module。"""
|
||||
module = ImdbModule()
|
||||
module.imdb_api = imdb_api
|
||||
module.scraper = Mock()
|
||||
return module
|
||||
|
||||
|
||||
def test_imdb_module_declares_independent_media_recognizer() -> None:
|
||||
"""IMDb 应作为独立宿主媒体识别 Module 暴露稳定元数据。"""
|
||||
assert ImdbModule.get_name() == "IMDb"
|
||||
assert ImdbModule.get_subtype() == MediaRecognizeType.IMDb
|
||||
assert ImdbModule.get_priority() == 4
|
||||
|
||||
|
||||
def test_imdb_api_search_uses_keyless_imdb_suggestion_endpoint() -> None:
|
||||
"""IMDb 标题搜索应直接调用 IMDb 免 Key 建议接口并缓存响应。"""
|
||||
api = ImdbApi()
|
||||
api.clear_cache()
|
||||
api._request = Mock()
|
||||
api._request.get_json.return_value = {
|
||||
"d": [
|
||||
{
|
||||
"id": "tt0111161",
|
||||
"qid": "movie",
|
||||
"l": "The Shawshank Redemption",
|
||||
"y": 1994,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
results = api.search_titles("The Shawshank Redemption", limit=1)
|
||||
cached_results = api.search_titles("The Shawshank Redemption", limit=1)
|
||||
|
||||
assert [item.id for item in results] == ["tt0111161"]
|
||||
assert [item.id for item in cached_results] == ["tt0111161"]
|
||||
api._request.get_json.assert_called_once_with(
|
||||
"https://v2.sg.media-imdb.com/suggestion/x/"
|
||||
"The%20Shawshank%20Redemption.json",
|
||||
params={},
|
||||
)
|
||||
|
||||
api.clear_cache()
|
||||
api.search_titles("The Shawshank Redemption", limit=1)
|
||||
assert api._request.get_json.call_count == 2
|
||||
|
||||
|
||||
def test_imdb_graphql_error_is_not_cached() -> None:
|
||||
"""GraphQL 错误响应不应污染缓存,后续成功详情仍应正常写入缓存。"""
|
||||
api = ImdbApi()
|
||||
api.clear_cache()
|
||||
api._request = Mock()
|
||||
api._request.post_json.side_effect = [
|
||||
{"errors": [{"message": "temporary failure"}]},
|
||||
{
|
||||
"data": {
|
||||
"titles": [
|
||||
{
|
||||
"id": "tt0111161",
|
||||
"titleText": {"text": "The Shawshank Redemption"},
|
||||
"titleType": {"id": "movie"},
|
||||
"releaseYear": {"year": 1994},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
assert api.get_title("tt0111161") is None
|
||||
detail = api.get_title("tt0111161")
|
||||
cached_detail = api.get_title("tt0111161")
|
||||
|
||||
assert detail and detail.primary_title == "The Shawshank Redemption"
|
||||
assert cached_detail and cached_detail.id == "tt0111161"
|
||||
assert api._request.post_json.call_count == 2
|
||||
|
||||
|
||||
def test_async_imdb_api_merges_paginated_episodes() -> None:
|
||||
"""IMDb 异步剧集查询应使用 GraphQL 游标合并并缓存所有分页。"""
|
||||
api = ImdbApi()
|
||||
asyncio.run(api.async_clear_cache())
|
||||
api._async_request = Mock()
|
||||
api._async_request.post_json = AsyncMock(
|
||||
side_effect=[
|
||||
{
|
||||
"data": {
|
||||
"titles": [
|
||||
{
|
||||
"episodes": {
|
||||
"episodes": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": "tt-first",
|
||||
"series": {
|
||||
"episodeNumber": {
|
||||
"seasonNumber": 1,
|
||||
"episodeNumber": 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": True,
|
||||
"endCursor": "next-page",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"titles": [
|
||||
{
|
||||
"episodes": {
|
||||
"episodes": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": "tt-second",
|
||||
"series": {
|
||||
"episodeNumber": {
|
||||
"seasonNumber": 1,
|
||||
"episodeNumber": 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": False,
|
||||
"endCursor": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"titles": [
|
||||
{
|
||||
"episodes": {
|
||||
"episodes": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": "tt-first",
|
||||
"series": {
|
||||
"episodeNumber": {
|
||||
"seasonNumber": 1,
|
||||
"episodeNumber": 1,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": True,
|
||||
"endCursor": "next-page",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"titles": [
|
||||
{
|
||||
"episodes": {
|
||||
"episodes": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": "tt-second",
|
||||
"series": {
|
||||
"episodeNumber": {
|
||||
"seasonNumber": 1,
|
||||
"episodeNumber": 2,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": False,
|
||||
"endCursor": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
results = asyncio.run(api.async_list_episodes("tt-series"))
|
||||
cached_results = asyncio.run(api.async_list_episodes("tt-series"))
|
||||
|
||||
assert [item.id for item in results] == ["tt-first", "tt-second"]
|
||||
assert [item.id for item in cached_results] == ["tt-first", "tt-second"]
|
||||
assert api._async_request.post_json.await_count == 2
|
||||
assert api._async_request.post_json.await_args_list[0].kwargs["json"][
|
||||
"variables"
|
||||
] == {"after": None, "first": 100, "titles": ("tt-series",)}
|
||||
assert api._async_request.post_json.await_args_list[1].kwargs["json"][
|
||||
"variables"
|
||||
] == {"after": "next-page", "first": 100, "titles": ("tt-series",)}
|
||||
|
||||
asyncio.run(api.async_clear_cache())
|
||||
refreshed_results = asyncio.run(api.async_list_episodes("tt-series"))
|
||||
assert [item.id for item in refreshed_results] == ["tt-first", "tt-second"]
|
||||
assert api._async_request.post_json.await_count == 4
|
||||
|
||||
|
||||
def test_imdb_proxy_snapshot_reloads_client_and_closes_old_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""代理变更应关闭旧 IMDb 客户端并按新代理快照重建。"""
|
||||
module = ImdbModule()
|
||||
old_client = Mock()
|
||||
new_client = Mock()
|
||||
monkeypatch.setattr(settings, "PROXY_HOST", "http://old-proxy:7890")
|
||||
|
||||
with patch("app.modules.imdb.ImdbApi", side_effect=[old_client, new_client]) as api_type:
|
||||
module.init_module()
|
||||
old_proxy = settings.PROXY
|
||||
monkeypatch.setattr(settings, "PROXY_HOST", "http://new-proxy:7890")
|
||||
new_proxy = settings.PROXY
|
||||
module.on_config_changed()
|
||||
|
||||
assert api_type.call_args_list[0].kwargs["proxies"] == old_proxy
|
||||
assert api_type.call_args_list[1].kwargs["proxies"] == new_proxy
|
||||
old_client.close.assert_called_once_with()
|
||||
assert module.imdb_api is new_client
|
||||
|
||||
|
||||
def test_imdb_module_clear_cache_delegates_to_api(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""系统统一清缓存动作应清理 IMDb HTTP 缓存区。"""
|
||||
imdb_module.clear_cache()
|
||||
|
||||
imdb_module.imdb_api.clear_cache.assert_called_once_with()
|
||||
|
||||
|
||||
def test_imdb_search_respects_source_and_returns_generic_identity(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""IMDb 搜索只响应选中的来源,并返回来源与原生 ID 对。"""
|
||||
meta = MetaBase("Breaking Bad")
|
||||
meta.name = "Breaking Bad"
|
||||
meta.type = MediaType.TV
|
||||
|
||||
assert imdb_module.search_medias(meta, media_source=MediaSource.Douban) is None
|
||||
|
||||
results = imdb_module.search_medias(meta, media_source=MediaSource.IMDb)
|
||||
|
||||
assert results and len(results) == 1
|
||||
assert results[0].media_source == MediaSource.IMDb
|
||||
assert results[0].media_id == "tt0903747"
|
||||
assert results[0].imdb_id == "tt0903747"
|
||||
assert results[0].type == MediaType.TV
|
||||
|
||||
|
||||
def test_imdb_explicit_identity_recognition_enriches_media_info(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""显式 IMDb ID 识别应补齐别名、演职员、剧集和图片。"""
|
||||
result = imdb_module.recognize_media(
|
||||
media_source=MediaSource.IMDb,
|
||||
media_id="TT0903747",
|
||||
)
|
||||
|
||||
assert isinstance(result, MediaInfo)
|
||||
assert result.media_source == MediaSource.IMDb
|
||||
assert result.media_id == "tt0903747"
|
||||
assert result.title == "Breaking Bad"
|
||||
assert result.names == ["Breaking Bad", "绝命毒师"]
|
||||
assert result.backdrop_path == "https://image.example/backdrop.jpg"
|
||||
assert result.directors[0]["name"] == "Vince Gilligan"
|
||||
assert result.actors[0]["character"] == "Walter White"
|
||||
assert result.seasons == {1: [1]}
|
||||
assert result.season_years == {1: "2008"}
|
||||
assert result.number_of_episodes == 1
|
||||
assert result.runtime == 47
|
||||
assert result.category == "欧美剧"
|
||||
|
||||
|
||||
def test_imdb_name_recognition_requires_imdb_selection(
|
||||
imdb_module: ImdbModule, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""名称识别仅在请求级或全局来源选中 IMDb 时执行。"""
|
||||
meta = MetaBase("绝命毒师")
|
||||
meta.name = "绝命毒师"
|
||||
meta.cn_name = "绝命毒师"
|
||||
meta.type = MediaType.TV
|
||||
monkeypatch.setattr(settings, "RECOGNIZE_SOURCE", MediaSource.TMDB.value)
|
||||
|
||||
assert imdb_module.recognize_media(meta=meta) is None
|
||||
|
||||
result = imdb_module.recognize_media(
|
||||
meta=meta,
|
||||
media_source=MediaSource.IMDb,
|
||||
)
|
||||
|
||||
assert result and result.media_id == "tt0903747"
|
||||
imdb_module.imdb_api.search_titles.assert_called_with("绝命毒师")
|
||||
|
||||
|
||||
def test_imdb_recognition_rejects_invalid_or_music_identity(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""IMDb Module 不应接管非法 ID 或音乐识别请求。"""
|
||||
assert (
|
||||
imdb_module.recognize_media(
|
||||
media_source=MediaSource.IMDb,
|
||||
media_id="0903747",
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
imdb_module.recognize_media(
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=MediaSource.IMDb,
|
||||
media_id="tt0903747",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_async_imdb_recognition_uses_async_detail_pipeline(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""异步显式识别应使用异步详情管线并保持统一身份。"""
|
||||
result = asyncio.run(
|
||||
imdb_module.async_recognize_media(
|
||||
media_source=MediaSource.IMDb,
|
||||
media_id="tt0903747",
|
||||
)
|
||||
)
|
||||
|
||||
assert result and result.media_source == MediaSource.IMDb
|
||||
assert result.media_id == "tt0903747"
|
||||
assert result.backdrop_path == "https://image.example/backdrop.jpg"
|
||||
imdb_module.imdb_api.async_get_title.assert_awaited()
|
||||
imdb_module.imdb_api.async_list_credits.assert_awaited_once_with("tt0903747")
|
||||
|
||||
|
||||
def test_imdb_scraping_only_handles_imdb_source(
|
||||
imdb_module: ImdbModule,
|
||||
) -> None:
|
||||
"""IMDb NFO 和图片刮削只响应显式 IMDb 刮削来源。"""
|
||||
media_info = MediaInfo()
|
||||
media_info.scrape_source = MediaSource.Douban.value
|
||||
assert imdb_module.metadata_nfo(media_info) is None
|
||||
assert imdb_module.metadata_img(media_info) is None
|
||||
|
||||
media_info.scrape_source = MediaSource.IMDb.value
|
||||
imdb_module.scraper.get_metadata_nfo.return_value = "<movie />"
|
||||
imdb_module.scraper.get_metadata_img.return_value = {"poster.jpg": "url"}
|
||||
|
||||
assert imdb_module.metadata_nfo(media_info) == "<movie />"
|
||||
assert imdb_module.metadata_img(media_info) == {"poster.jpg": "url"}
|
||||
@@ -490,7 +490,7 @@ from app.runtime.extensions.host_module_adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 37
|
||||
assert len(specs) == 38
|
||||
|
||||
adapter = HostModuleAdapter()
|
||||
lifecycle_events = []
|
||||
@@ -537,7 +537,7 @@ from app.schemas.types import EventType
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 37
|
||||
assert len(specs) == 38
|
||||
spec_by_id = {spec.id: spec for spec in specs}
|
||||
|
||||
events = {spec.id: [] for spec in specs}
|
||||
@@ -704,7 +704,7 @@ from app.runtime.extensions.host_module_adapter import (
|
||||
|
||||
registry = build_host_module_registry()
|
||||
specs = registry.list_specs()
|
||||
assert len(specs) == 37
|
||||
assert len(specs) == 38
|
||||
configured_specs = tuple(
|
||||
spec for spec in specs
|
||||
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
|
||||
@@ -800,12 +800,12 @@ from app.application.module import configure_module_runtime
|
||||
configure_module_runtime(lambda: ModuleManager())
|
||||
|
||||
manager = ModuleManager()
|
||||
assert len(manager.list_specs()) == 37
|
||||
assert len(manager.list_specs()) == 38
|
||||
assert manager.get_specs() == manager.list_specs()
|
||||
|
||||
from app.api.endpoints.system import modulelist
|
||||
response = modulelist(None)
|
||||
assert len(response.data["modules"]) == 37
|
||||
assert len(response.data["modules"]) == 38
|
||||
|
||||
heavy_prefixes = (
|
||||
"lark_oapi",
|
||||
@@ -913,7 +913,7 @@ from app.runtime.extensions.module_manager import ModuleManager
|
||||
|
||||
manager = ModuleManager()
|
||||
modules = manager.get_modules()
|
||||
assert len(modules) == len(manager.list_specs()) == 37
|
||||
assert len(modules) == len(manager.list_specs()) == 38
|
||||
for spec in manager.list_specs():
|
||||
implementation = modules[spec.id]
|
||||
assert implementation.get_name() == spec.metadata["name"]
|
||||
|
||||
Reference in New Issue
Block a user