"""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.settings import RuntimeSettingsCompat settings = RuntimeSettingsCompat() 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 )