Files
MoviePilot/app/modules/thetvdb/__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

435 lines
16 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.
"""
TVDB 模块,负责会话管理、电视剧详情及补充媒体别名。
"""
from dataclasses import dataclass
from threading import Lock
from typing import Any, Optional, Tuple, Union
from app.domain.context import MediaInfo
from app.domain.media import is_media_source_enabled
from app.domain.meta.metabase import MetaBase
from app.modules import _ModuleBase
from app.modules.thetvdb import client
from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.types import (
MediaRecognizeType,
MediaSource,
MediaSourceSelection,
MediaType,
ModuleType,
)
@dataclass(frozen=True, slots=True)
class _TvdbAuxiliaryLookup:
"""
补充电视剧别名所需的单次 TVDB 查询。
:param method_name: 待调用的模块方法,tvdb_info 查询详情,search_tvdb 按标题搜索
:param argument: 方法的唯一位置参数,为原生数字标识或媒体标题
"""
method_name: str
argument: Union[int, str]
class TheTvDbModule(_ModuleBase):
"""
通过 TVDB v4 查询电视剧详情与补充别名,管理登录会话及令牌失效重试。
"""
__timeout: int = 15
tvdb: Optional[client.TVDB] = None
__auth_lock = Lock()
def init_module(self) -> None:
"""
重置 TVDB 会话,使下一次请求按最新密钥、PIN 和代理建立连接。
"""
with self.__auth_lock:
self.tvdb = None
def _initialize_tvdb_session(self, is_retry: bool = False) -> None:
"""
创建或刷新 TVDB 登录会话。
:param is_retry: 是否是由于token失效后的重试登录
"""
action = "刷新" if is_retry else "创建"
logger.info(f"开始{action}TVDB登录会话...")
try:
if not get_runtime_setting('TVDB_V4_API_KEY'):
raise ConnectionError("TVDB API Key 未配置,无法初始化会话。")
self.tvdb = client.TVDB(apikey=get_runtime_setting('TVDB_V4_API_KEY'),
pin=get_runtime_setting('TVDB_V4_API_PIN'),
proxy=get_runtime_setting('PROXY'),
timeout=self.__timeout)
if self.tvdb:
logger.info(f"TVDB登录会话{action}成功。")
else:
raise ValueError(f"TVDB登录会话{action}后实例仍为None。")
except Exception as e:
self.tvdb = None
raise ConnectionError(f"TVDB登录会话{action}失败: {str(e)}") from e
def _ensure_tvdb_session(self, is_retry: bool = False) -> None:
"""
确保TVDB会话存在。如果不存在或需要强制重新初始化,则进行初始化。
:param is_retry: 是否重新初始化(例如token失效时)
"""
# 第一次检查 (无锁),提高性能,避免不必要锁竞争
if not self.tvdb or is_retry:
with self.__auth_lock:
# 第二次检查 (有锁),防止多个线程都通过第一次检查后重复初始化
if not self.tvdb or is_retry:
self._initialize_tvdb_session(is_retry=is_retry)
def _handle_tvdb_call(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
"""
调用客户端方法,令牌失效时重新登录并最多重试一次。
登录失败、其他接口错误或重试仍失败时向调用方传播异常,由公开模块入口决定回退。
:param method_name: TVDB 客户端方法名
:param args: 原样传给客户端的位置参数
:param kwargs: 原样传给客户端的关键字参数
:return: 客户端原始返回值;首次调用明确报告资源不存在时为 None
"""
try:
self._ensure_tvdb_session()
actual_method = getattr(self.tvdb, method_name)
return actual_method(*args, **kwargs)
except ValueError as e:
if "Unauthorized" in str(e):
logger.warning("TVDB Token 可能已失效,正在尝试重新登录...")
try:
self._ensure_tvdb_session(is_retry=True)
actual_method = getattr(self.tvdb, method_name)
return actual_method(*args, **kwargs)
except ConnectionError as conn_err:
logger.error(f"TVDB Token失效后重新登录失败: {conn_err}")
raise
elif "NotFoundException" in str(e) or "ID not found" in str(e):
logger.warning(f"TVDB 资源未找到 (调用 {method_name}): {e}")
return None
else:
logger.error(f"TVDB 调用 ({method_name}) 时发生未处理的 ValueError: {str(e)}")
raise
except ConnectionError as e:
logger.error(f"TVDB 连接会话错误: {str(e)}")
raise
except AttributeError as e:
logger.error(f"TVDB 实例上没有方法 '{method_name}': {e}")
raise
except Exception as e:
logger.error(f"TVDB 调用时发生未知错误: {str(e)}", exc_info=True)
raise
@staticmethod
def get_name() -> str:
"""
返回 TVDB 模块的稳定名称。
"""
return "TheTvDb"
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型。
"""
return ModuleType.MediaRecognize
@staticmethod
def get_subtype() -> MediaRecognizeType:
"""
获取模块子类型。
"""
return MediaRecognizeType.TVDB
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效。
"""
return 4
def stop(self) -> None:
"""
停止当前 TVDB 会话并释放其运行时引用。
"""
with self.__auth_lock:
self.tvdb = None
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性。
"""
try:
self._handle_tvdb_call("get_series", 81189)
return True, ""
except Exception as e:
return False, str(e)
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""
TVDB 随宿主启动,无需额外的模块开关。
:return: 始终为 None,表示不检查配置项
"""
pass
def tvdb_info(self, tvdbid: int) -> Optional[dict[str, Any]]:
"""
查询电视剧扩展详情,必要时建立会话或刷新失效令牌。
:param tvdbid: TVDB 电视剧数字标识
:return: 原始电视剧详情;资源不存在或查询异常时为 None
"""
try:
logger.info(f"开始获取TVDB信息: {tvdbid} ...")
result = self._handle_tvdb_call("get_series_extended", tvdbid)
return result if isinstance(result, dict) else None
except Exception as err:
logger.error(f"获取TVDB信息失败: {str(err)}")
return None
def tvdb_slug(self, tvdbid: int) -> Optional[str]:
"""
查询电视剧的网页路径别名。
:param tvdbid: TVDB 电视剧数字标识
:return: 用于构造直达链接的 slug;未提供或请求失败时为 None
"""
try:
result = self._handle_tvdb_call("get_series", tvdbid)
if result and isinstance(result, dict):
slug = result.get("slug")
return slug if isinstance(slug, str) else None
return None
except Exception as err:
logger.error(f"获取TVDB slug 失败: {str(err)}")
return None
def search_tvdb(self, title: str) -> list[dict[str, Any]]:
"""
按标题搜索 TVDB,并仅保留电视剧类型的候选。
:param title: 待搜索的电视剧名称
:return: 原始候选列表;无结果、响应结构异常或请求失败时为空列表
"""
try:
logger.info(f"开始用标题搜索TVDB剧集: {title} ...")
res = self._handle_tvdb_call("search", title)
if res is None:
return []
if not isinstance(res, list):
logger.warning(f"TVDB 搜索 '{title}' 未返回列表:{type(res)}")
return []
return [
item
for item in res
if isinstance(item, dict) and item.get("type") == "series"
]
except Exception as err:
logger.error(f"用标题搜索TVDB剧集失败 ({title}): {str(err)}")
return []
@staticmethod
def _tvdb_aliases(info: dict[str, object]) -> list[str]:
"""
从 TVDB 搜索或详情响应中提取名称与翻译别名。
"""
values: list[object] = [info.get("name")]
for field in ("aliases", "translations", "nameTranslations"):
raw_value = info.get(field) or []
if isinstance(raw_value, dict):
raw_value = list(raw_value.values())
if not isinstance(raw_value, (list, tuple, set)):
raw_value = [raw_value]
for item in raw_value:
values.append(
item.get("name") or item.get("value")
if isinstance(item, dict)
else item
)
aliases: list[str] = []
seen: set[str] = set()
for value in values:
alias = str(value or "").strip()
normalized = " ".join(alias.casefold().split())
if alias and normalized not in seen:
aliases.append(alias)
seen.add(normalized)
return aliases
@staticmethod
def _tvdb_media_id(info: dict[str, object]) -> Optional[str]:
"""
从不同形态的 TVDB 响应中提取纯数字来源 ID。
"""
raw_id = info.get("tvdb_id") or info.get("id")
if raw_id is None:
return None
value = str(raw_id).rsplit("-", 1)[-1]
return value if value.isdigit() else None
def get_media_auxiliary_info(
self,
mediainfo: MediaInfo,
media_source: Optional[MediaSourceSelection] = None,
metainfo: Optional[MetaBase] = None,
) -> list[MediaInfo]:
"""
查询相同电视剧的 TVDB 别名,保持主媒体对象不变。
候选需符合可用年份并与已知名称存在交集;异步入口在线程池执行阻塞请求。
:param mediainfo: 已识别的电视剧信息,优先使用其 TVDB 原生标识,否则按标题搜索
:param media_source: 允许的补充来源选择,未包含 TVDB 时不查询
:param metainfo: 兼容统一补充接口的解析线索,当前不使用
:return: 最多包含一个匹配候选的列表;类型、来源不符或无匹配时为空列表
"""
lookup = self._build_auxiliary_lookup(
mediainfo=mediainfo,
media_source=media_source,
metainfo=metainfo,
)
if not lookup:
return []
candidates = self._load_auxiliary_candidates(lookup)
return self._resolve_auxiliary_candidates(mediainfo, candidates)
@staticmethod
def _build_auxiliary_lookup(
mediainfo: MediaInfo,
media_source: Optional[MediaSourceSelection],
metainfo: Optional[MetaBase],
) -> Optional[_TvdbAuxiliaryLookup]:
"""
校验 TVDB 附加信息请求并选择原生 ID 或标题查询。
"""
if (
not mediainfo
or mediainfo.type != MediaType.TV
or not is_media_source_enabled(media_source, MediaSource.TVDB)
):
return None
del metainfo
if (
mediainfo.media_source == MediaSource.TVDB
and str(mediainfo.media_id or "").isdigit()
):
media_id = mediainfo.media_id
if isinstance(media_id, str) and media_id.isdigit():
return _TvdbAuxiliaryLookup(
method_name="tvdb_info",
argument=int(media_id),
)
title = mediainfo.title
if not isinstance(title, str):
return None
return _TvdbAuxiliaryLookup(
method_name="search_tvdb",
argument=title,
)
def _load_auxiliary_candidates(
self, lookup: _TvdbAuxiliaryLookup
) -> list[dict[str, object]]:
"""
通过同步 TVDB I/O 获取候选详情。
"""
result = getattr(self, lookup.method_name)(lookup.argument)
if lookup.method_name == "tvdb_info":
return [result] if result else []
return result or []
async def _async_load_auxiliary_candidates(
self, lookup: _TvdbAuxiliaryLookup
) -> list[dict[str, object]]:
"""
仅在线程池中执行 TVDB 客户端的阻塞网络调用。
"""
result = await run_in_threadpool(
getattr(self, lookup.method_name),
lookup.argument,
)
if lookup.method_name == "tvdb_info":
return [result] if result else []
return result or []
def _resolve_auxiliary_candidates(
self, mediainfo: MediaInfo, candidates: list[dict[str, object]]
) -> list[MediaInfo]:
"""
按年份、名称和来源 ID 统一解析 TVDB 候选。
"""
target_names = {
" ".join(str(name).casefold().split())
for name in [mediainfo.title, *(mediainfo.names or [])]
if name
}
for info in candidates:
candidate_year = str(
info.get("year")
or info.get("firstAired")
or info.get("first_air_time")
or ""
)[:4]
if mediainfo.year and candidate_year and str(mediainfo.year) != candidate_year:
continue
aliases = self._tvdb_aliases(info)
normalized_aliases = {" ".join(alias.casefold().split()) for alias in aliases}
if not target_names.intersection(normalized_aliases):
continue
media_id = self._tvdb_media_id(info)
if not media_id or not aliases:
continue
return [MediaInfo(
media_source=MediaSource.TVDB,
media_id=media_id,
type=MediaType.TV,
title=aliases[0],
year=str(info.get("year")) if info.get("year") else mediainfo.year,
names=aliases,
)]
return []
async def async_get_media_auxiliary_info(
self,
mediainfo: MediaInfo,
media_source: Optional[MediaSourceSelection] = None,
metainfo: Optional[MetaBase] = None,
) -> list[MediaInfo]:
"""
异步查询相同电视剧的 TVDB 别名,保持主媒体对象不变。
候选需符合可用年份并与已知名称存在交集;异步入口在线程池执行阻塞请求。
:param mediainfo: 已识别的电视剧信息,优先使用其 TVDB 原生标识,否则按标题搜索
:param media_source: 允许的补充来源选择,未包含 TVDB 时不查询
:param metainfo: 兼容统一补充接口的解析线索,当前不使用
:return: 最多包含一个匹配候选的列表;类型、来源不符或无匹配时为空列表
"""
lookup = self._build_auxiliary_lookup(
mediainfo=mediainfo,
media_source=media_source,
metainfo=metainfo,
)
if not lookup:
return []
candidates = await self._async_load_auxiliary_candidates(lookup)
return self._resolve_auxiliary_candidates(mediainfo, candidates)
def clear_cache(self) -> None:
"""
清除缓存。
"""
logger.info(f"开始清除{self.get_name()}缓存 ...")
if tvdb := self.tvdb:
tvdb.clear_cache()
logger.info(f"{self.get_name()}缓存清除完成")