feat(media): add native IMDb source module

This commit is contained in:
jxxghp
2026-08-22 11:22:59 +08:00
parent b8c79e1a1e
commit 1a7c3cfff8
12 changed files with 1847 additions and 13 deletions
+677
View File
@@ -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
)