mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
304 lines
11 KiB
Python
304 lines
11 KiB
Python
from threading import Lock
|
|
from typing import Optional, Tuple, Union, cast
|
|
|
|
from app.domain.context import MediaInfo
|
|
from app.domain.media import is_media_source_enabled
|
|
from app.domain.meta.metabase import MetaBase
|
|
from app.runtime.execution import run_in_threadpool
|
|
from app.runtime.settings import get_runtime_setting
|
|
|
|
from app.modules import _ModuleBase
|
|
from app.modules.thetvdb import tvdb_v4_official
|
|
from app.runtime.log import logger
|
|
from app.schemas.types import (
|
|
MediaRecognizeType,
|
|
MediaSource,
|
|
MediaSourceSelection,
|
|
MediaType,
|
|
ModuleType,
|
|
)
|
|
|
|
|
|
class TheTvDbModule(_ModuleBase):
|
|
"""
|
|
TVDB媒体信息匹配
|
|
"""
|
|
__timeout: int = 15
|
|
tvdb: Optional[tvdb_v4_official.TVDB] = None
|
|
__auth_lock = Lock()
|
|
|
|
def init_module(self) -> None:
|
|
pass
|
|
|
|
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 = tvdb_v4_official.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, **kwargs):
|
|
"""
|
|
包裹 TVDB 调用,处理 token 失效情况并尝试重新初始化
|
|
:param method_name: 要在 self.tvdb 实例上调用的方法的名称 (字符串)
|
|
"""
|
|
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:
|
|
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):
|
|
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) -> Tuple[str, Union[str, bool]]:
|
|
pass
|
|
|
|
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
|
|
"""
|
|
获取TVDB信息
|
|
:param tvdbid: int
|
|
:return: TVDB信息
|
|
"""
|
|
try:
|
|
logger.info(f"开始获取TVDB信息: {tvdbid} ...")
|
|
return self._handle_tvdb_call("get_series_extended", tvdbid)
|
|
except Exception as err:
|
|
logger.error(f"获取TVDB信息失败: {str(err)}")
|
|
return None
|
|
|
|
def tvdb_slug(self, tvdbid: int) -> Optional[str]:
|
|
"""
|
|
获取TVDB剧集的 slug(别名),用于构建 TheTvDb 直达链接。
|
|
:param tvdbid: int
|
|
:return: slug 字符串,如 "speed-and-love"
|
|
"""
|
|
try:
|
|
result = self._handle_tvdb_call("get_series", tvdbid)
|
|
if result and isinstance(result, dict):
|
|
return result.get("slug")
|
|
return None
|
|
except Exception as err:
|
|
logger.error(f"获取TVDB slug 失败: {str(err)}")
|
|
return None
|
|
|
|
def search_tvdb(self, title: str) -> list:
|
|
"""
|
|
用标题搜索TVDB剧集
|
|
:param title: 标题
|
|
:return: TVDB信息
|
|
"""
|
|
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 补充电视剧别名,不向主媒体写入 TVDB 专用字段。"""
|
|
if (
|
|
not mediainfo
|
|
or mediainfo.type != MediaType.TV
|
|
or not is_media_source_enabled(media_source, MediaSource.TVDB)
|
|
):
|
|
return []
|
|
del metainfo
|
|
if (
|
|
mediainfo.media_source == MediaSource.TVDB
|
|
and str(mediainfo.media_id or "").isdigit()
|
|
):
|
|
info = self.tvdb_info(int(mediainfo.media_id))
|
|
candidates = [info] if info else []
|
|
else:
|
|
candidates = self.search_tvdb(mediainfo.title)
|
|
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 同步附加信息查询。"""
|
|
return cast(
|
|
list[MediaInfo],
|
|
await run_in_threadpool(
|
|
self.get_media_auxiliary_info,
|
|
mediainfo=mediainfo,
|
|
media_source=media_source,
|
|
metainfo=metainfo,
|
|
),
|
|
)
|
|
|
|
def clear_cache(self):
|
|
"""
|
|
清除缓存
|
|
"""
|
|
logger.info(f"开始清除{self.get_name()}缓存 ...")
|
|
if tvdb := self.tvdb:
|
|
tvdb.clear_cache()
|
|
logger.info(f"{self.get_name()}缓存清除完成")
|