Files
MoviePilot/app/modules/themoviedb/__init__.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

2085 lines
84 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
TMDB 媒体识别模块、标准媒体投影与辅助信息查询入口。
"""
import re
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any, Dict, Generator, List, Optional, Tuple, Union, cast
import cn2an
from app.adapters.network.http import RequestUtils
from app.domain.classification.evaluator import read_fact
from app.domain.classification.facts import build_classification_facts
from app.domain.context import MediaInfo
from app.domain.media import is_media_source_enabled, is_media_source_selected
from app.domain.meta.metabase import MetaBase
from app.foundation.text import convert as zhconv_convert
from app.modules import _ModuleBase
from app.modules._base.media import MediaAuxiliaryProviderMixin
from app.modules.themoviedb.cache import TmdbCache
from app.modules.themoviedb.category import CategoryHelper as CategoryHelper
from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.modules.themoviedb.tmdbv3api.exceptions import TMDbConnectionError
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.category import (
ClassificationEnrichmentMatch,
ClassificationEnrichmentRequest,
ClassificationEnrichmentResponse,
)
from app.schemas.context import MediaPerson as _SchemaMediaPerson
from app.schemas.media import normalize_media_source
from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode
from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason
from app.schemas.types import (
MediaImageType,
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_TmdbData = Dict[str, Any]
_TmdbDataList = List[_TmdbData]
@dataclass(frozen=True)
class _RecognizePlan:
"""
保存同步与异步 TMDB 识别入口共用的请求决策。
:param meta: 待识别元数据,可为空;缓存读取阶段可能更新其类型和媒体身份
:param mtype: 显式指定的媒体类型,未指定时结合元数据判断
:param tmdbid: 校验后的 TMDB 数字标识,未指定时按名称识别
:param episode_group: 可选剧集组标识,用于映射季集和年份
:param use_cache: 是否读取识别缓存;关闭时仍可写入本次识别结果
"""
meta: Optional[MetaBase]
mtype: Optional[MediaType]
tmdbid: Optional[int]
episode_group: Optional[str]
use_cache: bool
class _RecognizeAction(Enum):
"""
标识 TMDB 识别纯状态机交给同步或异步外壳执行的 I/O 动作。
"""
# 准备元数据身份并读取识别缓存。
LOAD_CACHE = auto()
# 查询指定剧集组的季集信息。
LOAD_GROUP = auto()
# 按显式 TMDB 标识查询并消歧。
LOOKUP_IDENTITY = auto()
# 按本轮名称和元数据搜索候选。
SEARCH_NAME = auto()
# 补充候选媒体的完整详情。
LOAD_DETAILS = auto()
# 按结果状态写入识别缓存。
SAVE_CACHE = auto()
# 构造标准媒体并补充季集信息。
BUILD_RESULT = auto()
# 记录缓存命中信息。
LOG_CACHE = auto()
# 记录身份查询或连接失败。
LOG_FAILURE = auto()
# 记录最终未识别结果。
LOG_MISS = auto()
@dataclass(frozen=True)
class _RecognizeStep:
"""
描述 TMDB 识别状态机的一次 I/O 请求及其稳定调用参数。
:param action: 由同步或异步执行器完成的识别动作
:param kwargs: 该动作的稳定命名参数,执行器按动作类型解释
"""
action: _RecognizeAction
kwargs: Dict[str, Any]
@dataclass(frozen=True)
class _RecognizeLookup:
"""
承载显式身份查询结果及连接失败状态,供纯状态机统一决策。
:param info: 身份查询结果;未命中或失败时可为空
:param connection_error: 是否发生明确的连接失败,默认 False,用于区分未命中
"""
info: Optional[_TmdbData]
connection_error: bool = False
@dataclass(frozen=True)
class _MatchStep:
"""
描述一次按名称匹配调用,I/O 外壳只负责选择同步或异步客户端。
:param name: 本轮用于匹配的名称
:param mtype: 本轮指定的电影或电视剧类型,None 表示未限定
:param year: 候选作品年份,可为空
:param season_year: 目标季首播年份,可为空
:param season_number: 目标季号,0 表示特别篇,None 表示未指定
:param include_year: 是否将 year 参数传给客户端
:param include_season: 是否传递季号和季年份
:param include_group_seasons: 是否传递已获取的剧集组详情
:param multi: 是否使用不限定类型的混合搜索
"""
name: str
mtype: Optional[MediaType] = None
year: Optional[str] = None
season_year: Optional[str] = None
season_number: Optional[int] = None
include_year: bool = False
include_season: bool = False
include_group_seasons: bool = False
multi: bool = False
@dataclass(frozen=True)
class _MediaSearchPlan:
"""
描述媒体搜索所需的 TMDB 查询组合。
:param name: 搜索关键词
:param year: 来自元数据的年份筛选值,未指定时为空
:param search_movies: 是否查询电影结果
:param search_tvs: 是否查询电视剧结果
:param search_multi: 是否调用混合类型搜索
:param sort_combined: 是否对合并的电影和电视剧结果按日期倒序排列
"""
name: str
year: Any
search_movies: bool
search_tvs: bool
search_multi: bool
sort_combined: bool
@dataclass(frozen=True)
class _ImageQuery:
"""
保存同步与异步图片客户端共用的查询参数。
:param tmdbid: 用于图片查询的 TMDB 数字标识
:param original_language: 用于构建语言回退列表的原始语言,可为空
:param movie: True 查询电影图片,False 查询电视剧图片
"""
tmdbid: int
original_language: Optional[str]
movie: bool
class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
"""
组织 TMDB 媒体识别、分类事实补充、图片获取与刮削。
同步与异步入口共享识别决策,保持类型消歧、年份回退和缓存语义一致。
"""
CONFIG_WATCH = {"PROXY_HOST", "TMDB_API_DOMAIN", "TMDB_API_KEY", "TMDB_LOCALE"}
auxiliary_media_source = MediaSource.TMDB
# 元数据缓存。
cache: TmdbCache
# 客户端在 init_module 中初始化;属性访问要求初始化后非空。
_tmdb: Optional[TmdbApi] = None
# 元数据刮削器。
scraper: TmdbScraper
def init_module(self) -> None:
"""
初始化识别缓存、TMDB 客户端和元数据刮削器。
"""
self.cache = TmdbCache()
self.tmdb = TmdbApi()
self.scraper = TmdbScraper()
@property
def tmdb(self) -> TmdbApi:
"""
返回已初始化的 TMDB 客户端。
"""
if self._tmdb is None:
self._tmdb = TmdbApi()
return self._tmdb
@tmdb.setter
def tmdb(self, client: TmdbApi) -> None:
"""
保存模块生命周期提供的 TMDB 客户端。
"""
self._tmdb = client
@staticmethod
def get_name() -> str:
"""
返回模块显示名称。
"""
return "TheMovieDb"
@staticmethod
def get_classification_enrichment_sources() -> tuple[MediaSource, ...]:
"""
声明本模块只能以 TMDB 来源补充标准分类事实。
"""
return (MediaSource.TMDB,)
def get_media_classification_facts(
self,
request: ClassificationEnrichmentRequest,
) -> ClassificationEnrichmentResponse | None:
"""
通过请求中已知 TMDB ID 补充影视标准事实,不修改主媒体身份。
:param request: 包含媒体类型、已知外部标识和缺失事实字段的补充请求
:return: 仅包含成功补齐字段的 TMDB 事实响应;身份无效、类型不支持或详情缺失时返回 None
"""
if request.media_type not in {MediaType.MOVIE.value, MediaType.TV.value}:
return None
raw_tmdb_id = request.external_ids.get(MediaSource.TMDB.value)
if not raw_tmdb_id:
return None
try:
tmdb_id = int(raw_tmdb_id)
media_type = MediaType(request.media_type)
except (TypeError, ValueError):
return None
info = self.tmdb_info(tmdbid=tmdb_id, mtype=media_type)
if not info:
return None
facts = build_classification_facts(MediaInfo(tmdb_info=info))
supplied = {}
for field_id in request.missing_fields:
value, missing = read_fact(facts, field_id)
if not missing:
supplied[field_id] = value
return ClassificationEnrichmentResponse(
media_source=MediaSource.TMDB.value,
match=ClassificationEnrichmentMatch(
kind="external_id",
media_source=MediaSource.TMDB.value,
media_id=raw_tmdb_id,
),
facts=supplied,
)
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型。
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型。
"""
return MediaRecognizeType.TMDB
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效。
"""
return 1
def stop(self) -> None:
"""
保存识别缓存,并保证随后尝试关闭 TMDB 客户端。
"""
# 缓存持久化失败不能阻断 HTTP 客户端关闭
try:
self.cache.save()
finally:
self.tmdb.close()
def test(self) -> Tuple[bool, str]:
"""
请求已知电影条目,检查 TMDB 连通性和认证状态。
:return: 连通性结果与错误说明二元组;成功时说明为空字符串
"""
ret = RequestUtils(ua=get_runtime_setting("NORMAL_USER_AGENT"), proxies=get_runtime_setting("PROXY")).get_res(
f"https://{get_runtime_setting('TMDB_API_DOMAIN')}/3/movie/550?api_key={get_runtime_setting('TMDB_API_KEY')}"
)
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接 {get_runtime_setting('TMDB_API_DOMAIN')},错误码:{ret.status_code}"
return False, f"{get_runtime_setting('TMDB_API_DOMAIN')} 网络连接失败"
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""
保留模块配置入口;TMDB 配置由运行设置管理,返回 None。
"""
return None
@staticmethod
def _validate_recognize_params(
meta: Optional[MetaBase],
tmdbid: Optional[int],
media_source: Optional[MediaSource] = None,
) -> bool:
"""
验证识别参数。
:param meta: 标题解析元数据
:param tmdbid: 显式指定的 TMDB 媒体标识;为空时需要可用的标题元数据
:param media_source: 请求级识别数据源
:return: 参数是否可用于TMDB识别
"""
if not tmdbid and not meta:
return False
selected_source = normalize_media_source(media_source or get_runtime_setting("RECOGNIZE_SOURCE"))
if meta and not tmdbid and selected_source != MediaSource.TMDB:
return False
if meta and not meta.name and not tmdbid:
logger.warn("识别媒体信息时未提供元数据名称")
return False
return True
@classmethod
def _build_recognize_plan(
cls,
meta: Optional[MetaBase],
mtype: Optional[MediaType],
media_source: Optional[MediaSource],
media_id: Optional[str],
episode_group: Optional[str],
cache: Optional[bool],
) -> Optional[_RecognizePlan]:
"""
统一验证识别身份并生成不含客户端状态的执行计划。
"""
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
return None
if media_source and media_source != MediaSource.TMDB:
return None
if media_id is not None and (media_source != MediaSource.TMDB or not str(media_id).isdigit()):
return None
tmdbid = int(media_id) if media_id is not None else None
if not cls._validate_recognize_params(meta, tmdbid, media_source):
return None
return _RecognizePlan(
meta=meta,
mtype=mtype,
tmdbid=tmdbid,
episode_group=episode_group,
use_cache=bool(cache),
)
def _load_recognize_cache(self, plan: _RecognizePlan) -> _TmdbData:
"""
按统一计划投影元数据身份并读取识别缓存。
"""
if not plan.meta:
return {}
if plan.mtype:
plan.meta.type = plan.mtype
if plan.tmdbid:
plan.meta.media_source = MediaSource.TMDB
plan.meta.media_id = str(plan.tmdbid)
return self.cache.get(plan.meta) if plan.use_cache else {}
@staticmethod
def _log_recognize_lookup_failure(plan: _RecognizePlan, connection_error: bool) -> None:
"""
按统一语义记录确定缺失、连接失败和无有效输入三类结果。
"""
if connection_error:
logger.error(f"tmdb_id:{plan.tmdbid} 连接TheMovieDb失败,无法完成识别,请检查网络连接后重试")
elif plan.tmdbid:
logger.warn(f"tmdb_id:{plan.tmdbid} 无法确定媒体类型,识别失败")
else:
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
def _save_recognize_cache(self, plan: _RecognizePlan, info: Optional[_TmdbData]) -> None:
"""
将同步与异步查询结果写入同一识别缓存合同。
"""
if plan.meta:
self.cache.update(plan.meta, info)
@staticmethod
def _prepare_search_names(meta: MetaBase) -> List[str]:
"""
准备搜索名称列表。
"""
zh_name = zhconv_convert(meta.cn_name, "zh-hans") if meta.cn_name else None
# 使用中英文名分别识别,去重去空,但要保持顺序
return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k]))
@staticmethod
def _fill_group_season_info(mediainfo: MediaInfo, episode_group: Optional[str], group_seasons: _TmdbDataList) -> None:
"""
将指定剧集组的季、集、年份信息写入 MediaInfo。
"""
seasons: dict[int, list[Any]] = {}
season_info: _TmdbDataList = []
season_years: dict[int, str] = {}
for group_season in group_seasons:
season = group_season.get("order")
episodes = group_season.get("episodes")
if not episodes:
continue
if not isinstance(season, int):
continue
seasons[season] = [ep.get("episode_number") for ep in episodes]
season_info.append(group_season)
first_date = episodes[0].get("air_date")
if first_date and _DATE_RE.match(first_date):
season_years[season] = str(first_date).split("-")[0]
if seasons:
mediainfo.seasons = seasons
mediainfo.number_of_seasons = len(seasons)
if season_info:
mediainfo.season_info = season_info
if season_years:
mediainfo.season_years = season_years
if episode_group is not None:
mediainfo.episode_group = episode_group
mediainfo.episode_groups = group_seasons
@staticmethod
def _build_search_medias_result(meta: MetaBase, results: Optional[_TmdbDataList]) -> List[MediaInfo]:
"""
构建搜索结果,并沿用原有逻辑把搜索词中的季写入电视剧标题中。
"""
if not results:
return []
medias = [MediaInfo(tmdb_info=info) for info in results]
if meta.begin_season is not None:
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in medias:
if media.type == MediaType.TV:
media.title = f"{media.title} 第{season_str}季"
media.season = meta.begin_season
return medias
@staticmethod
def _build_media_search_plan(
meta: MetaBase, media_source: Optional[MediaSourceSelection]
) -> Optional[_MediaSearchPlan]:
"""
统一选择媒体搜索接口及组合结果排序策略。
"""
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not meta.name:
return _MediaSearchPlan(
name="",
year=meta.year,
search_movies=False,
search_tvs=False,
search_multi=False,
sort_combined=False,
)
if meta.type == MediaType.UNKNOWN and not meta.year:
return _MediaSearchPlan(
name=meta.name,
year=meta.year,
search_movies=False,
search_tvs=False,
search_multi=True,
sort_combined=False,
)
search_movies = meta.type in (MediaType.UNKNOWN, MediaType.MOVIE)
search_tvs = meta.type not in (MediaType.MOVIE,)
return _MediaSearchPlan(
name=meta.name,
year=meta.year,
search_movies=search_movies,
search_tvs=search_tvs,
search_multi=False,
sort_combined=meta.type == MediaType.UNKNOWN,
)
@staticmethod
def _merge_media_search_results(
plan: _MediaSearchPlan,
movie_results: Optional[_TmdbDataList] = None,
tv_results: Optional[_TmdbDataList] = None,
multi_results: Optional[_TmdbDataList] = None,
) -> _TmdbDataList:
"""
按搜索计划合并客户端结果,并稳定保持原有日期倒序。
"""
if plan.search_multi:
return list(multi_results or [])
results = list(movie_results or [])
results.extend(tv_results or [])
if plan.sort_combined:
results.sort(
key=lambda item: item.get("release_date") or item.get("first_air_date") or "0000-00-00",
reverse=True,
)
return results
def _safe_get_info_by_type(self, mtype: MediaType, tmdbid: int) -> Tuple[Optional[_TmdbData], bool]:
"""
查询指定类型的媒体详情,将"确认TMDB连接失败"与"确认查无此项"区分开。
:param mtype: 媒体类型:电影或电视剧
:param tmdbid: TMDB的ID
:return: (媒体信息或None, 本次查询是否因TMDB连接失败而没有得到确定结果)
"""
try:
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid, raise_on_connection_error=True), False
except TMDbConnectionError:
return None, True
async def _async_safe_get_info_by_type(self, mtype: MediaType, tmdbid: int) -> Tuple[Optional[_TmdbData], bool]:
"""
查询指定类型的媒体详情,将"确认TMDB连接失败"与"确认查无此项"区分开(异步版本)。
"""
try:
return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid, raise_on_connection_error=True), False
except TMDbConnectionError:
return None, True
@classmethod
def _resolve_tmdbid_candidates(
cls,
tmdbid: int,
meta: Optional[MetaBase],
info_tv: Optional[_TmdbData],
tv_conn_error: bool,
info_movie: Optional[_TmdbData],
movie_conn_error: bool,
) -> Optional[_TmdbData]:
"""
统一解释电影、电视剧两路详情结果并执行元数据消歧。
"""
if info_tv and info_movie:
result = cls._disambiguate_by_meta(info_tv, info_movie, meta)
if result:
return result
logger.warn(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧")
return None
if info_tv or info_movie:
return info_tv or info_movie
if tv_conn_error or movie_conn_error:
raise TMDbConnectionError(f"连接TheMovieDb失败,无法确认tmdb_id:{tmdbid} 的媒体类型")
return None
def _get_info_by_tmdbid(self, tmdbid: int, mtype: Optional[MediaType], meta: Optional[MetaBase]) -> Optional[_TmdbData]:
"""
根据tmdbid查询媒体信息,当类型未知且同时存在电影和电视剧时,通过元数据消歧。
:raises TMDbConnectionError: 电影、电视剧两路查询都没有得到确定结果,且至少一路
是因TMDB连接失败导致的,此时不能断言"条目不存在",交由上层报网络故障
"""
if mtype:
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid, raise_on_connection_error=True)
# 类型未知,分别查询电影和电视剧;每一路的连接失败要单独识别,
# 避免一路瞬时抖动掩盖另一路已经得到的确定结果
info_tv, tv_conn_error = self._safe_get_info_by_type(MediaType.TV, tmdbid)
info_movie, movie_conn_error = self._safe_get_info_by_type(MediaType.MOVIE, tmdbid)
return self._resolve_tmdbid_candidates(tmdbid, meta, info_tv, tv_conn_error, info_movie, movie_conn_error)
async def _async_get_info_by_tmdbid(
self, tmdbid: int, mtype: Optional[MediaType], meta: Optional[MetaBase]
) -> Optional[_TmdbData]:
"""
根据tmdbid查询媒体信息,当类型未知且同时存在电影和电视剧时,通过元数据消歧(异步版本)。
:raises TMDbConnectionError: 电影、电视剧两路查询都没有得到确定结果,且至少一路
是因TMDB连接失败导致的,此时不能断言"条目不存在",交由上层报网络故障
"""
if mtype:
return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid, raise_on_connection_error=True)
# 类型未知,分别查询电影和电视剧;每一路的连接失败要单独识别,
# 避免一路瞬时抖动掩盖另一路已经得到的确定结果
info_tv, tv_conn_error = await self._async_safe_get_info_by_type(MediaType.TV, tmdbid)
info_movie, movie_conn_error = await self._async_safe_get_info_by_type(MediaType.MOVIE, tmdbid)
return self._resolve_tmdbid_candidates(tmdbid, meta, info_tv, tv_conn_error, info_movie, movie_conn_error)
@staticmethod
def _disambiguate_by_meta(info_tv: _TmdbData, info_movie: _TmdbData, meta: Optional[MetaBase]) -> Optional[_TmdbData]:
"""
通过元数据(标题、年份、类型)对同tmdbid的电影和电视剧进行消歧。
"""
if not meta:
return None
def _collect_titles(info: _TmdbData) -> set[str]:
"""
收集 TMDB 记录中的本地和原始标题,供候选匹配使用。
"""
titles = set()
for key in ("title", "name", "original_title", "original_name"):
if info.get(key):
titles.add(info[key])
for name in info.get("names") or []:
titles.add(name)
return titles
def _match_score(info: _TmdbData) -> int:
"""
按候选标题和识别名称计算匹配分值,供媒体结果排序使用。
"""
score = 0
titles = _collect_titles(info)
meta_names = [n for n in [meta.cn_name, meta.en_name] if n]
for meta_name in meta_names:
if any(meta_name in t or t in meta_name for t in titles):
score += 2
break
if meta.year:
release_date = info.get("release_date") or info.get("first_air_date") or ""
if release_date and release_date[:4] == meta.year:
score += 1
return score
score_tv = _match_score(info_tv)
score_movie = _match_score(info_movie)
if score_tv > score_movie:
logger.info(f"通过元数据消歧,tmdb_id:{info_tv.get('id')} 识别为电视剧")
return info_tv
elif score_movie > score_tv:
logger.info(f"通过元数据消歧,tmdb_id:{info_movie.get('id')} 识别为电影")
return info_movie
# 评分相同时参考meta.type
if meta.type == MediaType.TV:
logger.info(f"通过媒体类型提示消歧,tmdb_id:{info_tv.get('id')} 识别为电视剧")
return info_tv
elif meta.type == MediaType.MOVIE:
logger.info(f"通过媒体类型提示消歧,tmdb_id:{info_movie.get('id')} 识别为电影")
return info_movie
return None
@staticmethod
def _build_match_plan(name: str, meta: MetaBase) -> Tuple[_MatchStep, ...]:
"""
生成名称识别的有序回退计划,确保同步与异步分支完全一致。
"""
normalized_year = str(meta.year) if meta.year is not None else None
if meta.type == MediaType.UNKNOWN and not meta.year:
return (_MatchStep(name=name, multi=True),)
if meta.type == MediaType.TV:
return (
_MatchStep(
name=name,
year=normalized_year,
mtype=meta.type,
season_year=normalized_year,
season_number=meta.begin_season,
include_year=True,
include_season=True,
include_group_seasons=True,
),
_MatchStep(name=name, mtype=meta.type),
)
return (
_MatchStep(
name=name,
year=normalized_year,
mtype=MediaType.MOVIE,
include_year=True,
),
_MatchStep(
name=name,
year=normalized_year,
mtype=MediaType.TV,
include_year=True,
include_group_seasons=True,
),
_MatchStep(name=name, multi=True),
)
@staticmethod
def _match_kwargs(step: _MatchStep, group_seasons: _TmdbDataList) -> Dict[str, Any]:
"""
把匹配步骤转换为客户端参数,并仅在原合同要求时携带剧集组。
"""
kwargs: Dict[str, Any] = {"name": step.name}
if step.include_year:
kwargs["year"] = step.year
if step.mtype is not None:
kwargs["mtype"] = step.mtype
if step.include_season:
kwargs["season_year"] = step.season_year
kwargs["season_number"] = step.season_number
if step.include_group_seasons:
kwargs["group_seasons"] = group_seasons
return kwargs
@staticmethod
def _log_match_start(name: str, meta: MetaBase) -> None:
"""
记录名称匹配开始信息,避免双入口文案漂移。
"""
if meta.begin_season is not None:
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {name} ...")
def _search_by_name(self, name: str, meta: MetaBase, group_seasons: _TmdbDataList) -> Optional[_TmdbData]:
"""
根据名称搜索媒体信息。
"""
self._log_match_start(name, meta)
for step in self._build_match_plan(name, meta):
if step.multi:
info = self.tmdb.match_multi(name=step.name)
else:
info = self.tmdb.match(**self._match_kwargs(step, group_seasons))
if info:
return info
return None
async def _async_search_by_name(
self, name: str, meta: MetaBase, group_seasons: _TmdbDataList
) -> Optional[_TmdbData]:
"""
根据名称搜索媒体信息(异步版本)。
"""
self._log_match_start(name, meta)
for step in self._build_match_plan(name, meta):
if step.multi:
info = await self.tmdb.async_match_multi(name=step.name)
else:
info = await self.tmdb.async_match(**self._match_kwargs(step, group_seasons))
if info:
return info
return None
@classmethod
def _prepare_episode_group_queries(
cls,
mediainfo: MediaInfo,
episode_group: Optional[str],
group_seasons: _TmdbDataList,
) -> List[str]:
"""
应用已知剧集组并返回仍需查询年份的分组 ID。
"""
if mediainfo.type != MediaType.TV or not mediainfo.episode_groups:
return []
if group_seasons:
cls._fill_group_season_info(mediainfo, episode_group, group_seasons)
return []
group_ids: List[str] = []
for group in mediainfo.episode_groups:
group_id = group.get("id")
if group.get("type") == 6 and group_id:
group_ids.append(cast(str, group_id))
return group_ids
@staticmethod
def _collect_group_season_years(
group_seasons: _TmdbDataList,
) -> Dict[int, str]:
"""
从已获取的剧集组详情中提取每季首播年份。
"""
season_years: Dict[int, str] = {}
for group_season in group_seasons:
season = group_season.get("order")
episodes = group_season.get("episodes")
if not episodes:
continue
first_date = episodes[0].get("air_date")
if first_date and _DATE_RE.match(first_date):
season_years[cast(int, season)] = str(first_date).split("-")[0]
return season_years
@staticmethod
def _apply_group_season_years(mediainfo: MediaInfo, season_years: Dict[int, str]) -> MediaInfo:
"""
把聚合后的剧集组年份写回媒体结果。
"""
if season_years:
mediainfo.season_years = season_years
return mediainfo
def _process_episode_groups(
self, mediainfo: MediaInfo, episode_group: Optional[str], group_seasons: _TmdbDataList
) -> MediaInfo:
"""
处理剧集组信息。
"""
season_years: Dict[int, str] = {}
for group_id in self._prepare_episode_group_queries(mediainfo, episode_group, group_seasons):
fetched_seasons = self.tmdb.get_tv_group_seasons(group_id)
if fetched_seasons:
season_years.update(self._collect_group_season_years(fetched_seasons))
return self._apply_group_season_years(mediainfo, season_years)
async def _async_process_episode_groups(
self, mediainfo: MediaInfo, episode_group: Optional[str], group_seasons: _TmdbDataList
) -> MediaInfo:
"""
处理剧集组信息(异步版本)。
"""
season_years: Dict[int, str] = {}
for group_id in self._prepare_episode_group_queries(mediainfo, episode_group, group_seasons):
fetched_seasons = await self.tmdb.async_get_tv_group_seasons(group_id)
if fetched_seasons:
season_years.update(self._collect_group_season_years(fetched_seasons))
return self._apply_group_season_years(mediainfo, season_years)
def _build_media_info_base(self, info: _TmdbData, meta: Optional[MetaBase], tmdbid: Optional[int]) -> MediaInfo:
"""
统一完成 TMDB 详情到 MediaInfo 的标准投影与结果日志。
"""
mediainfo = MediaInfo(tmdb_info=info)
if meta:
logger.info(f"{meta.name} TMDB识别结果:{(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year} {mediainfo.tmdb_id}")
else:
logger.info(f"{tmdbid} TMDB识别结果:{(mediainfo.type or MediaType.UNKNOWN).value} {mediainfo.title_year}")
return mediainfo
def _build_media_info_result(
self,
info: _TmdbData,
meta: MetaBase,
tmdbid: Optional[int],
episode_group: Optional[str],
group_seasons: _TmdbDataList,
) -> MediaInfo:
"""
构建同步识别结果并补充剧集组信息。
"""
mediainfo = self._build_media_info_base(info, meta, tmdbid)
return self._process_episode_groups(mediainfo, episode_group, group_seasons)
async def _async_build_media_info_result(
self,
info: _TmdbData,
meta: MetaBase,
tmdbid: Optional[int],
episode_group: Optional[str],
group_seasons: _TmdbDataList,
) -> MediaInfo:
"""
构建异步识别结果并补充剧集组信息。
"""
mediainfo = self._build_media_info_base(info, meta, tmdbid)
return await self._async_process_episode_groups(mediainfo, episode_group, group_seasons)
@classmethod
def _recognize_steps(cls, plan: _RecognizePlan) -> Generator[_RecognizeStep, Any, Optional[MediaInfo]]:
"""
生成双 ABI 共用的 TMDB 识别状态机,仅把实际 I/O 留给入口外壳。
"""
cache_info = yield _RecognizeStep(
action=_RecognizeAction.LOAD_CACHE,
kwargs={"plan": plan},
)
group_seasons: _TmdbDataList = []
if plan.episode_group:
group_seasons = yield _RecognizeStep(
action=_RecognizeAction.LOAD_GROUP,
kwargs={"group_id": plan.episode_group},
)
cache_hit = bool(cache_info and plan.use_cache)
info: Optional[_TmdbData]
if not cache_hit:
lookup = _RecognizeLookup(info=None)
if plan.tmdbid:
lookup = yield _RecognizeStep(
action=_RecognizeAction.LOOKUP_IDENTITY,
kwargs={
"tmdbid": plan.tmdbid,
"mtype": plan.mtype,
"meta": plan.meta,
},
)
info = lookup.info
if not info and plan.meta and not plan.tmdbid:
for name in cls._prepare_search_names(plan.meta):
info = yield _RecognizeStep(
action=_RecognizeAction.SEARCH_NAME,
kwargs={
"name": name,
"meta": plan.meta,
"group_seasons": group_seasons,
},
)
if info:
break
if info and not info.get("genres"):
info = yield _RecognizeStep(
action=_RecognizeAction.LOAD_DETAILS,
kwargs={
"mtype": info.get("media_type"),
"tmdbid": info.get("id"),
},
)
elif not info:
yield _RecognizeStep(
action=_RecognizeAction.LOG_FAILURE,
kwargs={
"plan": plan,
"connection_error": lookup.connection_error,
},
)
return None
yield _RecognizeStep(
action=_RecognizeAction.SAVE_CACHE,
kwargs={"plan": plan, "info": info},
)
else:
yield _RecognizeStep(
action=_RecognizeAction.LOG_CACHE,
kwargs={"plan": plan, "cache_info": cache_info},
)
if cache_info.get("title"):
info = yield _RecognizeStep(
action=_RecognizeAction.LOAD_DETAILS,
kwargs={
"mtype": cache_info.get("type"),
"tmdbid": cache_info.get("id"),
},
)
else:
info = None
if info:
mediainfo = yield _RecognizeStep(
action=_RecognizeAction.BUILD_RESULT,
kwargs={
"info": info,
"meta": plan.meta,
"tmdbid": plan.tmdbid,
"episode_group": plan.episode_group,
"group_seasons": group_seasons,
},
)
mediainfo.recognize_cache_hit = cache_hit
return cast(MediaInfo, mediainfo)
yield _RecognizeStep(
action=_RecognizeAction.LOG_MISS,
kwargs={"target": plan.meta.name if plan.meta else plan.tmdbid},
)
return None
def _run_recognize_steps(self, plan: _RecognizePlan) -> Optional[MediaInfo]:
"""
用同步 TMDB 客户端驱动共享识别状态机。
"""
steps = self._recognize_steps(plan)
result: Any = None
while True:
try:
step = steps.send(result)
except StopIteration as completed:
return cast(Optional[MediaInfo], completed.value)
if step.action == _RecognizeAction.LOAD_CACHE:
result = self._load_recognize_cache(**step.kwargs)
elif step.action == _RecognizeAction.LOAD_GROUP:
result = self.tmdb.get_tv_group_seasons(**step.kwargs)
elif step.action == _RecognizeAction.LOOKUP_IDENTITY:
try:
info = self._get_info_by_tmdbid(**step.kwargs)
result = _RecognizeLookup(info=info)
except TMDbConnectionError as err:
logger.error(f"tmdb_id:{plan.tmdbid} {err}")
result = _RecognizeLookup(info=None, connection_error=True)
elif step.action == _RecognizeAction.SEARCH_NAME:
result = self._search_by_name(**step.kwargs)
elif step.action == _RecognizeAction.LOAD_DETAILS:
result = self.tmdb.get_info(**step.kwargs)
elif step.action == _RecognizeAction.SAVE_CACHE:
self._save_recognize_cache(**step.kwargs)
result = None
elif step.action == _RecognizeAction.BUILD_RESULT:
result = self._build_media_info_result(**step.kwargs)
else:
self._run_common_recognize_step(step)
result = None
async def _async_run_recognize_steps(self, plan: _RecognizePlan) -> Optional[MediaInfo]:
"""
用异步 TMDB 客户端驱动共享识别状态机。
"""
steps = self._recognize_steps(plan)
result: Any = None
while True:
try:
step = steps.send(result)
except StopIteration as completed:
return cast(Optional[MediaInfo], completed.value)
if step.action == _RecognizeAction.LOAD_CACHE:
result = self._load_recognize_cache(**step.kwargs)
elif step.action == _RecognizeAction.LOAD_GROUP:
result = await self.tmdb.async_get_tv_group_seasons(**step.kwargs)
elif step.action == _RecognizeAction.LOOKUP_IDENTITY:
try:
info = await self._async_get_info_by_tmdbid(**step.kwargs)
result = _RecognizeLookup(info=info)
except TMDbConnectionError as err:
logger.error(f"tmdb_id:{plan.tmdbid} {err}")
result = _RecognizeLookup(info=None, connection_error=True)
elif step.action == _RecognizeAction.SEARCH_NAME:
result = await self._async_search_by_name(**step.kwargs)
elif step.action == _RecognizeAction.LOAD_DETAILS:
result = await self.tmdb.async_get_info(**step.kwargs)
elif step.action == _RecognizeAction.SAVE_CACHE:
self._save_recognize_cache(**step.kwargs)
result = None
elif step.action == _RecognizeAction.BUILD_RESULT:
result = await self._async_build_media_info_result(**step.kwargs)
else:
self._run_common_recognize_step(step)
result = None
def _run_common_recognize_step(self, step: _RecognizeStep) -> None:
"""
执行不依赖 TMDB 客户端 ABI 的缓存和结果日志动作。
"""
if step.action == _RecognizeAction.LOG_CACHE:
plan = cast(_RecognizePlan, step.kwargs["plan"])
cache_info = cast(_TmdbData, step.kwargs["cache_info"])
meta = plan.meta
target = meta.name if meta else plan.tmdbid
if cache_info.get("title"):
logger.info(f"{target} 使用TMDB识别缓存:{cache_info.get('title')}")
else:
logger.info(f"{target} 使用TMDB识别缓存:无法识别")
elif step.action == _RecognizeAction.LOG_FAILURE:
self._log_recognize_lookup_failure(**step.kwargs)
else:
logger.info(f"{step.kwargs['target']} 未匹配到TMDB媒体信息")
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: Optional[bool] = True,
**kwargs: object,
) -> Optional[MediaInfo]:
"""
识别媒体信息。
缓存准备阶段可能更新输入 meta 的媒体类型和身份。cache=False 仅跳过缓存读取,
新查询结果仍可能回填缓存;明确连接失败与短期未命中分别处理。
:param meta: 待识别或搜索的文件、种子元数据
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:param media_id: 来源原生标识;显式提供时来源必须为 TMDB 且值为数字
:param episode_group: 可选 TMDB 剧集组标识,用于映射季集编排
:param cache: 是否读取识别缓存,默认 True;关闭读取不禁止写入新结果
:param kwargs: 兼容模块统一入口的扩展参数,当前不使用
:return: 含来源身份及季集信息的媒体对象;不适用、未命中或无法消歧时返回 None
"""
plan = self._build_recognize_plan(meta, mtype, media_source, media_id, episode_group, cache)
if not plan:
return None
return self._run_recognize_steps(plan)
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: Optional[bool] = True,
**kwargs: object,
) -> Optional[MediaInfo]:
"""
识别媒体信息(异步版本)。
缓存准备阶段可能更新输入 meta 的媒体类型和身份。cache=False 仅跳过缓存读取,
新查询结果仍可能回填缓存;明确连接失败与短期未命中分别处理。
:param meta: 待识别或搜索的文件、种子元数据
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:param media_id: 来源原生标识;显式提供时来源必须为 TMDB 且值为数字
:param episode_group: 可选 TMDB 剧集组标识,用于映射季集编排
:param cache: 是否读取识别缓存,默认 True;关闭读取不禁止写入新结果
:param kwargs: 兼容模块统一入口的扩展参数,当前不使用
:return: 含来源身份及季集信息的媒体对象;不适用、未命中或无法消歧时返回 None
"""
plan = self._build_recognize_plan(meta, mtype, media_source, media_id, episode_group, cache)
if not plan:
return None
return await self._async_run_recognize_steps(plan)
def match_tmdbinfo(
self, name: str, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None
) -> Optional[_TmdbData]:
"""
搜索和匹配TMDB信息。
:param name: 待搜索或匹配的名称
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
"""
logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...")
info = self.tmdb.match(name=name, year=year, mtype=mtype or MediaType.MOVIE, season_year=year, season_number=season)
if info and not info.get("genres"):
media_type = info.get("media_type")
tmdb_id = info.get("id")
if isinstance(media_type, MediaType) and isinstance(tmdb_id, int):
info = self.tmdb.get_info(mtype=media_type, tmdbid=tmdb_id)
return info
async def async_match_tmdbinfo(
self, name: str, mtype: Optional[MediaType] = None, year: Optional[str] = None, season: Optional[int] = None
) -> Optional[_TmdbData]:
"""
异步搜索和匹配TMDB信息。
:param name: 待搜索或匹配的名称
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
"""
logger.info(f"开始使用 名称:{name} 年份:{year} 匹配TMDB信息 ...")
info = await self.tmdb.async_match(name=name, year=year, mtype=mtype or MediaType.MOVIE, season_year=year, season_number=season)
if info and not info.get("genres"):
media_type = info.get("media_type")
tmdb_id = info.get("id")
if isinstance(media_type, MediaType) and isinstance(tmdb_id, int):
info = await self.tmdb.async_get_info(mtype=media_type, tmdbid=tmdb_id)
return info
def tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[_TmdbData]:
"""
获取TMDB信息。
:param tmdbid: TMDB 数字媒体标识
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param season: TV 的显式季号(含 0)读取季详情;None 或电影的 0 读取媒体详情
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
if season is None or (season == 0 and mtype != MediaType.TV):
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
else:
return self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)
async def async_tmdb_info(self, tmdbid: int, mtype: MediaType, season: Optional[int] = None) -> Optional[_TmdbData]:
"""
异步获取TMDB信息。
:param tmdbid: TMDB 数字媒体标识
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param season: TV 的显式季号(含 0)读取季详情;None 或电影的 0 读取媒体详情
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
if season is None or (season == 0 and mtype != MediaType.TV):
return await self.tmdb.async_get_info(mtype=mtype, tmdbid=tmdbid)
else:
return await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season)
def update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MediaInfo,
) -> Optional[bool]:
"""
回填TMDB本地识别缓存,覆盖名称负缓存,避免共享识别后重复回查。
:param meta: 待识别或搜索的文件、种子元数据
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:return: 符合 TMDB 回填条件并调用缓存更新后返回 True;不适用时返回 None
"""
if not meta or not mediainfo:
return None
if mediainfo.media_source != "themoviedb" or not mediainfo.tmdb_info:
return None
self.cache.update(meta, mediainfo.tmdb_info)
return True
async def async_update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MediaInfo,
) -> Optional[bool]:
"""
异步回填TMDB本地识别缓存。
:param meta: 待识别或搜索的文件、种子元数据
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:return: 符合 TMDB 回填条件并调用缓存更新后返回 True;不适用时返回 None
"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
def tmdb_cache_items(self) -> _TmdbDataList:
"""
查询TMDB识别缓存条目列表。
:return: 按缓存键排序的识别缓存管理条目列表
"""
return self.cache.list_items()
def tmdb_cache_delete(self, cache_key: str) -> _TmdbData:
"""
按缓存键删除单条TMDB识别缓存。
:param cache_key: 缓存管理列表提供的完整识别缓存键
:return: 已删除的条目;不存在时返回空字典
"""
return self.cache.delete(cache_key)
def tmdb_cache_clear(self) -> None:
"""
清空全部TMDB识别缓存。
"""
self.cache.clear()
def search_medias(
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息。
:param meta: 待识别或搜索的文件、种子元数据
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
plan = self._build_media_search_plan(meta, media_source)
if not plan:
return None
if not plan.name:
return []
multi_results = self.tmdb.search_multiis(plan.name) if plan.search_multi else None
movie_results = self.tmdb.search_movies(plan.name, plan.year) if plan.search_movies else None
tv_results = self.tmdb.search_tvs(plan.name, plan.year) if plan.search_tvs else None
results = self._merge_media_search_results(plan, movie_results, tv_results, multi_results)
# 将搜索词中的季写入标题中
return self._build_search_medias_result(meta, results)
def search_persons(
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[_SchemaMediaPerson]]:
"""
搜索人物信息。
:param name: 待搜索或匹配的名称
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not name:
return []
results = self.tmdb.search_persons(name)
if results:
return [_SchemaMediaPerson(source="themoviedb", **person) for person in results]
return []
async def async_search_persons(
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[_SchemaMediaPerson]]:
"""
异步搜索人物信息。
:param name: 待搜索或匹配的名称
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
if not is_media_source_enabled(media_source, MediaSource.TMDB):
return None
if not name:
return []
results = await self.tmdb.async_search_persons(name)
if results:
return [_SchemaMediaPerson(source="themoviedb", **person) for person in results]
return []
def search_collections(
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索集合信息。
:param name: 待搜索或匹配的名称
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
if media_source and not is_media_source_selected(media_source, MediaSource.TMDB):
return None
if not name:
return []
results = self.tmdb.search_collections(name)
if results:
return [MediaInfo(tmdb_info=info) for info in results]
return []
async def async_search_collections(
self, name: str, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
异步搜索集合信息。
:param name: 待搜索或匹配的名称
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
if media_source and not is_media_source_selected(media_source, MediaSource.TMDB):
return None
if not name:
return []
results = await self.tmdb.async_search_collections(name)
if results:
return [MediaInfo(tmdb_info=info) for info in results]
return []
def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
"""
根据合集ID查询集合。
:param collection_id: TMDB 电影合集标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
results = self.tmdb.get_collection(collection_id)
if results:
return [MediaInfo(tmdb_info=info) for info in results]
return []
def metadata_nfo(
self, meta: MetaBase, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None
) -> Optional[str]:
"""
按刮削来源生成 TMDB NFO XML 内容。
:param meta: 待识别或搜索的文件、种子元数据
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:param episode: 目标集号,可选入口非零时表示单集
:return: UTF-8 XML 字节;刮削来源不是 TMDB 时返回 None
"""
if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "themoviedb":
return None
return self.scraper.get_metadata_nfo(meta=meta, mediainfo=mediainfo, season=season, episode=episode)
def metadata_img(
self, mediainfo: MediaInfo, season: Optional[int] = None, episode: Optional[int] = None
) -> Optional[_TmdbData]:
"""
按刮削来源生成 TMDB 图片文件名与地址映射。
主媒体图片缺失时,刮削器可能原地补充 mediainfo 的图片路径。
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:param episode: 目标集号,可选入口非零时表示单集
:return: 图片文件名到下载地址的字典;来源不是 TMDB 时返回 None
"""
if (mediainfo.scrape_source or get_runtime_setting("SCRAP_SOURCE")) != "themoviedb":
return None
return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode)
def tmdb_discover(
self,
mtype: MediaType,
sort_by: str,
with_genres: str,
with_original_language: str,
with_keywords: str,
with_watch_providers: str,
vote_average: float,
vote_count: int,
release_date: str,
page: Optional[int] = 1,
) -> Optional[List[MediaInfo]]:
"""
按类型、语言、评分和日期组合条件发现媒体。
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param sort_by: TMDB 排序字段及方向,如 popularity.desc
:param with_genres: 类型标识筛选字符串,分隔语义由 TMDB 定义
:param with_original_language: 原始语言代码筛选
:param with_keywords: 关键词标识筛选字符串
:param with_watch_providers: 流媒体服务商标识筛选字符串
:param vote_average: 最低平均评分,包含此边界
:param vote_count: 最低评分人数,包含此边界
:param release_date: 最早上映或整剧首播日期,格式 YYYY-MM-DD,包含边界
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
if mtype == MediaType.MOVIE:
infos = self.tmdb.discover_movies(
{
"sort_by": sort_by,
"with_genres": with_genres,
"with_original_language": with_original_language,
"with_keywords": with_keywords,
"with_watch_providers": with_watch_providers,
"vote_average.gte": vote_average,
"vote_count.gte": vote_count,
"release_date.gte": release_date,
"page": page,
}
)
elif mtype == MediaType.TV:
infos = self.tmdb.discover_tvs(
{
"sort_by": sort_by,
"with_genres": with_genres,
"with_original_language": with_original_language,
"with_keywords": with_keywords,
"with_watch_providers": with_watch_providers,
"vote_average.gte": vote_average,
"vote_count.gte": vote_count,
"first_air_date.gte": release_date,
"page": page,
}
)
else:
return []
if infos:
return [MediaInfo(tmdb_info=info) for info in infos]
return []
def tmdb_trending(self, page: Optional[int] = 1) -> List[MediaInfo]:
"""
查询周趋势榜并转换为标准媒体对象。
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
trending = self.tmdb.discover_trending(page=page)
if trending:
return [MediaInfo(tmdb_info=info) for info in trending]
return []
def tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
tmdb_info = self.tmdb.get_info(tmdbid=tmdbid, mtype=MediaType.TV)
if not tmdb_info:
return []
return [
_SchemaTmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None
]
def tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息。
:param group_id: TMDB 剧集组标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
group_seasons = self.tmdb.get_tv_group_seasons(group_id)
if not group_seasons:
return []
return [
_SchemaTmdbSeason(
season_number=sea.get("order"),
name=sea.get("name"),
episode_count=len(sea.get("episodes") or []),
air_date=episodes[0].get("air_date") if (episodes := sea.get("episodes")) else None,
)
for sea in group_seasons
]
def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[_SchemaTmdbEpisode]:
"""
根据TMDBID查询某季的所有集信息。
:param tmdbid: TMDB 数字媒体标识
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:param episode_group: 可选 TMDB 剧集组标识,用于映射季集编排
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
if episode_group:
season_info = self.tmdb.get_tv_group_detail(episode_group, season=season)
else:
season_info = self.tmdb.get_tv_season_detail(tmdbid=tmdbid, season=season)
if not season_info or not (episodes := season_info.get("episodes")):
return []
return [_SchemaTmdbEpisode(**episode) for episode in episodes]
def scheduler_job(self) -> None:
"""
由调度器周期调用,持久化当前本地识别缓存。
"""
self.cache.save()
@staticmethod
def _validate_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
验证 obtain_images 参数。
:param mediainfo: 媒体信息
:return: None 表示继续查询图片,MediaInfo 表示跳过并原样返回
"""
if mediainfo.media_source != "themoviedb" and get_runtime_setting("RECOGNIZE_SOURCE") != "themoviedb":
return mediainfo
if not mediainfo.tmdb_id:
return mediainfo
if mediainfo.logo_path and mediainfo.poster_path and mediainfo.backdrop_path:
return mediainfo
return None
@staticmethod
def _build_image_query(mediainfo: MediaInfo) -> _ImageQuery:
"""
生成图片查询计划,确保双入口选择同一媒体接口与语言参数。
"""
tmdbid = mediainfo.tmdb_id
assert tmdbid is not None
return _ImageQuery(
tmdbid=tmdbid,
original_language=mediainfo.original_language,
movie=mediainfo.type == MediaType.MOVIE,
)
@staticmethod
def _pick_best_tmdb_image(images: Optional[_TmdbDataList]) -> Optional[str]:
"""
从 TMDB 图片候选中选出评分最高的文件路径。
"""
if not images:
return None
images = sorted(
images,
key=lambda x: (
x.get("vote_average") or 0,
x.get("vote_count") or 0,
),
reverse=True,
)
return images[0].get("file_path")
@classmethod
def _process_tmdb_images(cls, mediainfo: MediaInfo, images: _TmdbData) -> MediaInfo:
"""
处理 TMDB 图片数据。
:param mediainfo: 媒体信息
:param images: 图片数据
:return: 更新后的媒体信息
"""
if isinstance(images, list):
images = images[0]
if not mediainfo.backdrop_path:
if image_path := cls._pick_best_tmdb_image(images.get("backdrops")):
mediainfo.backdrop_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path)
if not mediainfo.logo_path:
if image_path := cls._pick_best_tmdb_image(images.get("logos")):
mediainfo.logo_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path)
if not mediainfo.poster_path:
if image_path := cls._pick_best_tmdb_image(images.get("posters")):
mediainfo.poster_path = get_runtime_setting("TMDB_IMAGE_URL")(image_path)
return mediainfo
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
补充抓取媒体信息图片。
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:return: 原媒体对象,缺失图片字段可能已补齐;不适用时保持原值
"""
result = self._validate_obtain_images_params(mediainfo)
if result is not None:
return result
query = self._build_image_query(mediainfo)
if query.movie:
images = self.tmdb.get_movie_images(
query.tmdbid,
original_language=query.original_language,
)
else:
images = self.tmdb.get_tv_images(
query.tmdbid,
original_language=query.original_language,
)
if not images:
return mediainfo
return self._process_tmdb_images(mediainfo, images)
async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
补充抓取媒体信息图片(异步版本)。
:param mediainfo: 已识别媒体信息,可能包含自定义刮削来源和剧集组
:return: 原媒体对象,缺失图片字段可能已补齐;不适用时保持原值
"""
result = self._validate_obtain_images_params(mediainfo)
if result is not None:
return result
query = self._build_image_query(mediainfo)
if query.movie:
images = await self.tmdb.async_get_movie_images(
query.tmdbid,
original_language=query.original_language,
)
else:
images = await self.tmdb.async_get_tv_images(
query.tmdbid,
original_language=query.original_language,
)
if not images:
return mediainfo
return self._process_tmdb_images(mediainfo, images)
def obtain_specific_image(
self,
mediaid: Union[str, int],
mtype: MediaType,
image_type: MediaImageType,
image_prefix: Optional[str] = "w500",
season: Optional[int] = None,
episode: Optional[int] = None,
) -> Optional[str]:
"""
获取指定媒体信息图片,返回图片地址。
:param mediaid: 数字形式的 TMDB 媒体标识
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param image_type: 需要的图片字段类型;单集查询固定使用截图
:param image_prefix: TMDB 图片尺寸前缀,空值回退为 w500
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:param episode: 目标集号,可选入口非零时表示单集
:return: 按指定尺寸拼接的图片地址;无效媒体标识或无图片时返回 None
"""
if not str(mediaid).isdigit():
return None
image_path = None
image_prefix = image_prefix or "w500"
if season is None and not episode:
tmdbinfo = self.tmdb.get_info(mtype=mtype, tmdbid=int(mediaid))
if tmdbinfo:
image_path = tmdbinfo.get(image_type.value)
elif season is not None and episode:
episodeinfo = self.tmdb.get_tv_episode_detail(tmdbid=int(mediaid), season=season, episode=episode)
if episodeinfo:
image_path = episodeinfo.get("still_path")
elif season is not None:
seasoninfo = self.tmdb.get_tv_season_detail(tmdbid=int(mediaid), season=season)
if seasoninfo:
image_path = seasoninfo.get(image_type.value)
if image_path:
image_url = get_runtime_setting("TMDB_IMAGE_URL")(image_path, image_prefix)
return image_url if isinstance(image_url, str) else None
return None
def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电影。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
similar = self.tmdb.get_movie_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
def tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电视剧。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
similar = self.tmdb.get_tv_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
def tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电影。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
recommend = self.tmdb.get_movie_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
def tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电视剧。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
recommend = self.tmdb.get_tv_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
def tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]:
"""
根据TMDBID查询电影演员列表。
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
:param tmdbid: TMDB 数字媒体标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
def tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]:
"""
根据TMDBID查询电视剧演员列表。
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
:param tmdbid: TMDB 数字媒体标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
def tmdb_person_detail(self, person_id: int) -> _SchemaMediaPerson:
"""
根据TMDBID查询人物详情。
:param person_id: TMDB 人物标识
:return: 标准人物对象;查询失败或无结果时返回空人物对象
"""
detail = self.tmdb.get_person_detail(person_id=person_id)
if detail:
return _SchemaMediaPerson(source="themoviedb", **detail)
return _SchemaMediaPerson()
def tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
"""
根据TMDBID查询人物参演作品。
合并电影与电视剧的 cast 作品,按上映或首播日期倒序后在本地分页。
:param person_id: TMDB 人物标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
infos = self.tmdb.get_person_credits(person_id=person_id, page=page)
if infos:
return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]
return []
async def async_search_medias(
self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None
) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息(异步版本)。
:param meta: 待识别或搜索的文件、种子元数据
:param media_source: 本次请求允许的数据源,未选中 TMDB 时不处理
:return: 匹配的标准对象列表;无结果为空列表,数据源不适用时返回 None
"""
plan = self._build_media_search_plan(meta, media_source)
if not plan:
return None
if not plan.name:
return []
multi_results = await self.tmdb.async_search_multiis(plan.name) if plan.search_multi else None
movie_results = await self.tmdb.async_search_movies(plan.name, plan.year) if plan.search_movies else None
tv_results = await self.tmdb.async_search_tvs(plan.name, plan.year) if plan.search_tvs else None
results = self._merge_media_search_results(plan, movie_results, tv_results, multi_results)
# 将搜索词中的季写入标题中
return self._build_search_medias_result(meta, results)
async def async_tmdb_discover(
self,
mtype: MediaType,
sort_by: str,
with_genres: str,
with_original_language: str,
with_keywords: str,
with_watch_providers: str,
vote_average: float,
vote_count: int,
release_date: str,
page: Optional[int] = 1,
raise_exception: bool = False,
) -> Optional[List[MediaInfo]]:
"""
按类型、语言、评分和日期组合条件发现媒体。
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
:param sort_by: TMDB 排序字段及方向,如 popularity.desc
:param with_genres: 类型标识筛选字符串,分隔语义由 TMDB 定义
:param with_original_language: 原始语言代码筛选
:param with_keywords: 关键词标识筛选字符串
:param with_watch_providers: 流媒体服务商标识筛选字符串
:param vote_average: 最低平均评分,包含此边界
:param vote_count: 最低评分人数,包含此边界
:param release_date: 最早上映或整剧首播日期,格式 YYYY-MM-DD,包含边界
:param page: 请求页码,从 1 开始
:param raise_exception: True 时重新抛出查询异常,False 时记录日志并返回空列表
:return: 当前请求的结果列表;无结果或查询失败时为空列表
:raises Exception: 开启异常传播且底层查询失败
"""
if mtype == MediaType.MOVIE:
infos = await self.tmdb.async_discover_movies(
{
"sort_by": sort_by,
"with_genres": with_genres,
"with_original_language": with_original_language,
"with_keywords": with_keywords,
"with_watch_providers": with_watch_providers,
"vote_average.gte": vote_average,
"vote_count.gte": vote_count,
"release_date.gte": release_date,
"page": page,
},
raise_exception=raise_exception,
)
elif mtype == MediaType.TV:
infos = await self.tmdb.async_discover_tvs(
{
"sort_by": sort_by,
"with_genres": with_genres,
"with_original_language": with_original_language,
"with_keywords": with_keywords,
"with_watch_providers": with_watch_providers,
"vote_average.gte": vote_average,
"vote_count.gte": vote_count,
"first_air_date.gte": release_date,
"page": page,
},
raise_exception=raise_exception,
)
else:
return []
if infos:
return [MediaInfo(tmdb_info=info) for info in infos]
return []
async def async_tmdb_trending(self, page: Optional[int] = 1, raise_exception: bool = False) -> List[MediaInfo]:
"""
查询周趋势榜并转换为标准媒体对象。
:param page: 请求页码,从 1 开始
:param raise_exception: True 时重新抛出查询异常,False 时记录日志并返回空列表
:return: 当前请求的结果列表;无结果或查询失败时为空列表
:raises Exception: 开启异常传播且底层查询失败
"""
trending = await self.tmdb.async_discover_trending(
page=page,
raise_exception=raise_exception,
)
if trending:
return [MediaInfo(tmdb_info=info) for info in trending]
return []
async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
"""
根据合集ID查询集合(异步版本)。
:param collection_id: TMDB 电影合集标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
results = await self.tmdb.async_get_collection(collection_id)
if results:
return [MediaInfo(tmdb_info=info) for info in results]
return []
async def async_tmdb_seasons(self, tmdbid: int) -> List[_SchemaTmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
tmdb_info = await self.tmdb.async_get_info(tmdbid=tmdbid, mtype=MediaType.TV)
if not tmdb_info:
return []
return [
_SchemaTmdbSeason(**sea) for sea in tmdb_info.get("seasons", []) if sea.get("season_number") is not None
]
async def async_tmdb_group_seasons(self, group_id: str) -> List[_SchemaTmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息(异步版本)。
:param group_id: TMDB 剧集组标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
group_seasons = await self.tmdb.async_get_tv_group_seasons(group_id)
if not group_seasons:
return []
return [
_SchemaTmdbSeason(
season_number=sea.get("order"),
name=sea.get("name"),
episode_count=len(sea.get("episodes") or []),
air_date=episodes[0].get("air_date") if (episodes := sea.get("episodes")) else None,
)
for sea in group_seasons
]
async def async_tmdb_episodes(
self, tmdbid: int, season: int, episode_group: Optional[str] = None
) -> List[_SchemaTmdbEpisode]:
"""
根据TMDBID查询某季的所有集信息(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
:param episode_group: 可选 TMDB 剧集组标识,用于映射季集编排
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
if episode_group:
season_info = await self.tmdb.async_get_tv_group_detail(episode_group, season=season)
else:
season_info = await self.tmdb.async_get_tv_season_detail(tmdbid=tmdbid, season=season)
if not season_info or not (episodes := season_info.get("episodes")):
return []
return [_SchemaTmdbEpisode(**episode) for episode in episodes]
async def async_tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电影(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
similar = await self.tmdb.async_get_movie_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
async def async_tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电视剧(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
similar = await self.tmdb.async_get_tv_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
async def async_tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电影(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
recommend = await self.tmdb.async_get_movie_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
async def async_tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电视剧(异步版本)。
:param tmdbid: TMDB 数字媒体标识
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
recommend = await self.tmdb.async_get_tv_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
async def async_tmdb_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]:
"""
根据TMDBID查询电影演员列表(异步版本)。
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
:param tmdbid: TMDB 数字媒体标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
credit_infos = await self.tmdb.async_get_movie_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
async def async_tmdb_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> List[_SchemaMediaPerson]:
"""
根据TMDBID查询电视剧演员列表(异步版本)。
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
:param tmdbid: TMDB 数字媒体标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
credit_infos = await self.tmdb.async_get_tv_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [_SchemaMediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
async def async_tmdb_person_detail(self, person_id: int) -> _SchemaMediaPerson:
"""
根据TMDBID查询人物详情(异步版本)。
:param person_id: TMDB 人物标识
:return: 标准人物对象;查询失败或无结果时返回空人物对象
"""
detail = await self.tmdb.async_get_person_detail(person_id=person_id)
if detail:
return _SchemaMediaPerson(source="themoviedb", **detail)
return _SchemaMediaPerson()
async def async_tmdb_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
"""
根据TMDBID查询人物参演作品(异步版本)。
合并电影与电视剧的 cast 作品,按上映或首播日期倒序后在本地分页。
:param person_id: TMDB 人物标识
:param page: 请求页码,从 1 开始
:return: 当前请求的结果列表;无结果或查询失败时为空列表
"""
infos = await self.tmdb.async_get_person_credits(person_id=person_id, page=page)
if infos:
return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]
return []
def clear_cache(self) -> None:
"""
清理发现结果缓存和同步 HTTP 响应缓存。
"""
logger.info("开始清除TMDB缓存 ...")
self.tmdb.clear_cache()
self.cache.clear()
logger.info("TMDB缓存清除完成")