diff --git a/app/locales/en-US.json b/app/locales/en-US.json
index cc1a2209a..24dd339e7 100644
--- a/app/locales/en-US.json
+++ b/app/locales/en-US.json
@@ -25,6 +25,9 @@
"FilterModule": {
"name": "Filter"
},
+ "ImdbModule": {
+ "name": "IMDb"
+ },
"IndexerModule": {
"name": "Site Indexer"
},
diff --git a/app/locales/zh-CN.json b/app/locales/zh-CN.json
index 0de92ca86..840b16c51 100644
--- a/app/locales/zh-CN.json
+++ b/app/locales/zh-CN.json
@@ -25,6 +25,9 @@
"FilterModule": {
"name": "过滤器"
},
+ "ImdbModule": {
+ "name": "IMDb"
+ },
"IndexerModule": {
"name": "站点索引"
},
diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json
index 4789ea1b3..e2f68cace 100644
--- a/app/locales/zh-TW.json
+++ b/app/locales/zh-TW.json
@@ -25,6 +25,9 @@
"FilterModule": {
"name": "過濾器"
},
+ "ImdbModule": {
+ "name": "IMDb"
+ },
"IndexerModule": {
"name": "站點索引"
},
diff --git a/app/modules/imdb/__init__.py b/app/modules/imdb/__init__.py
new file mode 100644
index 000000000..ec196f7fd
--- /dev/null
+++ b/app/modules/imdb/__init__.py
@@ -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
+ )
diff --git a/app/modules/imdb/api.py b/app/modules/imdb/api.py
new file mode 100644
index 000000000..a500cb929
--- /dev/null
+++ b/app/modules/imdb/api.py
@@ -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]})
+ )
diff --git a/app/modules/imdb/capability.toml b/app/modules/imdb/capability.toml
new file mode 100644
index 000000000..431c588b9
--- /dev/null
+++ b/app/modules/imdb/capability.toml
@@ -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"]
diff --git a/app/runtime/config.py b/app/runtime/config.py
index 70f95d286..fce144102 100644
--- a/app/runtime/config.py
+++ b/app/runtime/config.py
@@ -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,
diff --git a/app/schemas/types.py b/app/schemas/types.py
index 8b76c9b7b..f988ca3d9 100644
--- a/app/schemas/types.py
+++ b/app/schemas/types.py
@@ -605,6 +605,8 @@ class MediaRecognizeType(Enum):
Bangumi = "Bangumi"
# AniList
AniList = "AniList"
+ # IMDb
+ IMDb = "IMDb"
# MusicBrainz
MusicBrainz = "MusicBrainz"
# TheAudioDB
diff --git a/tests/fixtures/architecture/configuration-debt-baseline.json b/tests/fixtures/architecture/configuration-debt-baseline.json
index e5ec2c07d..63c7e531a 100644
--- a/tests/fixtures/architecture/configuration-debt-baseline.json
+++ b/tests/fixtures/architecture/configuration-debt-baseline.json
@@ -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",
diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json
index 486645837..a57b1ae9e 100644
--- a/tests/fixtures/architecture/dependency-baseline.json
+++ b/tests/fixtures/architecture/dependency-baseline.json
@@ -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",
diff --git a/tests/test_imdb_module.py b/tests/test_imdb_module.py
new file mode 100644
index 000000000..0a6d9c264
--- /dev/null
+++ b/tests/test_imdb_module.py
@@ -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 = ""
+ imdb_module.scraper.get_metadata_img.return_value = {"poster.jpg": "url"}
+
+ assert imdb_module.metadata_nfo(media_info) == ""
+ assert imdb_module.metadata_img(media_info) == {"poster.jpg": "url"}
diff --git a/tests/test_module_manager_capability_adapter.py b/tests/test_module_manager_capability_adapter.py
index 5cc183eac..2e443e396 100644
--- a/tests/test_module_manager_capability_adapter.py
+++ b/tests/test_module_manager_capability_adapter.py
@@ -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"]