mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
* fix(media): preserve recognition and rule mutation identities * test: cover typed classification facts with alias policies --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
2332 lines
91 KiB
Python
2332 lines
91 KiB
Python
"""
|
||
TMDB 名称匹配计划、详情消歧及同步异步查询服务。
|
||
"""
|
||
|
||
import re
|
||
import traceback
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from typing import Any, Callable, Generator, List, Optional, cast
|
||
|
||
from app.foundation import text as text_tools
|
||
from app.foundation.text import convert as zhconv_convert
|
||
from app.runtime.log import logger
|
||
from app.runtime.settings import get_runtime_setting
|
||
from app.schemas.types import MediaType
|
||
|
||
from .tmdbv3api import TV, Collection, Discover, Episode, Movie, Person, Search, Season, TMDb, Trending
|
||
from .tmdbv3api.exceptions import TMDbConnectionError, TMDbException
|
||
|
||
_MediaRecord = dict[str, Any]
|
||
_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||
|
||
|
||
class _MatchTarget(Enum):
|
||
"""
|
||
描述一次媒体匹配计划需要调用的真实 TMDB 查询。
|
||
"""
|
||
|
||
# 按名称与年份查询电影。
|
||
MOVIE_NAME = "movie_name"
|
||
# 按名称与年份查询电视剧。
|
||
TV_NAME = "tv_name"
|
||
# 按名称与目标季年份查询电视剧。
|
||
TV_SEASON = "tv_season"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _MatchStep:
|
||
"""
|
||
冻结同步与异步入口共同消费的单步匹配计划。
|
||
|
||
:param target: 本步骤使用电影名称、电视剧名称或季信息查询
|
||
:param year: 本轮查询年份,None 表示不限制年份
|
||
"""
|
||
|
||
target: _MatchTarget
|
||
year: Optional[str] = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _CandidatePlan:
|
||
"""
|
||
冻结标题直达结果和需要详情补全的候选顺序。
|
||
|
||
:param direct: 无需额外详情即可命中的候选,未找到时为 None
|
||
:param aliases: 需要补充详情后检查别名的候选,按尝试顺序保存
|
||
"""
|
||
|
||
direct: Optional[_MediaRecord]
|
||
aliases: tuple[_MediaRecord, ...]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _DetailRequest:
|
||
"""
|
||
描述候选决策继续推进前唯一允许执行的详情 I/O。
|
||
|
||
:param mtype: 需要查询详情的媒体类型
|
||
:param tmdbid: 候选条目的 TMDB 标识
|
||
"""
|
||
|
||
mtype: MediaType
|
||
tmdbid: int
|
||
|
||
|
||
class TmdbApi:
|
||
"""
|
||
统一 TMDB 名称匹配、媒体详情投影和辅助信息查询。
|
||
|
||
同步与异步入口共享识别决策,保持类型消歧、年份回退和缓存语义一致。
|
||
"""
|
||
|
||
def __init__(self, language: Optional[str] = None):
|
||
"""
|
||
按统一语言创建 TMDB 基础及分类接口客户端。
|
||
|
||
:param language: 查询语言;未指定时使用运行配置
|
||
"""
|
||
self.tmdb = TMDb(language=language)
|
||
self.search = Search(language=language)
|
||
self.movie = Movie(language=language)
|
||
self.tv = TV(language=language)
|
||
self.season_obj = Season(language=language)
|
||
self.episode_obj = Episode(language=language)
|
||
self.discover = Discover(language=language)
|
||
self.trending = Trending(language=language)
|
||
self.person = Person(language=language)
|
||
self.collection = Collection(language=language)
|
||
|
||
def search_multiis(self, title: str) -> List[_MediaRecord]:
|
||
"""
|
||
同时查询模糊匹配的电影、电视剧TMDB信息。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
multis = self.search.multi(term=title) or []
|
||
return self._project_multi_search_results(multis)
|
||
|
||
def search_movies(self, title: str, year: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的电影TMDB信息。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
movies = self.search.movies(**self._search_kwargs(title, year, MediaType.MOVIE)) or []
|
||
return self._project_search_results(title, movies, MediaType.MOVIE)
|
||
|
||
def search_tvs(self, title: str, year: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的电视剧TMDB信息。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
tvs = self.search.tv_shows(**self._search_kwargs(title, year, MediaType.TV)) or []
|
||
return self._project_search_results(title, tvs, MediaType.TV)
|
||
|
||
def search_persons(self, name: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的人物TMDB信息。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not name:
|
||
return []
|
||
return self.search.people(term=name) or []
|
||
|
||
def search_collections(self, name: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的合集TMDB信息。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not name:
|
||
return []
|
||
collections = self.search.collections(term=name) or []
|
||
return self._project_collections(collections)
|
||
|
||
def get_collection(self, collection_id: int) -> List[_MediaRecord]:
|
||
"""
|
||
根据合集ID查询合集详情。
|
||
|
||
:param collection_id: TMDB 电影合集标识
|
||
:return: 合集中的电影条目列表;无效标识或查询失败时为空列表,底层无响应时可能为 None
|
||
"""
|
||
if not collection_id:
|
||
return []
|
||
try:
|
||
return self.collection.details(collection_id=collection_id)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
return []
|
||
|
||
@staticmethod
|
||
def __compare_names(file_name: str, tmdb_names: Any) -> bool:
|
||
"""
|
||
比较文件名是否匹配,忽略大小写和特殊字符。
|
||
|
||
:param file_name: 识别的文件名或者种子名
|
||
:param tmdb_names: TMDB返回的译名
|
||
:return: 标题相符时返回 True,否则返回 False
|
||
"""
|
||
if not file_name or not tmdb_names:
|
||
return False
|
||
if not isinstance(tmdb_names, (list, tuple)):
|
||
tmdb_names = [tmdb_names]
|
||
file_name = text_tools.remove_punctuation(file_name).upper()
|
||
for tmdb_name in tmdb_names:
|
||
tmdb_name = text_tools.remove_punctuation(tmdb_name).strip().upper()
|
||
if file_name == tmdb_name:
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _search_kwargs(title: str, year: Optional[str], mtype: MediaType) -> dict[str, str]:
|
||
"""
|
||
构造同步与异步搜索客户端共享的查询参数。
|
||
"""
|
||
kwargs = {"term": title}
|
||
if year:
|
||
kwargs["year" if mtype == MediaType.MOVIE else "release_year"] = year
|
||
return kwargs
|
||
|
||
@staticmethod
|
||
def _project_multi_search_results(multis: List[_MediaRecord]) -> List[_MediaRecord]:
|
||
"""
|
||
过滤多类型搜索结果并投影为宿主媒体类型。
|
||
"""
|
||
ret_infos = []
|
||
for multi in multis:
|
||
media_type = multi.get("media_type")
|
||
if media_type not in ("movie", "tv"):
|
||
continue
|
||
multi["media_type"] = MediaType.MOVIE if media_type == "movie" else MediaType.TV
|
||
ret_infos.append(multi)
|
||
return ret_infos
|
||
|
||
@staticmethod
|
||
def _project_search_results(title: str, items: List[_MediaRecord], mtype: MediaType) -> List[_MediaRecord]:
|
||
"""
|
||
为来源已完成搜索匹配的候选补齐类型,不按本地化展示标题二次排除。
|
||
"""
|
||
del title
|
||
ret_infos = []
|
||
for item in items:
|
||
item["media_type"] = mtype
|
||
ret_infos.append(item)
|
||
return ret_infos
|
||
|
||
@staticmethod
|
||
def _project_collections(collections: List[_MediaRecord]) -> List[_MediaRecord]:
|
||
"""
|
||
补齐合集查询结果的宿主类型与稳定身份。
|
||
"""
|
||
for collection in collections:
|
||
collection["media_type"] = MediaType.COLLECTION
|
||
collection["collection_id"] = collection.get("id")
|
||
return collections
|
||
|
||
@staticmethod
|
||
def _validate_match_params(name: str, search_obj: Any) -> bool:
|
||
"""
|
||
验证匹配方法的基本参数。
|
||
"""
|
||
if not search_obj:
|
||
return False
|
||
if not name:
|
||
return False
|
||
return True
|
||
|
||
@staticmethod
|
||
def _generate_year_range(year: Optional[str]) -> List[Optional[str]]:
|
||
"""
|
||
生成年份范围用于匹配。
|
||
"""
|
||
year_range = [year]
|
||
if year:
|
||
year_range.append(str(int(year) + 1))
|
||
year_range.append(str(int(year) - 1))
|
||
return year_range
|
||
|
||
@classmethod
|
||
def _build_match_plan(
|
||
cls,
|
||
mtype: MediaType,
|
||
year: Optional[str],
|
||
season_year: Optional[str],
|
||
season_number: Optional[int],
|
||
) -> tuple[_MatchStep, ...]:
|
||
"""
|
||
冻结名称、年份和季信息对应的有序查询计划。
|
||
"""
|
||
if mtype != MediaType.TV:
|
||
return tuple(
|
||
_MatchStep(_MatchTarget.MOVIE_NAME, search_year) for search_year in cls._generate_year_range(year)
|
||
)
|
||
steps = []
|
||
if season_year and season_number is not None:
|
||
steps.append(_MatchStep(_MatchTarget.TV_SEASON, season_year))
|
||
steps.extend(_MatchStep(_MatchTarget.TV_NAME, search_year) for search_year in cls._generate_year_range(year))
|
||
return tuple(steps)
|
||
|
||
@classmethod
|
||
def _match_resolution(
|
||
cls, mtype: MediaType, plan: tuple[_MatchStep, ...]
|
||
) -> Generator[_MatchStep, Optional[_MediaRecord], Optional[_MediaRecord]]:
|
||
"""
|
||
统一推进匹配计划并在首个有效结果处短路。
|
||
"""
|
||
info: Optional[_MediaRecord] = {}
|
||
for step in plan:
|
||
info = yield step
|
||
if info:
|
||
break
|
||
result_type = MediaType.TV if mtype == MediaType.TV else MediaType.MOVIE
|
||
return cls._set_media_type(info, result_type)
|
||
|
||
def _execute_match_step_sync(
|
||
self,
|
||
step: _MatchStep,
|
||
name: str,
|
||
mtype: MediaType,
|
||
season_year: Optional[str],
|
||
season_number: Optional[int],
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
通过同步客户端执行一条已冻结的匹配查询。
|
||
"""
|
||
if step.target == _MatchTarget.TV_SEASON:
|
||
assert step.year is not None and season_number is not None
|
||
self._log_match_debug(mtype, name, step.year, season_number, season_year)
|
||
return self.__search_tv_by_season(name, step.year, season_number, group_seasons)
|
||
self._log_match_debug(mtype, name, step.year)
|
||
if step.target == _MatchTarget.TV_NAME:
|
||
return self.__search_tv_by_name(name, step.year)
|
||
return self.__search_movie_by_name(name, step.year)
|
||
|
||
async def _execute_match_step_async(
|
||
self,
|
||
step: _MatchStep,
|
||
name: str,
|
||
mtype: MediaType,
|
||
season_year: Optional[str],
|
||
season_number: Optional[int],
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
通过异步客户端执行一条已冻结的匹配查询。
|
||
"""
|
||
if step.target == _MatchTarget.TV_SEASON:
|
||
assert step.year is not None and season_number is not None
|
||
self._log_match_debug(mtype, name, step.year, season_number, season_year)
|
||
return await self.__async_search_tv_by_season(name, step.year, season_number, group_seasons)
|
||
self._log_match_debug(mtype, name, step.year)
|
||
if step.target == _MatchTarget.TV_NAME:
|
||
return await self.__async_search_tv_by_name(name, step.year)
|
||
return await self.__async_search_movie_by_name(name, step.year)
|
||
|
||
def _run_match_sync(
|
||
self,
|
||
resolution: Generator[_MatchStep, Optional[_MediaRecord], Optional[_MediaRecord]],
|
||
name: str,
|
||
mtype: MediaType,
|
||
season_year: Optional[str],
|
||
season_number: Optional[int],
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
同步执行共享匹配状态机请求的真实 I/O。
|
||
"""
|
||
try:
|
||
step = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
while True:
|
||
info = self._execute_match_step_sync(step, name, mtype, season_year, season_number, group_seasons)
|
||
try:
|
||
step = resolution.send(info)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
|
||
async def _run_match_async(
|
||
self,
|
||
resolution: Generator[_MatchStep, Optional[_MediaRecord], Optional[_MediaRecord]],
|
||
name: str,
|
||
mtype: MediaType,
|
||
season_year: Optional[str],
|
||
season_number: Optional[int],
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
异步执行共享匹配状态机请求的真实 I/O。
|
||
"""
|
||
try:
|
||
step = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
while True:
|
||
info = await self._execute_match_step_async(
|
||
step, name, mtype, season_year, season_number, group_seasons
|
||
)
|
||
try:
|
||
step = resolution.send(info)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
|
||
@staticmethod
|
||
def _log_match_debug(
|
||
mtype: MediaType,
|
||
name: str,
|
||
year: Optional[str] = None,
|
||
season_number: Optional[int] = None,
|
||
season_year: Optional[str] = None,
|
||
) -> None:
|
||
"""
|
||
记录匹配调试日志。
|
||
"""
|
||
if season_number is not None and season_year:
|
||
logger.debug(f"正在识别{mtype.value}:{name}, 季集={season_number}, 季集年份={season_year} ...")
|
||
else:
|
||
logger.debug(f"正在识别{mtype.value}:{name}, 年份={year} ...")
|
||
|
||
@staticmethod
|
||
def _set_media_type(info: Optional[_MediaRecord], mtype: MediaType) -> Optional[_MediaRecord]:
|
||
"""
|
||
设置媒体类型。
|
||
"""
|
||
if info:
|
||
info["media_type"] = mtype
|
||
return info
|
||
|
||
@staticmethod
|
||
def _sort_multi_results(multis: List[_MediaRecord]) -> List[_MediaRecord]:
|
||
"""
|
||
按年份降序排列搜索结果,电影在前面。
|
||
"""
|
||
return sorted(
|
||
multis,
|
||
key=lambda x: (
|
||
("1" if x.get("media_type") == "movie" else "0")
|
||
+ (x.get("release_date") or x.get("first_air_date") or "0000-00-00")
|
||
),
|
||
reverse=True,
|
||
)
|
||
|
||
@staticmethod
|
||
def _convert_media_type(ret_info: _MediaRecord) -> _MediaRecord:
|
||
"""
|
||
转换媒体类型为MediaType枚举。
|
||
"""
|
||
if ret_info and not isinstance(ret_info.get("media_type"), MediaType):
|
||
ret_info["media_type"] = MediaType.MOVIE if ret_info.get("media_type") == "movie" else MediaType.TV
|
||
return ret_info
|
||
|
||
@staticmethod
|
||
def _candidate_media_type(candidate: _MediaRecord) -> Optional[MediaType]:
|
||
"""
|
||
把 TMDB 多类型结果值收窄为可查询的宿主媒体类型。
|
||
"""
|
||
media_type = candidate.get("media_type")
|
||
if media_type in (MediaType.MOVIE, "movie"):
|
||
return MediaType.MOVIE
|
||
if media_type in (MediaType.TV, "tv"):
|
||
return MediaType.TV
|
||
return None
|
||
|
||
@staticmethod
|
||
def _title_fields(mtype: MediaType) -> tuple[str, str]:
|
||
"""
|
||
返回媒体类型对应的标题与原标题字段。
|
||
"""
|
||
if mtype == MediaType.MOVIE:
|
||
return "title", "original_title"
|
||
return "name", "original_name"
|
||
|
||
@classmethod
|
||
def _title_matches(cls, name: str, candidate: _MediaRecord, mtype: MediaType) -> bool:
|
||
"""
|
||
使用同一规则比较候选标题和原标题。
|
||
"""
|
||
return any(cls.__compare_names(name, candidate.get(field)) for field in cls._title_fields(mtype))
|
||
|
||
@classmethod
|
||
def _build_name_candidate_plan(
|
||
cls,
|
||
name: str,
|
||
mtype: MediaType,
|
||
items: List[_MediaRecord],
|
||
year: Optional[str],
|
||
) -> _CandidatePlan:
|
||
"""
|
||
排序、过滤年份并冻结标题优先的候选匹配计划。
|
||
"""
|
||
date_field = "release_date" if mtype == MediaType.MOVIE else "first_air_date"
|
||
candidates = sorted(
|
||
items,
|
||
key=lambda item: item.get(date_field) or "0000-00-00",
|
||
reverse=True,
|
||
)
|
||
if year:
|
||
candidates = [item for item in candidates if (item.get(date_field) or "")[:4] == year]
|
||
direct = next(
|
||
(item for item in candidates if cls._title_matches(name, item, mtype)),
|
||
None,
|
||
)
|
||
return _CandidatePlan(direct=direct, aliases=tuple(candidates))
|
||
|
||
@classmethod
|
||
def _build_multi_candidate_plan(cls, name: str, multis: List[_MediaRecord]) -> _CandidatePlan:
|
||
"""
|
||
冻结多类型结果的排序、标题优先和别名回退顺序。
|
||
"""
|
||
candidates = cls._sort_multi_results(multis)
|
||
direct = next(
|
||
(
|
||
item
|
||
for item in candidates
|
||
if (mtype := cls._candidate_media_type(item)) and cls._title_matches(name, item, mtype)
|
||
),
|
||
None,
|
||
)
|
||
return _CandidatePlan(direct=direct, aliases=tuple(candidates))
|
||
|
||
@classmethod
|
||
def _alias_matches(cls, name: str, candidate: Optional[_MediaRecord]) -> bool:
|
||
"""
|
||
判断详情候选的别名或译名是否与输入完全一致。
|
||
"""
|
||
return bool(candidate and cls.__compare_names(name, candidate.get("names")))
|
||
|
||
@classmethod
|
||
def _candidate_resolution(
|
||
cls,
|
||
plan: _CandidatePlan,
|
||
media_type_for: Callable[[_MediaRecord], Optional[MediaType]],
|
||
accepts: Callable[[Optional[_MediaRecord]], bool],
|
||
) -> Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord]:
|
||
"""
|
||
用生成器统一候选短路、详情补全和首个命中决策。
|
||
"""
|
||
if plan.direct is not None:
|
||
return plan.direct
|
||
for candidate in plan.aliases:
|
||
resolved: Optional[_MediaRecord] = candidate
|
||
if not candidate.get("names"):
|
||
mtype = media_type_for(candidate)
|
||
tmdbid = candidate.get("id")
|
||
if not mtype or not isinstance(tmdbid, int):
|
||
continue
|
||
resolved = yield _DetailRequest(mtype=mtype, tmdbid=tmdbid)
|
||
if accepts(resolved):
|
||
return resolved or {}
|
||
return {}
|
||
|
||
@classmethod
|
||
def _name_resolution(
|
||
cls, name: str, mtype: MediaType, plan: _CandidatePlan
|
||
) -> Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord]:
|
||
"""
|
||
构造标题搜索候选的别名详情决策。
|
||
"""
|
||
return cls._candidate_resolution(
|
||
plan,
|
||
lambda _candidate: mtype,
|
||
lambda candidate: cls._alias_matches(name, candidate),
|
||
)
|
||
|
||
@classmethod
|
||
def _multi_resolution(
|
||
cls, name: str, plan: _CandidatePlan
|
||
) -> Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord]:
|
||
"""
|
||
构造多类型候选的类型化详情决策。
|
||
"""
|
||
return cls._candidate_resolution(
|
||
plan,
|
||
cls._candidate_media_type,
|
||
lambda candidate: cls._alias_matches(name, candidate),
|
||
)
|
||
|
||
def _resolve_details_sync(
|
||
self,
|
||
resolution: Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord],
|
||
) -> _MediaRecord:
|
||
"""
|
||
通过同步客户端执行生成器明确请求的详情 I/O。
|
||
"""
|
||
try:
|
||
request = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(_MediaRecord, result.value)
|
||
while True:
|
||
detail = self.get_info(mtype=request.mtype, tmdbid=request.tmdbid)
|
||
try:
|
||
request = resolution.send(detail)
|
||
except StopIteration as result:
|
||
return cast(_MediaRecord, result.value)
|
||
|
||
async def _resolve_details_async(
|
||
self,
|
||
resolution: Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord],
|
||
) -> _MediaRecord:
|
||
"""
|
||
通过异步客户端执行生成器明确请求的详情 I/O。
|
||
"""
|
||
try:
|
||
request = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(_MediaRecord, result.value)
|
||
while True:
|
||
detail = await self.async_get_info(mtype=request.mtype, tmdbid=request.tmdbid)
|
||
try:
|
||
request = resolution.send(detail)
|
||
except StopIteration as result:
|
||
return cast(_MediaRecord, result.value)
|
||
|
||
@staticmethod
|
||
def __get_names(tmdb_info: _MediaRecord) -> List[str]:
|
||
"""
|
||
搜索tmdb中所有的标题和译名,用于名称匹配。
|
||
|
||
:param tmdb_info: TMDB信息
|
||
:return: 所有译名的清单
|
||
"""
|
||
if not tmdb_info:
|
||
return []
|
||
ret_names = []
|
||
if tmdb_info.get("media_type") == MediaType.MOVIE:
|
||
alternative_titles = tmdb_info.get("alternative_titles", {}).get("titles", [])
|
||
for alternative_title in alternative_titles:
|
||
title = alternative_title.get("title")
|
||
if title and title not in ret_names:
|
||
ret_names.append(title)
|
||
translations = tmdb_info.get("translations", {}).get("translations", [])
|
||
for translation in translations:
|
||
title = translation.get("data", {}).get("title")
|
||
if title and title not in ret_names:
|
||
ret_names.append(title)
|
||
else:
|
||
alternative_titles = tmdb_info.get("alternative_titles", {}).get("results", [])
|
||
for alternative_title in alternative_titles:
|
||
name = alternative_title.get("title")
|
||
if name and name not in ret_names:
|
||
ret_names.append(name)
|
||
translations = tmdb_info.get("translations", {}).get("translations", [])
|
||
for translation in translations:
|
||
name = translation.get("data", {}).get("name")
|
||
if name and name not in ret_names:
|
||
ret_names.append(name)
|
||
return ret_names
|
||
|
||
def match(
|
||
self,
|
||
name: str,
|
||
mtype: MediaType,
|
||
year: Optional[str] = None,
|
||
season_year: Optional[str] = None,
|
||
season_number: Optional[int] = None,
|
||
group_seasons: Optional[List[_MediaRecord]] = None,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
搜索tmdb中的媒体信息,匹配返回一条尽可能正确的信息。
|
||
|
||
按共享匹配计划依次尝试年份、季信息和别名回退,首个有效候选即返回。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:param season_year: 目标季的首播年份,可为空
|
||
:param season_number: 目标季编号,0 表示特别篇,None 表示未指定
|
||
:param group_seasons: 已获取的剧集组季集信息,用于季年份匹配
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
"""
|
||
if not self._validate_match_params(name, self.search):
|
||
return None
|
||
plan = self._build_match_plan(mtype, year, season_year, season_number)
|
||
return self._run_match_sync(
|
||
self._match_resolution(mtype, plan),
|
||
name,
|
||
mtype,
|
||
season_year,
|
||
season_number,
|
||
group_seasons,
|
||
)
|
||
|
||
def __search_movie_by_name(self, name: str, year: Optional[str]) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称查询电影TMDB匹配。
|
||
|
||
:param name: 识别的文件名或种子名
|
||
:param year: 电影上映日期
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
if year:
|
||
movies = self.search.movies(term=name, year=year)
|
||
else:
|
||
movies = self.search.movies(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}")
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
if not movies:
|
||
logger.debug(f"{name} 未找到相关电影信息!")
|
||
return {}
|
||
plan = self._build_name_candidate_plan(name, MediaType.MOVIE, movies, year)
|
||
return self._resolve_details_sync(self._name_resolution(name, MediaType.MOVIE, plan))
|
||
|
||
def __search_tv_by_name(self, name: str, year: Optional[str]) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称查询电视剧TMDB匹配。
|
||
|
||
:param name: 识别的文件名或者种子名
|
||
:param year: 电视剧的首播年份
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
if year:
|
||
tvs = self.search.tv_shows(term=name, release_year=year)
|
||
else:
|
||
tvs = self.search.tv_shows(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}")
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
if not tvs:
|
||
logger.debug(f"{name} 未找到相关剧集信息!")
|
||
return {}
|
||
plan = self._build_name_candidate_plan(name, MediaType.TV, tvs, year)
|
||
return self._resolve_details_sync(self._name_resolution(name, MediaType.TV, plan))
|
||
|
||
@staticmethod
|
||
def _season_year_matches(
|
||
tv_info: _MediaRecord,
|
||
season_year: str,
|
||
season_number: int,
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> bool:
|
||
"""
|
||
判断详情中的指定季或集数组是否在目标年份首播。
|
||
"""
|
||
if group_seasons:
|
||
for group_season in group_seasons:
|
||
if group_season.get("order") != season_number:
|
||
continue
|
||
episodes = group_season.get("episodes") or []
|
||
if not episodes or not isinstance(episodes[0], dict):
|
||
continue
|
||
first_date = episodes[0].get("air_date")
|
||
if isinstance(first_date, str) and _DATE_PATTERN.match(first_date):
|
||
return first_date[:4] == str(season_year)
|
||
return False
|
||
for season_info in tv_info.get("seasons") or []:
|
||
if season_info.get("season_number") != season_number:
|
||
continue
|
||
air_date = season_info.get("air_date") or ""
|
||
if air_date[:4] == str(season_year):
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _season_candidate_matches(
|
||
cls,
|
||
name: str,
|
||
candidate: Optional[_MediaRecord],
|
||
season_year: str,
|
||
season_number: int,
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> bool:
|
||
"""
|
||
按标题、别名、首播年份和季年份判断剧集候选。
|
||
"""
|
||
if not candidate:
|
||
return False
|
||
if not (cls._title_matches(name, candidate, MediaType.TV) or cls._alias_matches(name, candidate)):
|
||
return False
|
||
first_air_year = (candidate.get("first_air_date") or "")[:4]
|
||
return first_air_year == str(season_year) or cls._season_year_matches(
|
||
candidate, season_year, season_number, group_seasons
|
||
)
|
||
|
||
@classmethod
|
||
def _build_season_candidate_plan(
|
||
cls,
|
||
name: str,
|
||
tvs: List[_MediaRecord],
|
||
season_year: str,
|
||
) -> _CandidatePlan:
|
||
"""
|
||
冻结电视剧首播年份直达和详情回退候选顺序。
|
||
"""
|
||
candidates = sorted(
|
||
tvs,
|
||
key=lambda item: item.get("first_air_date") or "0000-00-00",
|
||
reverse=True,
|
||
)
|
||
direct = next(
|
||
(
|
||
item
|
||
for item in candidates
|
||
if cls._title_matches(name, item, MediaType.TV)
|
||
and (item.get("first_air_date") or "")[:4] == str(season_year)
|
||
),
|
||
None,
|
||
)
|
||
return _CandidatePlan(direct=direct, aliases=tuple(candidates))
|
||
|
||
@classmethod
|
||
def _season_resolution(
|
||
cls,
|
||
name: str,
|
||
plan: _CandidatePlan,
|
||
season_year: str,
|
||
season_number: int,
|
||
group_seasons: Optional[List[_MediaRecord]],
|
||
) -> Generator[_DetailRequest, Optional[_MediaRecord], _MediaRecord]:
|
||
"""
|
||
构造剧集候选的标题、别名和季年份详情决策。
|
||
"""
|
||
return cls._candidate_resolution(
|
||
plan,
|
||
lambda _candidate: MediaType.TV,
|
||
lambda candidate: cls._season_candidate_matches(name, candidate, season_year, season_number, group_seasons),
|
||
)
|
||
|
||
def __search_tv_by_season(
|
||
self,
|
||
name: str,
|
||
season_year: str,
|
||
season_number: int,
|
||
group_seasons: Optional[List[_MediaRecord]] = None,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据电视剧的名称和季的年份及序号匹配TMDB。
|
||
|
||
:param name: 识别的文件名或者种子名
|
||
:param season_year: 季的年份
|
||
:param season_number: 季序号
|
||
:param group_seasons: 集数组信息
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
tvs = self.search.tv_shows(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
print(traceback.format_exc())
|
||
return None
|
||
|
||
if not tvs:
|
||
logger.debug(f"{name} 未找到季{season_number}相关信息!")
|
||
return {}
|
||
plan = self._build_season_candidate_plan(name, tvs, season_year)
|
||
return self._resolve_details_sync(
|
||
self._season_resolution(name, plan, season_year, season_number, group_seasons)
|
||
)
|
||
|
||
def match_multi(self, name: str) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称同时查询电影和电视剧,没有类型也没有年份时使用。
|
||
|
||
按共享匹配计划依次尝试年份、季信息和别名回退,首个有效候选即返回。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
"""
|
||
try:
|
||
multis = self.search.multi(term=name) or []
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
print(traceback.format_exc())
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
|
||
if not multis:
|
||
logger.debug(f"{name} 未找到相关媒体息!")
|
||
return {}
|
||
plan = self._build_multi_candidate_plan(name, multis)
|
||
resolved = self._resolve_details_sync(self._multi_resolution(name, plan))
|
||
return self._convert_media_type(resolved)
|
||
|
||
@staticmethod
|
||
def _info_lookup_types(mtype: Optional[MediaType]) -> tuple[MediaType, ...]:
|
||
"""
|
||
返回详情查询的稳定类型顺序,未知类型固定先剧集后电影。
|
||
"""
|
||
if mtype == MediaType.MOVIE:
|
||
return (MediaType.MOVIE,)
|
||
if mtype == MediaType.TV:
|
||
return (MediaType.TV,)
|
||
return MediaType.TV, MediaType.MOVIE
|
||
|
||
@staticmethod
|
||
def _select_info(
|
||
mtype: Optional[MediaType],
|
||
tmdbid: int,
|
||
details: dict[MediaType, Optional[_MediaRecord]],
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
从查询结果中统一判断类型、歧义和未命中状态。
|
||
"""
|
||
if mtype in (MediaType.MOVIE, MediaType.TV):
|
||
tmdb_info = details.get(mtype)
|
||
if tmdb_info:
|
||
tmdb_info["media_type"] = mtype
|
||
return tmdb_info
|
||
tv_info = details.get(MediaType.TV)
|
||
movie_info = details.get(MediaType.MOVIE)
|
||
if tv_info and movie_info:
|
||
logger.warning(f"无法判断tmdb_id:{tmdbid} 是电影还是电视剧")
|
||
return None
|
||
if tv_info:
|
||
tv_info["media_type"] = MediaType.TV
|
||
return tv_info
|
||
if movie_info:
|
||
movie_info["media_type"] = MediaType.MOVIE
|
||
return movie_info
|
||
logger.warning(f"tmdb_id:{tmdbid} 未查询到媒体信息")
|
||
return None
|
||
|
||
@classmethod
|
||
def _info_resolution(
|
||
cls, mtype: Optional[MediaType], tmdbid: int
|
||
) -> Generator[MediaType, Optional[_MediaRecord], Optional[_MediaRecord]]:
|
||
"""
|
||
统一推进详情类型查询顺序并完成歧义判断。
|
||
"""
|
||
details: dict[MediaType, Optional[_MediaRecord]] = {}
|
||
for detail_type in cls._info_lookup_types(mtype):
|
||
details[detail_type] = yield detail_type
|
||
return cls._select_info(mtype, tmdbid, details)
|
||
|
||
def _run_info_sync(
|
||
self,
|
||
resolution: Generator[MediaType, Optional[_MediaRecord], Optional[_MediaRecord]],
|
||
tmdbid: int,
|
||
raise_on_connection_error: bool,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
同步执行共享详情状态机请求的真实 I/O。
|
||
"""
|
||
try:
|
||
detail_type = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
while True:
|
||
if detail_type == MediaType.TV:
|
||
detail = self.__get_tv_detail(
|
||
tmdbid,
|
||
raise_on_connection_error=raise_on_connection_error,
|
||
)
|
||
else:
|
||
detail = self.__get_movie_detail(
|
||
tmdbid,
|
||
raise_on_connection_error=raise_on_connection_error,
|
||
)
|
||
try:
|
||
detail_type = resolution.send(detail)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
|
||
async def _run_info_async(
|
||
self,
|
||
resolution: Generator[MediaType, Optional[_MediaRecord], Optional[_MediaRecord]],
|
||
tmdbid: int,
|
||
raise_on_connection_error: bool,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
异步执行共享详情状态机请求的真实 I/O。
|
||
"""
|
||
try:
|
||
detail_type = next(resolution)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
while True:
|
||
if detail_type == MediaType.TV:
|
||
detail = await self.__async_get_tv_detail(
|
||
tmdbid,
|
||
raise_on_connection_error=raise_on_connection_error,
|
||
)
|
||
else:
|
||
detail = await self.__async_get_movie_detail(
|
||
tmdbid,
|
||
raise_on_connection_error=raise_on_connection_error,
|
||
)
|
||
try:
|
||
detail_type = resolution.send(detail)
|
||
except StopIteration as result:
|
||
return cast(Optional[_MediaRecord], result.value)
|
||
|
||
def _project_info(self, tmdb_info: Optional[_MediaRecord]) -> Optional[_MediaRecord]:
|
||
"""
|
||
把 TMDB 详情统一投影为宿主识别所需字段。
|
||
"""
|
||
if not tmdb_info:
|
||
return tmdb_info
|
||
tmdb_info["genre_ids"] = [genre.get("id") for genre in tmdb_info.get("genres") or []]
|
||
tmdb_info["names"] = self.__get_names(tmdb_info)
|
||
tmdb_info["content_rating"] = self.__get_content_rating(tmdb_info)
|
||
self.__update_tmdbinfo_extra_title(tmdb_info)
|
||
if self.tmdb.language in ("zh", "zh-CN"):
|
||
self.__update_tmdbinfo_cn_title(tmdb_info)
|
||
return tmdb_info
|
||
|
||
def get_info(
|
||
self, mtype: Optional[MediaType], tmdbid: int, raise_on_connection_error: bool = False
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
按 TMDB 标识查询详情,并补充规范类型、别名和分级。
|
||
|
||
类型未知时同时考虑电影和电视剧;详情身份仍存在歧义时不任意选取。
|
||
|
||
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
tmdb_info = self._run_info_sync(
|
||
self._info_resolution(mtype, tmdbid),
|
||
tmdbid,
|
||
raise_on_connection_error,
|
||
)
|
||
return self._project_info(tmdb_info)
|
||
|
||
@staticmethod
|
||
def __get_content_rating(tmdb_info: _MediaRecord) -> Optional[str]:
|
||
"""
|
||
获得tmdb中的内容评级。
|
||
|
||
:param tmdb_info: TMDB信息
|
||
:return: 内容评级
|
||
"""
|
||
if not tmdb_info:
|
||
return None
|
||
ratings = {}
|
||
if results := (tmdb_info.get("release_dates") or {}).get("results"):
|
||
"""
|
||
[
|
||
{
|
||
"iso_3166_1": "AR",
|
||
"release_dates": [
|
||
{
|
||
"certification": "+13",
|
||
"descriptors": [],
|
||
"iso_639_1": "",
|
||
"note": "",
|
||
"release_date": "2025-01-23T00:00:00.000Z",
|
||
"type": 3
|
||
}
|
||
]
|
||
}
|
||
]
|
||
"""
|
||
for item in results:
|
||
iso_3166_1 = item.get("iso_3166_1")
|
||
if not iso_3166_1:
|
||
continue
|
||
dates = item.get("release_dates")
|
||
if not dates:
|
||
continue
|
||
certification = dates[0].get("certification")
|
||
if not certification:
|
||
continue
|
||
ratings[iso_3166_1] = certification
|
||
elif results := (tmdb_info.get("content_ratings") or {}).get("results"):
|
||
"""
|
||
[
|
||
{
|
||
"descriptors": [],
|
||
"iso_3166_1": "US",
|
||
"rating": "TV-MA"
|
||
}
|
||
]
|
||
"""
|
||
for item in results:
|
||
iso_3166_1 = item.get("iso_3166_1")
|
||
if not iso_3166_1:
|
||
continue
|
||
rating = item.get("rating")
|
||
if not rating:
|
||
continue
|
||
ratings[iso_3166_1] = rating
|
||
if not ratings:
|
||
return None
|
||
return ratings.get("CN") or ratings.get("US")
|
||
|
||
@staticmethod
|
||
def __update_tmdbinfo_cn_title(tmdb_info: _MediaRecord) -> None:
|
||
"""
|
||
更新TMDB信息中的中文名称。
|
||
"""
|
||
|
||
def __get_tmdb_chinese_title(tmdbinfo: _MediaRecord) -> Optional[str]:
|
||
"""
|
||
从别名中获取中文标题。
|
||
"""
|
||
if not tmdbinfo:
|
||
return None
|
||
if tmdbinfo.get("media_type") == MediaType.MOVIE:
|
||
alternative_titles = tmdbinfo.get("alternative_titles", {}).get("titles", [])
|
||
else:
|
||
alternative_titles = tmdbinfo.get("alternative_titles", {}).get("results", [])
|
||
for alternative_title in alternative_titles:
|
||
iso_3166_1 = alternative_title.get("iso_3166_1")
|
||
if iso_3166_1 == "CN":
|
||
title = alternative_title.get("title")
|
||
if isinstance(title, str) and text_tools.contains_chinese(title) \
|
||
and zhconv_convert(title, "zh-hans") == title:
|
||
return title
|
||
title = tmdbinfo.get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE else tmdbinfo.get("name")
|
||
return title if isinstance(title, str) else None
|
||
|
||
org_title = tmdb_info.get("title") \
|
||
if tmdb_info.get("media_type") == MediaType.MOVIE \
|
||
else tmdb_info.get("name")
|
||
if not text_tools.contains_chinese(org_title if isinstance(org_title, str) else ""):
|
||
cn_title = __get_tmdb_chinese_title(tmdb_info)
|
||
if cn_title and cn_title != org_title:
|
||
if tmdb_info.get("media_type") == MediaType.MOVIE:
|
||
tmdb_info['title'] = cn_title
|
||
else:
|
||
tmdb_info['name'] = cn_title
|
||
else:
|
||
sg_title = tmdb_info.get("sg_title")
|
||
if sg_title and sg_title != org_title and text_tools.contains_chinese(sg_title):
|
||
if tmdb_info.get("media_type") == MediaType.MOVIE:
|
||
tmdb_info['title'] = sg_title
|
||
else:
|
||
tmdb_info['name'] = sg_title
|
||
|
||
@staticmethod
|
||
def __update_tmdbinfo_extra_title(tmdb_info: _MediaRecord) -> None:
|
||
"""
|
||
更新TMDB信息中的其它语种名称。
|
||
"""
|
||
|
||
def __get_tmdb_lang_title(tmdbinfo: _MediaRecord, lang: Optional[str] = "US") -> Optional[str]:
|
||
"""
|
||
从译名中获取其它语种标题。
|
||
"""
|
||
if not tmdbinfo:
|
||
return None
|
||
translations = tmdb_info.get("translations", {}).get("translations", [])
|
||
for translation in translations:
|
||
if translation.get("iso_3166_1") == lang:
|
||
title = translation.get("data", {}).get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE \
|
||
else translation.get("data", {}).get("name")
|
||
return title if isinstance(title, str) else None
|
||
return None
|
||
|
||
org_title = (
|
||
tmdb_info.get("original_title")
|
||
if tmdb_info.get("media_type") == MediaType.MOVIE
|
||
else tmdb_info.get("original_name")
|
||
)
|
||
if tmdb_info.get("original_language") == "en":
|
||
tmdb_info['en_title'] = org_title
|
||
else:
|
||
en_title = __get_tmdb_lang_title(tmdb_info, "US")
|
||
tmdb_info['en_title'] = en_title or org_title
|
||
|
||
tmdb_info['hk_title'] = __get_tmdb_lang_title(tmdb_info, "HK")
|
||
tmdb_info['tw_title'] = __get_tmdb_lang_title(tmdb_info, "TW")
|
||
|
||
# 查找新加坡名(用于替代中文名)
|
||
tmdb_info['sg_title'] = __get_tmdb_lang_title(tmdb_info, "SG") or org_title
|
||
|
||
def __get_movie_detail(self,
|
||
tmdbid: int,
|
||
append_to_response: Optional[str] = "images,"
|
||
"credits,"
|
||
"alternative_titles,"
|
||
"translations,"
|
||
"release_dates,"
|
||
"external_ids",
|
||
raise_on_connection_error: bool = False) -> Optional[_MediaRecord]:
|
||
"""
|
||
查询电影或电视剧详情,保留连接失败与空结果的区别。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param append_to_response: 逗号分隔的附加详情接口名称
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: TMDB 详情;客户端不可用或无数据时为空字典,查询异常默认返回 None
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
if not self.movie:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电影:%s ..." % tmdbid)
|
||
tmdbinfo = self.movie.details(tmdbid, append_to_response)
|
||
if tmdbinfo:
|
||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
|
||
return tmdbinfo or {}
|
||
except TMDbConnectionError as err:
|
||
logger.error(str(err))
|
||
if raise_on_connection_error:
|
||
raise
|
||
return None
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return None
|
||
|
||
def __get_tv_detail(self,
|
||
tmdbid: int,
|
||
append_to_response: Optional[str] = "images,"
|
||
"credits,"
|
||
"alternative_titles,"
|
||
"translations,"
|
||
"content_ratings,"
|
||
"external_ids,"
|
||
"episode_groups",
|
||
raise_on_connection_error: bool = False) -> Optional[_MediaRecord]:
|
||
"""
|
||
查询电影或电视剧详情,保留连接失败与空结果的区别。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param append_to_response: 逗号分隔的附加详情接口名称
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: TMDB 详情;客户端不可用或无数据时为空字典,查询异常默认返回 None
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
if not self.tv:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电视剧:%s ..." % tmdbid)
|
||
tmdbinfo = self.tv.details(tv_id=tmdbid, append_to_response=append_to_response)
|
||
if tmdbinfo:
|
||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
|
||
return tmdbinfo or {}
|
||
except TMDbConnectionError as err:
|
||
logger.error(str(err))
|
||
if raise_on_connection_error:
|
||
raise
|
||
return None
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return None
|
||
|
||
def get_tv_season_detail(self, tmdbid: int, season: int) -> _MediaRecord:
|
||
"""
|
||
获取电视剧季的详情。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.season_obj:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season))
|
||
tmdbinfo = self.season_obj.details(tv_id=tmdbid, season_num=season)
|
||
return tmdbinfo or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
def get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> _MediaRecord:
|
||
"""
|
||
获取电视剧集的详情。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:param episode: 目标集号,可选入口非零时表示单集
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.episode_obj:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode))
|
||
tmdbinfo = self.episode_obj.details(tv_id=tmdbid, season_num=season, episode_num=episode)
|
||
return tmdbinfo or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
def discover_movies(self, params: _MediaRecord) -> List[_MediaRecord]:
|
||
"""
|
||
发现电影。
|
||
|
||
:param params: 按 TMDB 发现接口命名的查询参数字典
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.discover:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在发现电影:{params}...")
|
||
tmdbinfo = self.discover.discover_movies(tuple(params.items()))
|
||
if tmdbinfo:
|
||
for info in tmdbinfo:
|
||
info['media_type'] = MediaType.MOVIE
|
||
return tmdbinfo or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def discover_tvs(self, params: _MediaRecord) -> List[_MediaRecord]:
|
||
"""
|
||
发现电视剧。
|
||
|
||
:param params: 按 TMDB 发现接口命名的查询参数字典
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.discover:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在发现电视剧:{params}...")
|
||
tmdbinfo = self.discover.discover_tv_shows(tuple(params.items()))
|
||
if tmdbinfo:
|
||
for info in tmdbinfo:
|
||
info['media_type'] = MediaType.TV
|
||
return tmdbinfo or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
@staticmethod
|
||
def _normalize_trending_infos(infos: Optional[List[_MediaRecord]]) -> List[_MediaRecord]:
|
||
"""
|
||
过滤流行趋势中的人物等非媒体项,并统一电影、电视剧的媒体类型。
|
||
"""
|
||
if not infos:
|
||
return []
|
||
|
||
ret_infos = []
|
||
for info in infos:
|
||
media_type = info.get("media_type")
|
||
if media_type == "movie":
|
||
info["media_type"] = MediaType.MOVIE
|
||
elif media_type == "tv":
|
||
info["media_type"] = MediaType.TV
|
||
elif media_type not in [MediaType.MOVIE, MediaType.TV]:
|
||
continue
|
||
ret_infos.append(info)
|
||
return ret_infos
|
||
|
||
def discover_trending(self, page: Optional[int] = 1) -> List[_MediaRecord]:
|
||
"""
|
||
查询周趋势榜中的电影和电视剧。
|
||
|
||
:param page: 请求页码,从 1 开始
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.trending:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取流行趋势:page={page} ...")
|
||
tmdbinfo = self.trending.all_week(page=page)
|
||
return self._normalize_trending_infos(tmdbinfo)
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
@staticmethod
|
||
def _build_include_image_language(original_language: Optional[str] = None) -> str:
|
||
"""
|
||
构造图片接口语言回退列表,避免当前语言没有图片时返回空列表。
|
||
"""
|
||
languages = []
|
||
for language in (
|
||
get_runtime_setting('TMDB_LOCALE'),
|
||
"en",
|
||
None,
|
||
original_language,
|
||
):
|
||
language = "null" if language is None else str(language).strip()
|
||
if language and language not in languages:
|
||
languages.append(language)
|
||
return ",".join(languages)
|
||
|
||
def get_movie_images(self, tmdbid: int, original_language: Optional[str] = None) -> _MediaRecord:
|
||
"""
|
||
获取电影的图片。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param original_language: 媒体原始语言,加入图片语言回退列表
|
||
:return: 图片分类字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.movie:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
||
return self.movie.images(
|
||
movie_id=tmdbid,
|
||
include_image_language=self._build_include_image_language(
|
||
original_language
|
||
),
|
||
) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
def get_tv_images(self, tmdbid: int, original_language: Optional[str] = None) -> _MediaRecord:
|
||
"""
|
||
获取电视剧的图片。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param original_language: 媒体原始语言,加入图片语言回退列表
|
||
:return: 图片分类字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.tv:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
||
return self.tv.images(
|
||
tv_id=tmdbid,
|
||
include_image_language=self._build_include_image_language(
|
||
original_language
|
||
),
|
||
) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
def get_movie_similar(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的相似电影。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取相似电影:{tmdbid}...")
|
||
return self.movie.similar(movie_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_tv_similar(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的相似电视剧。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取相似电视剧:{tmdbid}...")
|
||
return self.tv.similar(tv_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_movie_recommend(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的推荐电影。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取推荐电影:{tmdbid}...")
|
||
return self.movie.recommendations(movie_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_tv_recommend(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的推荐电视剧。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取推荐电视剧:{tmdbid}...")
|
||
return self.tv.recommendations(tv_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_movie_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的演员列表。
|
||
|
||
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取电影演职人员:{tmdbid}...")
|
||
info = self.movie.credits(movie_id=tmdbid) or {}
|
||
cast = info.get('cast') or []
|
||
if cast:
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的演员列表。
|
||
|
||
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取电视剧演职人员:{tmdbid}...")
|
||
info = self.tv.credits(tv_id=tmdbid) or {}
|
||
cast = info.get('cast') or []
|
||
if cast:
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_tv_group_seasons(self, group_id: str) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧剧集组季集列表。
|
||
|
||
保留剧集组顺序,并将每组 episode_number 重新编号为从 1 开始。
|
||
|
||
:param group_id: TMDB 剧集组标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取剧集组:{group_id}...")
|
||
group_seasons = self.tv.group_episodes(group_id) or []
|
||
return [
|
||
{
|
||
**group_season,
|
||
"episodes": [
|
||
{**ep, "episode_number": idx}
|
||
# 剧集组中每个季的episode_number从1开始
|
||
for idx, ep in enumerate(group_season.get("episodes", []), start=1)
|
||
]
|
||
}
|
||
for group_season in group_seasons
|
||
]
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def get_tv_group_detail(self, group_id: str, season: int) -> _MediaRecord:
|
||
"""
|
||
获取剧集组某个季的信息。
|
||
|
||
:param group_id: TMDB 剧集组标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
group_seasons = self.get_tv_group_seasons(group_id)
|
||
if not group_seasons:
|
||
return {}
|
||
for group_season in group_seasons:
|
||
if group_season.get('order') == season:
|
||
return group_season
|
||
return {}
|
||
|
||
def get_person_detail(self, person_id: int) -> _MediaRecord:
|
||
"""
|
||
查询人物详情。
|
||
|
||
:param person_id: TMDB 人物标识
|
||
:return: 人物详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.person:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取人物详情:{person_id}...")
|
||
return self.person.details(person_id=person_id) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
def get_person_credits(self, person_id: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取人物参演作品。
|
||
|
||
合并电影与电视剧的 cast 作品,按上映或首播日期倒序后在本地分页。
|
||
|
||
:param person_id: TMDB 人物标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.person:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取人物参演作品:{person_id}...")
|
||
movies = self.person.movie_credits(person_id=person_id) or {}
|
||
tvs = self.person.tv_credits(person_id=person_id) or {}
|
||
cast = (movies.get('cast') or []) + (tvs.get('cast') or [])
|
||
if cast:
|
||
cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',
|
||
reverse=True)
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def clear_cache(self) -> None:
|
||
"""
|
||
清理发现结果缓存和同步 HTTP 响应缓存。
|
||
"""
|
||
getattr(self.discover.discover_movies, "cache_clear", lambda: None)()
|
||
getattr(self.discover.discover_tv_shows, "cache_clear", lambda: None)()
|
||
self.tmdb.cache_clear()
|
||
|
||
async def __async_search_movie_by_name(self, name: str, year: Optional[str]) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称查询电影TMDB匹配(异步版本)。
|
||
|
||
:param name: 识别的文件名或种子名
|
||
:param year: 电影上映日期
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
if year:
|
||
movies = await self.search.async_movies(term=name, year=year)
|
||
else:
|
||
movies = await self.search.async_movies(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}")
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
if not movies:
|
||
logger.debug(f"{name} 未找到相关电影信息!")
|
||
return {}
|
||
plan = self._build_name_candidate_plan(name, MediaType.MOVIE, movies, year)
|
||
return await self._resolve_details_async(self._name_resolution(name, MediaType.MOVIE, plan))
|
||
|
||
async def __async_search_tv_by_name(self, name: str, year: Optional[str]) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称查询电视剧TMDB匹配(异步版本)。
|
||
|
||
:param name: 识别的文件名或者种子名
|
||
:param year: 电视剧的首播年份
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
if year:
|
||
tvs = await self.search.async_tv_shows(term=name, release_year=year)
|
||
else:
|
||
tvs = await self.search.async_tv_shows(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)} - {traceback.format_exc()}")
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
if not tvs:
|
||
logger.debug(f"{name} 未找到相关剧集信息!")
|
||
return {}
|
||
plan = self._build_name_candidate_plan(name, MediaType.TV, tvs, year)
|
||
return await self._resolve_details_async(self._name_resolution(name, MediaType.TV, plan))
|
||
|
||
async def __async_search_tv_by_season(
|
||
self,
|
||
name: str,
|
||
season_year: str,
|
||
season_number: int,
|
||
group_seasons: Optional[List[_MediaRecord]] = None,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据电视剧的名称和季的年份及序号匹配TMDB(异步版本)。
|
||
|
||
:param name: 识别的文件名或者种子名
|
||
:param season_year: 季的年份
|
||
:param season_number: 季序号
|
||
:param group_seasons: 集数组信息
|
||
:return: 匹配的媒体信息
|
||
"""
|
||
try:
|
||
tvs = await self.search.async_tv_shows(term=name)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
print(traceback.format_exc())
|
||
return None
|
||
|
||
if not tvs:
|
||
logger.debug(f"{name} 未找到季{season_number}相关信息!")
|
||
return {}
|
||
plan = self._build_season_candidate_plan(name, tvs, season_year)
|
||
return await self._resolve_details_async(
|
||
self._season_resolution(name, plan, season_year, season_number, group_seasons)
|
||
)
|
||
|
||
async def __async_get_movie_detail(self,
|
||
tmdbid: int,
|
||
append_to_response: Optional[str] = "images,"
|
||
"credits,"
|
||
"alternative_titles,"
|
||
"translations,"
|
||
"release_dates,"
|
||
"external_ids",
|
||
raise_on_connection_error: bool = False) -> Optional[_MediaRecord]:
|
||
"""
|
||
查询电影或电视剧详情,保留连接失败与空结果的区别。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param append_to_response: 逗号分隔的附加详情接口名称
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: TMDB 详情;客户端不可用或无数据时为空字典,查询异常默认返回 None
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
if not self.movie:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电影:%s ..." % tmdbid)
|
||
tmdbinfo = await self.movie.async_details(tmdbid, append_to_response)
|
||
if tmdbinfo:
|
||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
|
||
return tmdbinfo or {}
|
||
except TMDbConnectionError as err:
|
||
logger.error(str(err))
|
||
if raise_on_connection_error:
|
||
raise
|
||
return None
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return None
|
||
|
||
async def __async_get_tv_detail(self,
|
||
tmdbid: int,
|
||
append_to_response: Optional[str] = "images,"
|
||
"credits,"
|
||
"alternative_titles,"
|
||
"translations,"
|
||
"content_ratings,"
|
||
"external_ids,"
|
||
"episode_groups",
|
||
raise_on_connection_error: bool = False) -> Optional[_MediaRecord]:
|
||
"""
|
||
查询电影或电视剧详情,保留连接失败与空结果的区别。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param append_to_response: 逗号分隔的附加详情接口名称
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: TMDB 详情;客户端不可用或无数据时为空字典,查询异常默认返回 None
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
if not self.tv:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电视剧:%s ..." % tmdbid)
|
||
tmdbinfo = await self.tv.async_details(tv_id=tmdbid, append_to_response=append_to_response)
|
||
if tmdbinfo:
|
||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
|
||
return tmdbinfo or {}
|
||
except TMDbConnectionError as err:
|
||
logger.error(str(err))
|
||
if raise_on_connection_error:
|
||
raise
|
||
return None
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return None
|
||
|
||
async def async_search_multiis(self, title: str) -> List[_MediaRecord]:
|
||
"""
|
||
同时查询模糊匹配的电影、电视剧TMDB信息(异步版本)。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
multis = await self.search.async_multi(term=title) or []
|
||
return self._project_multi_search_results(multis)
|
||
|
||
async def async_search_movies(self, title: str, year: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的电影TMDB信息(异步版本)。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
movies = await self.search.async_movies(**self._search_kwargs(title, year, MediaType.MOVIE)) or []
|
||
return self._project_search_results(title, movies, MediaType.MOVIE)
|
||
|
||
async def async_search_tvs(self, title: str, year: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的电视剧TMDB信息(异步版本)。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param title: 待搜索标题,空值不发起查询
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not title:
|
||
return []
|
||
tvs = await self.search.async_tv_shows(**self._search_kwargs(title, year, MediaType.TV)) or []
|
||
return self._project_search_results(title, tvs, MediaType.TV)
|
||
|
||
async def async_discover_movies(
|
||
self, params: _MediaRecord, raise_exception: bool = False
|
||
) -> List[_MediaRecord]:
|
||
"""
|
||
发现电影(异步版本)。
|
||
|
||
:param params: 按 TMDB 发现接口命名的查询参数字典
|
||
:param raise_exception: True 时重新抛出查询异常,False 时记录日志并返回空列表
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
:raises Exception: 开启异常传播且底层查询失败
|
||
"""
|
||
if not params:
|
||
return []
|
||
try:
|
||
items = await self.discover.async_discover_movies(params_tuple=tuple(params.items())) or []
|
||
for item in items:
|
||
item['media_type'] = MediaType.MOVIE
|
||
return items
|
||
except Exception as e:
|
||
logger.error(f"获取电影发现失败:{str(e)}")
|
||
if raise_exception:
|
||
raise
|
||
return []
|
||
|
||
async def async_discover_tvs(
|
||
self, params: _MediaRecord, raise_exception: bool = False
|
||
) -> List[_MediaRecord]:
|
||
"""
|
||
发现电视剧(异步版本)。
|
||
|
||
:param params: 按 TMDB 发现接口命名的查询参数字典
|
||
:param raise_exception: True 时重新抛出查询异常,False 时记录日志并返回空列表
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
:raises Exception: 开启异常传播且底层查询失败
|
||
"""
|
||
if not params:
|
||
return []
|
||
try:
|
||
items = await self.discover.async_discover_tv_shows(params_tuple=tuple(params.items())) or []
|
||
for item in items:
|
||
item['media_type'] = MediaType.TV
|
||
return items
|
||
except Exception as e:
|
||
logger.error(f"获取电视剧发现失败:{str(e)}")
|
||
if raise_exception:
|
||
raise
|
||
return []
|
||
|
||
async def async_search_persons(self, name: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的人物TMDB信息(异步版本)。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not name:
|
||
return []
|
||
return await self.search.async_people(term=name) or []
|
||
|
||
async def async_search_collections(self, name: str) -> List[_MediaRecord]:
|
||
"""
|
||
查询模糊匹配的当前页的合集TMDB信息(异步版本)。
|
||
|
||
查询异常由调用方处理;不自动翻页。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 当前页的结果字典列表;关键词为空或无匹配时为空列表
|
||
"""
|
||
if not name:
|
||
return []
|
||
collections = await self.search.async_collections(term=name) or []
|
||
return self._project_collections(collections)
|
||
|
||
async def async_get_collection(self, collection_id: int) -> List[_MediaRecord]:
|
||
"""
|
||
根据合集ID查询合集详情(异步版本)。
|
||
|
||
:param collection_id: TMDB 电影合集标识
|
||
:return: 合集中的电影条目列表;无效标识或查询失败时为空列表,底层无响应时可能为 None
|
||
"""
|
||
if not collection_id:
|
||
return []
|
||
try:
|
||
return await self.collection.async_details(collection_id=collection_id)
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
return []
|
||
|
||
async def async_match(
|
||
self,
|
||
name: str,
|
||
mtype: MediaType,
|
||
year: Optional[str] = None,
|
||
season_year: Optional[str] = None,
|
||
season_number: Optional[int] = None,
|
||
group_seasons: Optional[List[_MediaRecord]] = None,
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
搜索tmdb中的媒体信息,匹配返回一条尽可能正确的信息(异步版本)。
|
||
|
||
按共享匹配计划依次尝试年份、季信息和别名回退,首个有效候选即返回。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
|
||
:param year: 作品上映或整剧首播年份,未指定时不按年份限制
|
||
:param season_year: 目标季的首播年份,可为空
|
||
:param season_number: 目标季编号,0 表示特别篇,None 表示未指定
|
||
:param group_seasons: 已获取的剧集组季集信息,用于季年份匹配
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
"""
|
||
if not self._validate_match_params(name, self.search):
|
||
return None
|
||
plan = self._build_match_plan(mtype, year, season_year, season_number)
|
||
return await self._run_match_async(
|
||
self._match_resolution(mtype, plan),
|
||
name,
|
||
mtype,
|
||
season_year,
|
||
season_number,
|
||
group_seasons,
|
||
)
|
||
|
||
async def async_match_multi(self, name: str) -> Optional[_MediaRecord]:
|
||
"""
|
||
根据名称同时查询电影和电视剧,没有类型也没有年份时使用(异步版本)。
|
||
|
||
按共享匹配计划依次尝试年份、季信息和别名回退,首个有效候选即返回。
|
||
|
||
:param name: 待搜索或匹配的名称
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
"""
|
||
try:
|
||
multis = await self.search.async_multi(term=name) or []
|
||
except TMDbException as err:
|
||
logger.error(f"连接TMDB出错:{str(err)}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接TMDB出错:{str(e)}")
|
||
print(traceback.format_exc())
|
||
return None
|
||
logger.debug(f"API返回:{str(self.search.total_results)}")
|
||
|
||
if not multis:
|
||
logger.debug(f"{name} 未找到相关媒体息!")
|
||
return {}
|
||
plan = self._build_multi_candidate_plan(name, multis)
|
||
resolved = await self._resolve_details_async(self._multi_resolution(name, plan))
|
||
return self._convert_media_type(resolved)
|
||
|
||
async def async_get_info(
|
||
self, mtype: Optional[MediaType], tmdbid: int, raise_on_connection_error: bool = False
|
||
) -> Optional[_MediaRecord]:
|
||
"""
|
||
按 TMDB 标识查询详情,并补充规范类型、别名和分级。
|
||
|
||
类型未知时同时考虑电影和电视剧;详情身份仍存在歧义时不任意选取。
|
||
|
||
:param mtype: 电影或电视剧类型;可选入口使用 None 表示未限定
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param raise_on_connection_error: True 时重新抛出连接失败;默认 False 将其作为无结果返回
|
||
:return: 补齐 media_type 等字段的 TMDB 字典;无匹配、失败或无法消歧时返回空值
|
||
:raises TMDbConnectionError: 开启连接错误传播且底层未取得响应
|
||
"""
|
||
tmdb_info = await self._run_info_async(
|
||
self._info_resolution(mtype, tmdbid),
|
||
tmdbid,
|
||
raise_on_connection_error,
|
||
)
|
||
return self._project_info(tmdb_info)
|
||
|
||
async def async_get_tv_season_detail(self, tmdbid: int, season: int) -> _MediaRecord:
|
||
"""
|
||
获取电视剧季的详情(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.season_obj:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season))
|
||
tmdbinfo = await self.season_obj.async_details(tv_id=tmdbid, season_num=season)
|
||
return tmdbinfo or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
async def async_get_tv_episode_detail(self, tmdbid: int, season: int, episode: int) -> _MediaRecord:
|
||
"""
|
||
获取电视剧集的详情(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:param episode: 目标集号,可选入口非零时表示单集
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.episode_obj:
|
||
return {}
|
||
try:
|
||
logger.debug("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode))
|
||
tmdbinfo = await self.episode_obj.async_details(tv_id=tmdbid, season_num=season, episode_num=episode)
|
||
return tmdbinfo or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
async def async_discover_trending(
|
||
self, page: Optional[int] = 1, raise_exception: bool = False
|
||
) -> List[_MediaRecord]:
|
||
"""
|
||
查询周趋势榜中的电影和电视剧。
|
||
|
||
:param page: 请求页码,从 1 开始
|
||
:param raise_exception: True 时重新抛出查询异常,False 时记录日志并返回空列表
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
:raises Exception: 开启异常传播且底层查询失败
|
||
"""
|
||
if not self.trending:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取流行趋势:page={page} ...")
|
||
tmdbinfo = await self.trending.async_all_week(page=page)
|
||
return self._normalize_trending_infos(tmdbinfo)
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
if raise_exception:
|
||
raise
|
||
return []
|
||
|
||
async def async_get_movie_images(
|
||
self, tmdbid: int, original_language: Optional[str] = None
|
||
) -> _MediaRecord:
|
||
"""
|
||
获取电影的图片(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param original_language: 媒体原始语言,加入图片语言回退列表
|
||
:return: 图片分类字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.movie:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
||
return await self.movie.async_images(
|
||
movie_id=tmdbid,
|
||
include_image_language=self._build_include_image_language(
|
||
original_language
|
||
),
|
||
) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
async def async_get_tv_images(
|
||
self, tmdbid: int, original_language: Optional[str] = None
|
||
) -> _MediaRecord:
|
||
"""
|
||
获取电视剧的图片(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param original_language: 媒体原始语言,加入图片语言回退列表
|
||
:return: 图片分类字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.tv:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
||
return await self.tv.async_images(
|
||
tv_id=tmdbid,
|
||
include_image_language=self._build_include_image_language(
|
||
original_language
|
||
),
|
||
) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
async def async_get_movie_similar(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的相似电影(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取相似电影:{tmdbid}...")
|
||
return await self.movie.async_similar(movie_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_tv_similar(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的相似电视剧(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取相似电视剧:{tmdbid}...")
|
||
return await self.tv.async_similar(tv_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_movie_recommend(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的推荐电影(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取推荐电影:{tmdbid}...")
|
||
return await self.movie.async_recommendations(movie_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_tv_recommend(self, tmdbid: int) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的推荐电视剧(异步版本)。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取推荐电视剧:{tmdbid}...")
|
||
return await self.tv.async_recommendations(tv_id=tmdbid) or []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_movie_credits(self, tmdbid: int,
|
||
page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取电影的演员列表(异步版本)。
|
||
|
||
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.movie:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取电影演职人员:{tmdbid}...")
|
||
info = await self.movie.async_credits(movie_id=tmdbid) or {}
|
||
cast = info.get('cast') or []
|
||
if cast:
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_tv_credits(self, tmdbid: int, page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧的演员列表(异步版本)。
|
||
|
||
仅取 cast 演员字段,不包含 crew 工作人员;拉取完整演员表后在本地分页。
|
||
|
||
:param tmdbid: TMDB 数字媒体标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取电视剧演职人员:{tmdbid}...")
|
||
info = await self.tv.async_credits(tv_id=tmdbid) or {}
|
||
cast = info.get('cast') or []
|
||
if cast:
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_tv_group_seasons(self, group_id: str) -> List[_MediaRecord]:
|
||
"""
|
||
获取电视剧剧集组季集列表(异步版本)。
|
||
|
||
保留剧集组顺序,并将每组 episode_number 重新编号为从 1 开始。
|
||
|
||
:param group_id: TMDB 剧集组标识
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.tv:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取剧集组:{group_id}...")
|
||
group_seasons = await self.tv.async_group_episodes(group_id) or []
|
||
return [
|
||
{
|
||
**group_season,
|
||
"episodes": [
|
||
{**ep, "episode_number": idx}
|
||
# 剧集组中每个季的episode_number从1开始
|
||
for idx, ep in enumerate(group_season.get("episodes", []), start=1)
|
||
]
|
||
}
|
||
for group_season in group_seasons
|
||
]
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
async def async_get_tv_group_detail(self, group_id: str, season: int) -> _MediaRecord:
|
||
"""
|
||
获取剧集组某个季的信息(异步版本)。
|
||
|
||
:param group_id: TMDB 剧集组标识
|
||
:param season: 目标季号,0 表示特别篇,None 在可选入口表示整剧
|
||
:return: 目标季、单集或分组详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
group_seasons = await self.async_get_tv_group_seasons(group_id)
|
||
if not group_seasons:
|
||
return {}
|
||
for group_season in group_seasons:
|
||
if group_season.get('order') == season:
|
||
return group_season
|
||
return {}
|
||
|
||
async def async_get_person_detail(self, person_id: int) -> _MediaRecord:
|
||
"""
|
||
查询人物详情。
|
||
|
||
:param person_id: TMDB 人物标识
|
||
:return: 人物详情字典;无结果或查询失败时为空字典
|
||
"""
|
||
if not self.person:
|
||
return {}
|
||
try:
|
||
logger.debug(f"正在获取人物详情:{person_id}...")
|
||
return await self.person.async_details(person_id=person_id) or {}
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return {}
|
||
|
||
async def async_get_person_credits(self, person_id: int,
|
||
page: Optional[int] = 1, count: Optional[int] = 24) -> List[_MediaRecord]:
|
||
"""
|
||
获取人物参演作品(异步版本)。
|
||
|
||
合并电影与电视剧的 cast 作品,按上映或首播日期倒序后在本地分页。
|
||
|
||
:param person_id: TMDB 人物标识
|
||
:param page: 请求页码,从 1 开始
|
||
:param count: 每页最多返回条数,演职员和作品在本地切片分页
|
||
:return: 当前请求的结果列表;无结果或查询失败时为空列表
|
||
"""
|
||
if not self.person:
|
||
return []
|
||
try:
|
||
logger.debug(f"正在获取人物参演作品:{person_id}...")
|
||
movies = await self.person.async_movie_credits(person_id=person_id) or {}
|
||
tvs = await self.person.async_tv_credits(person_id=person_id) or {}
|
||
cast = (movies.get('cast') or []) + (tvs.get('cast') or [])
|
||
if cast:
|
||
cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',
|
||
reverse=True)
|
||
return cast[((1 if page is None else page) - 1) * (24 if count is None else count): (1 if page is None else page) * (24 if count is None else count)]
|
||
return []
|
||
except Exception as e:
|
||
logger.error(str(e))
|
||
return []
|
||
|
||
def close(self) -> None:
|
||
"""
|
||
依次关闭各 TMDB 客户端的同步会话。
|
||
"""
|
||
for client in (
|
||
self.tmdb,
|
||
self.search,
|
||
self.movie,
|
||
self.tv,
|
||
self.season_obj,
|
||
self.episode_obj,
|
||
self.discover,
|
||
self.trending,
|
||
self.person,
|
||
self.collection,
|
||
):
|
||
if client:
|
||
client.close()
|