mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 21:07:01 +08:00
refactor(tmdb): unify sync async decision plans
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
|
||||
import cn2an
|
||||
|
||||
from app.modules._base.media_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.schemas.context import MediaPerson as _SchemaMediaPerson
|
||||
from app.schemas.tmdb import TmdbEpisode as _SchemaTmdbEpisode
|
||||
from app.schemas.tmdb import TmdbSeason as _SchemaTmdbSeason
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
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_auxiliary import MediaAuxiliaryProviderMixin
|
||||
from app.modules.themoviedb.category import CategoryHelper
|
||||
from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
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 CategoryConfig
|
||||
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,
|
||||
@@ -33,6 +33,55 @@ from app.schemas.types import (
|
||||
)
|
||||
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
_TmdbData = Dict[str, Any]
|
||||
_TmdbDataList = List[_TmdbData]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RecognizePlan:
|
||||
"""保存同步与异步 TMDB 识别入口共用的请求决策。"""
|
||||
|
||||
meta: Optional[MetaBase]
|
||||
mtype: Optional[MediaType]
|
||||
tmdbid: Optional[int]
|
||||
episode_group: Optional[str]
|
||||
use_cache: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MatchStep:
|
||||
"""描述一次按名称匹配调用,I/O 外壳只负责选择同步或异步客户端。"""
|
||||
|
||||
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 查询组合。"""
|
||||
|
||||
name: str
|
||||
year: Any
|
||||
search_movies: bool
|
||||
search_tvs: bool
|
||||
search_multi: bool
|
||||
sort_combined: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ImageQuery:
|
||||
"""保存同步与异步图片客户端共用的查询参数。"""
|
||||
|
||||
tmdbid: int
|
||||
original_language: Optional[str]
|
||||
movie: bool
|
||||
|
||||
|
||||
class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
@@ -107,7 +156,7 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
|
||||
@staticmethod
|
||||
def _validate_recognize_params(
|
||||
meta: MetaBase,
|
||||
meta: Optional[MetaBase],
|
||||
tmdbid: Optional[int],
|
||||
media_source: Optional[MediaSource] = None,
|
||||
) -> bool:
|
||||
@@ -132,6 +181,69 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
|
||||
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, cast(_TmdbData, info))
|
||||
|
||||
@staticmethod
|
||||
def _prepare_search_names(meta: MetaBase) -> List[str]:
|
||||
"""
|
||||
@@ -195,6 +307,63 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
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[dict], bool]:
|
||||
"""
|
||||
查询指定类型的媒体详情,将"确认TMDB连接失败"与"确认查无此项"区分开。
|
||||
@@ -217,6 +386,31 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
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[dict]:
|
||||
"""
|
||||
@@ -231,18 +425,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
# 避免一路瞬时抖动掩盖另一路已经得到的确定结果
|
||||
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)
|
||||
if info_tv and info_movie:
|
||||
# 同时存在,尝试通过元数据消歧
|
||||
result = self._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
|
||||
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[dict]:
|
||||
@@ -258,18 +443,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
# 避免一路瞬时抖动掩盖另一路已经得到的确定结果
|
||||
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)
|
||||
if info_tv and info_movie:
|
||||
# 同时存在,尝试通过元数据消歧
|
||||
result = self._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
|
||||
return self._resolve_tmdbid_candidates(
|
||||
tmdbid, meta, info_tv, tv_conn_error, info_movie, movie_conn_error
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _disambiguate_by_meta(info_tv: dict, info_movie: dict,
|
||||
@@ -325,147 +501,178 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
|
||||
return None
|
||||
|
||||
def _search_by_name(self, name: str, meta: MetaBase, group_seasons: List[dict]) -> dict:
|
||||
@staticmethod
|
||||
def _build_match_plan(name: str, meta: MetaBase) -> Tuple[_MatchStep, ...]:
|
||||
"""生成名称识别的有序回退计划,确保同步与异步分支完全一致。"""
|
||||
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=meta.year,
|
||||
mtype=meta.type,
|
||||
season_year=meta.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=meta.year,
|
||||
mtype=MediaType.MOVIE,
|
||||
include_year=True,
|
||||
),
|
||||
_MatchStep(
|
||||
name=name,
|
||||
year=meta.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]:
|
||||
"""
|
||||
根据名称搜索媒体信息
|
||||
"""
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
return self.tmdb.match_multi(name)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 确定是电视
|
||||
info = self.tmdb.match(name=name,
|
||||
year=meta.year,
|
||||
mtype=meta.type,
|
||||
season_year=meta.year,
|
||||
season_number=meta.begin_season,
|
||||
group_seasons=group_seasons)
|
||||
if not info:
|
||||
# 去掉年份再查一次
|
||||
info = self.tmdb.match(name=name, mtype=meta.type)
|
||||
return info
|
||||
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(name=name, year=meta.year, mtype=MediaType.MOVIE)
|
||||
# 没有再按电视剧查
|
||||
if not info:
|
||||
info = self.tmdb.match(name=name, year=meta.year, mtype=MediaType.TV,
|
||||
group_seasons=group_seasons)
|
||||
if not info:
|
||||
# 去掉年份和类型再查一次
|
||||
info = self.tmdb.match_multi(name=name)
|
||||
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: List[dict]) -> dict:
|
||||
async def _async_search_by_name(
|
||||
self, name: str, meta: MetaBase, group_seasons: _TmdbDataList
|
||||
) -> Optional[_TmdbData]:
|
||||
"""
|
||||
根据名称搜索媒体信息(异步版本)
|
||||
"""
|
||||
if meta.begin_season is not None:
|
||||
logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {name} ...")
|
||||
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
return await self.tmdb.async_match_multi(name)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 确定是电视
|
||||
info = await self.tmdb.async_match(name=name,
|
||||
year=meta.year,
|
||||
mtype=meta.type,
|
||||
season_year=meta.year,
|
||||
season_number=meta.begin_season,
|
||||
group_seasons=group_seasons)
|
||||
if not info:
|
||||
# 去掉年份再查一次
|
||||
info = await self.tmdb.async_match(name=name, mtype=meta.type)
|
||||
return info
|
||||
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(name=name, year=meta.year, mtype=MediaType.MOVIE)
|
||||
# 没有再按电视剧查
|
||||
if not info:
|
||||
info = await self.tmdb.async_match(name=name, year=meta.year, mtype=MediaType.TV,
|
||||
group_seasons=group_seasons)
|
||||
if not info:
|
||||
# 去掉年份和类型再查一次
|
||||
info = await self.tmdb.async_match_multi(name=name)
|
||||
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: List[dict]) -> MediaInfo:
|
||||
group_seasons: _TmdbDataList) -> MediaInfo:
|
||||
"""
|
||||
处理剧集组信息
|
||||
"""
|
||||
if mediainfo.type == MediaType.TV and mediainfo.episode_groups:
|
||||
if group_seasons:
|
||||
self._fill_group_season_info(mediainfo, episode_group, group_seasons)
|
||||
else:
|
||||
# 每季年份
|
||||
season_years = {}
|
||||
for group in mediainfo.episode_groups:
|
||||
if group.get('type') != 6:
|
||||
# 只处理剧集部分
|
||||
continue
|
||||
group_episodes = self.tmdb.get_tv_group_seasons(group.get('id'))
|
||||
if not group_episodes:
|
||||
continue
|
||||
for group_episode in group_episodes:
|
||||
season = group_episode.get('order')
|
||||
episodes = group_episode.get('episodes')
|
||||
if not episodes:
|
||||
continue
|
||||
# 当前季第一季时间
|
||||
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 season_years:
|
||||
mediainfo.season_years = season_years
|
||||
return mediainfo
|
||||
season_years = {}
|
||||
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: List[dict]) -> MediaInfo:
|
||||
group_seasons: _TmdbDataList) -> MediaInfo:
|
||||
"""
|
||||
处理剧集组信息(异步版本)
|
||||
"""
|
||||
if mediainfo.type == MediaType.TV and mediainfo.episode_groups:
|
||||
if group_seasons:
|
||||
self._fill_group_season_info(mediainfo, episode_group, group_seasons)
|
||||
else:
|
||||
# 每季年份
|
||||
season_years = {}
|
||||
for group in mediainfo.episode_groups:
|
||||
if group.get('type') != 6:
|
||||
# 只处理剧集部分
|
||||
continue
|
||||
group_episodes = await self.tmdb.async_get_tv_group_seasons(group.get('id'))
|
||||
if not group_episodes:
|
||||
continue
|
||||
for group_episode in group_episodes:
|
||||
season = group_episode.get('order')
|
||||
episodes = group_episode.get('episodes')
|
||||
if not episodes:
|
||||
continue
|
||||
# 当前季第一季时间
|
||||
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 season_years:
|
||||
mediainfo.season_years = season_years
|
||||
return mediainfo
|
||||
season_years = {}
|
||||
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_result(self, info: dict, meta: MetaBase, tmdbid: Optional[int],
|
||||
episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo:
|
||||
"""
|
||||
构建MediaInfo结果
|
||||
"""
|
||||
def _build_media_info_base(
|
||||
self, info: _TmdbData, meta: Optional[MetaBase], tmdbid: Optional[int]
|
||||
) -> MediaInfo:
|
||||
"""统一完成 TMDB 详情到 MediaInfo 的分类映射与结果日志。"""
|
||||
# 确定二级分类
|
||||
if info.get('media_type') == MediaType.TV:
|
||||
cat = self.category.get_tv_category(info)
|
||||
@@ -483,32 +690,20 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
else:
|
||||
logger.info(f"{tmdbid} TMDB识别结果:{mediainfo.type.value} "
|
||||
f"{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: dict, meta: MetaBase, tmdbid: Optional[int],
|
||||
episode_group: Optional[str], group_seasons: List[dict]) -> MediaInfo:
|
||||
"""
|
||||
构建MediaInfo结果(异步版本)
|
||||
"""
|
||||
# 确定二级分类
|
||||
if info.get('media_type') == MediaType.TV:
|
||||
cat = self.category.get_tv_category(info)
|
||||
else:
|
||||
cat = self.category.get_movie_category(info)
|
||||
|
||||
# 赋值TMDB信息并返回
|
||||
mediainfo = MediaInfo(tmdb_info=info)
|
||||
mediainfo.set_category(cat)
|
||||
|
||||
if meta:
|
||||
logger.info(f"{meta.name} TMDB识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year} "
|
||||
f"{mediainfo.tmdb_id}")
|
||||
else:
|
||||
logger.info(f"{tmdbid} TMDB识别结果:{mediainfo.type.value} "
|
||||
f"{mediainfo.title_year}")
|
||||
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)
|
||||
@@ -530,40 +725,22 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# TMDB 只处理影视;音乐识别模块异常时也不能把音乐请求回退成电视剧搜索。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
plan = self._build_recognize_plan(
|
||||
meta, mtype, media_source, media_id, episode_group, cache
|
||||
)
|
||||
if not plan:
|
||||
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 self._validate_recognize_params(meta, tmdbid, media_source):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
# 未提供元数据时,直接使用tmdbid查询,不使用缓存
|
||||
cache_info = {}
|
||||
else:
|
||||
# 读取缓存
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if tmdbid:
|
||||
meta.media_source = MediaSource.TMDB
|
||||
meta.media_id = str(tmdbid)
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
tmdbid = plan.tmdbid
|
||||
cache_info = self._load_recognize_cache(plan)
|
||||
|
||||
# 查询剧集组
|
||||
group_seasons = []
|
||||
if episode_group:
|
||||
group_seasons = self.tmdb.get_tv_group_seasons(episode_group)
|
||||
if plan.episode_group:
|
||||
group_seasons = self.tmdb.get_tv_group_seasons(plan.episode_group)
|
||||
cache_hit = False
|
||||
|
||||
# 识别匹配
|
||||
if not cache_info or not cache:
|
||||
if not cache_info or not plan.use_cache:
|
||||
info = None
|
||||
connection_error = False
|
||||
# 缓存没有或者强制不使用缓存
|
||||
@@ -587,19 +764,11 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
info = self.tmdb.get_info(mtype=info.get("media_type"),
|
||||
tmdbid=info.get("id"))
|
||||
elif not info:
|
||||
if connection_error:
|
||||
# 网络故障与"条目不存在"是完全不同的两类问题,不能用同一句文案掩盖,
|
||||
# 否则用户无从判断该等网络恢复还是该确认条目本身是否存在
|
||||
logger.error(f"tmdb_id:{tmdbid} 连接TheMovieDb失败,无法完成识别,请检查网络连接后重试")
|
||||
elif tmdbid:
|
||||
logger.warn(f"tmdb_id:{tmdbid} 无法确定媒体类型,识别失败")
|
||||
else:
|
||||
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
|
||||
self._log_recognize_lookup_failure(plan, connection_error)
|
||||
return None
|
||||
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
self.cache.update(meta, info)
|
||||
self._save_recognize_cache(plan, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
cache_hit = True
|
||||
@@ -612,7 +781,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
info = None
|
||||
|
||||
if info:
|
||||
mediainfo = self._build_media_info_result(info, meta, tmdbid, episode_group, group_seasons)
|
||||
mediainfo = self._build_media_info_result(
|
||||
info, meta, tmdbid, plan.episode_group, group_seasons
|
||||
)
|
||||
if mediainfo:
|
||||
mediainfo.recognize_cache_hit = cache_hit
|
||||
return mediainfo
|
||||
@@ -638,40 +809,24 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
# 与同步入口保持同一类型边界,音乐请求不得进入 TMDB。
|
||||
if mtype == MediaType.MUSIC or getattr(meta, "type", None) == MediaType.MUSIC:
|
||||
plan = self._build_recognize_plan(
|
||||
meta, mtype, media_source, media_id, episode_group, cache
|
||||
)
|
||||
if not plan:
|
||||
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 self._validate_recognize_params(meta, tmdbid, media_source):
|
||||
return None
|
||||
|
||||
if not meta:
|
||||
# 未提供元数据时,直接使用tmdbid查询,不使用缓存
|
||||
cache_info = {}
|
||||
else:
|
||||
# 读取缓存
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if tmdbid:
|
||||
meta.media_source = MediaSource.TMDB
|
||||
meta.media_id = str(tmdbid)
|
||||
cache_info = self.cache.get(meta) if cache else {}
|
||||
tmdbid = plan.tmdbid
|
||||
cache_info = self._load_recognize_cache(plan)
|
||||
|
||||
# 查询剧集组
|
||||
group_seasons = []
|
||||
if episode_group:
|
||||
group_seasons = await self.tmdb.async_get_tv_group_seasons(episode_group)
|
||||
if plan.episode_group:
|
||||
group_seasons = await self.tmdb.async_get_tv_group_seasons(
|
||||
plan.episode_group
|
||||
)
|
||||
cache_hit = False
|
||||
|
||||
# 识别匹配
|
||||
if not cache_info or not cache:
|
||||
if not cache_info or not plan.use_cache:
|
||||
info = None
|
||||
connection_error = False
|
||||
# 缓存没有或者强制不使用缓存
|
||||
@@ -695,19 +850,11 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
info = await self.tmdb.async_get_info(mtype=info.get("media_type"),
|
||||
tmdbid=info.get("id"))
|
||||
elif not info:
|
||||
if connection_error:
|
||||
# 网络故障与"条目不存在"是完全不同的两类问题,不能用同一句文案掩盖,
|
||||
# 否则用户无从判断该等网络恢复还是该确认条目本身是否存在
|
||||
logger.error(f"tmdb_id:{tmdbid} 连接TheMovieDb失败,无法完成识别,请检查网络连接后重试")
|
||||
elif tmdbid:
|
||||
logger.warn(f"tmdb_id:{tmdbid} 无法确定媒体类型,识别失败")
|
||||
else:
|
||||
logger.error("识别媒体信息时未提供元数据或唯一且有效的tmdbid")
|
||||
self._log_recognize_lookup_failure(plan, connection_error)
|
||||
return None
|
||||
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
self.cache.update(meta, info)
|
||||
self._save_recognize_cache(plan, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
cache_hit = True
|
||||
@@ -720,7 +867,9 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
info = None
|
||||
|
||||
if info:
|
||||
mediainfo = await self._async_build_media_info_result(info, meta, tmdbid, episode_group, group_seasons)
|
||||
mediainfo = await self._async_build_media_info_result(
|
||||
info, meta, tmdbid, plan.episode_group, group_seasons
|
||||
)
|
||||
if mediainfo:
|
||||
mediainfo.recognize_cache_hit = cache_hit
|
||||
return mediainfo
|
||||
@@ -859,26 +1008,25 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(media_source, MediaSource.TMDB):
|
||||
plan = self._build_media_search_plan(meta, media_source)
|
||||
if not plan:
|
||||
return None
|
||||
if not meta.name:
|
||||
if not plan.name:
|
||||
return []
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
results = self.tmdb.search_multiis(meta.name)
|
||||
else:
|
||||
if meta.type == MediaType.UNKNOWN:
|
||||
results = self.tmdb.search_movies(meta.name, meta.year)
|
||||
results.extend(self.tmdb.search_tvs(meta.name, meta.year))
|
||||
# 组合结果的情况下要排序
|
||||
results = sorted(
|
||||
results,
|
||||
key=lambda x: x.get("release_date") or x.get("first_air_date") or "0000-00-00",
|
||||
reverse=True
|
||||
)
|
||||
elif meta.type == MediaType.MOVIE:
|
||||
results = self.tmdb.search_movies(meta.name, meta.year)
|
||||
else:
|
||||
results = self.tmdb.search_tvs(meta.name, meta.year)
|
||||
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)
|
||||
|
||||
@@ -1117,6 +1265,17 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
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: list) -> Optional[str]:
|
||||
"""
|
||||
@@ -1169,16 +1328,16 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# 调用TMDB图片接口
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
query = self._build_image_query(mediainfo)
|
||||
if query.movie:
|
||||
images = self.tmdb.get_movie_images(
|
||||
mediainfo.tmdb_id,
|
||||
original_language=mediainfo.original_language,
|
||||
query.tmdbid,
|
||||
original_language=query.original_language,
|
||||
)
|
||||
else:
|
||||
images = self.tmdb.get_tv_images(
|
||||
mediainfo.tmdb_id,
|
||||
original_language=mediainfo.original_language,
|
||||
query.tmdbid,
|
||||
original_language=query.original_language,
|
||||
)
|
||||
if not images:
|
||||
return mediainfo
|
||||
@@ -1197,16 +1356,16 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# 调用TMDB图片接口
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
query = self._build_image_query(mediainfo)
|
||||
if query.movie:
|
||||
images = await self.tmdb.async_get_movie_images(
|
||||
mediainfo.tmdb_id,
|
||||
original_language=mediainfo.original_language,
|
||||
query.tmdbid,
|
||||
original_language=query.original_language,
|
||||
)
|
||||
else:
|
||||
images = await self.tmdb.async_get_tv_images(
|
||||
mediainfo.tmdb_id,
|
||||
original_language=mediainfo.original_language,
|
||||
query.tmdbid,
|
||||
original_language=query.original_language,
|
||||
)
|
||||
if not images:
|
||||
return mediainfo
|
||||
@@ -1341,26 +1500,29 @@ class TheMovieDbModule(MediaAuxiliaryProviderMixin, _ModuleBase):
|
||||
:param media_source: 请求级搜索数据源
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if not is_media_source_enabled(media_source, MediaSource.TMDB):
|
||||
plan = self._build_media_search_plan(meta, media_source)
|
||||
if not plan:
|
||||
return None
|
||||
if not meta.name:
|
||||
if not plan.name:
|
||||
return []
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
results = await self.tmdb.async_search_multiis(meta.name)
|
||||
else:
|
||||
if meta.type == MediaType.UNKNOWN:
|
||||
results = await self.tmdb.async_search_movies(meta.name, meta.year)
|
||||
results.extend(await self.tmdb.async_search_tvs(meta.name, meta.year))
|
||||
# 组合结果的情况下要排序
|
||||
results = sorted(
|
||||
results,
|
||||
key=lambda x: x.get("release_date") or x.get("first_air_date") or "0000-00-00",
|
||||
reverse=True
|
||||
)
|
||||
elif meta.type == MediaType.MOVIE:
|
||||
results = await self.tmdb.async_search_movies(meta.name, meta.year)
|
||||
else:
|
||||
results = await self.tmdb.async_search_tvs(meta.name, meta.year)
|
||||
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)
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
|
||||
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
|
||||
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
|
||||
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
|
||||
| 全量 mypy 历史债务 | 10,283 / 592 文件 | 生命周期撤销接线清除 3 项调用类型债,识别链 typed plan/result 再清除 34 项;strict frontier 当前覆盖 41 个文件 |
|
||||
| Ruff 历史诊断 | 644 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 全量 mypy 历史债务 | 10,255 / 592 文件 | 识别链 typed plan/result 与 TMDB 双 ABI 共用计划继续清除参数、返回值和泛型注解债;strict frontier 当前覆盖 41 个文件 |
|
||||
| Ruff 历史诊断 | 643 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 81.00%,Domain 80.67% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
@@ -2572,17 +2572,16 @@
|
||||
"override": 1
|
||||
},
|
||||
"app/modules/themoviedb/__init__.py": {
|
||||
"arg-type": 27,
|
||||
"arg-type": 15,
|
||||
"assignment": 12,
|
||||
"empty-body": 1,
|
||||
"index": 2,
|
||||
"no-any-return": 4,
|
||||
"no-untyped-call": 7,
|
||||
"no-untyped-def": 3,
|
||||
"return-value": 8,
|
||||
"type-arg": 29,
|
||||
"union-attr": 2,
|
||||
"var-annotated": 2
|
||||
"return-value": 2,
|
||||
"type-arg": 21,
|
||||
"union-attr": 2
|
||||
},
|
||||
"app/modules/themoviedb/category.py": {
|
||||
"assignment": 2,
|
||||
|
||||
@@ -573,9 +573,6 @@
|
||||
"app/modules/theaudiodb/__init__.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/modules/themoviedb/__init__.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"app/modules/themoviedb/category.py": {
|
||||
"F541": 1,
|
||||
"I001": 1
|
||||
|
||||
184
tests/test_tmdb_sync_async_parity.py
Normal file
184
tests/test_tmdb_sync_async_parity.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""TMDB 模块同步与异步公开能力的业务决策一致性测试。"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, call
|
||||
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def _module_with_clients() -> TheMovieDbModule:
|
||||
"""构造不触发模块生命周期、只注入测试客户端的 TMDB 模块。"""
|
||||
module = object.__new__(TheMovieDbModule)
|
||||
module.tmdb = Mock()
|
||||
module.cache = Mock()
|
||||
module.category = Mock()
|
||||
return module
|
||||
|
||||
|
||||
def _movie_info(tmdbid: int = 10) -> dict:
|
||||
"""构造可投影为 MediaInfo 的最小电影详情。"""
|
||||
return {
|
||||
"id": tmdbid,
|
||||
"media_type": MediaType.MOVIE,
|
||||
"title": "测试电影",
|
||||
"release_date": "2024-02-03",
|
||||
"genres": [{"id": 18, "name": "剧情"}],
|
||||
}
|
||||
|
||||
|
||||
def test_recognize_sync_async_share_identity_cache_and_result_decisions() -> None:
|
||||
"""显式 TMDB 身份在双入口应产生相同查询、缓存写入和媒体结果。"""
|
||||
module = _module_with_clients()
|
||||
info = _movie_info()
|
||||
module.tmdb.get_info.return_value = info
|
||||
module.tmdb.async_get_info = AsyncMock(return_value=info)
|
||||
module.category.get_movie_category.return_value = "剧情片"
|
||||
sync_meta = MetaBase("测试电影 2024")
|
||||
async_meta = MetaBase("测试电影 2024")
|
||||
|
||||
sync_result = module.recognize_media(
|
||||
meta=sync_meta,
|
||||
mtype=MediaType.MOVIE,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="10",
|
||||
cache=False,
|
||||
)
|
||||
async_result = asyncio.run(
|
||||
module.async_recognize_media(
|
||||
meta=async_meta,
|
||||
mtype=MediaType.MOVIE,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="10",
|
||||
cache=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert sync_result is not None
|
||||
assert async_result is not None
|
||||
assert (
|
||||
sync_result.media_source,
|
||||
sync_result.media_id,
|
||||
sync_result.title,
|
||||
sync_result.year,
|
||||
sync_result.category,
|
||||
sync_result.recognize_cache_hit,
|
||||
) == (
|
||||
async_result.media_source,
|
||||
async_result.media_id,
|
||||
async_result.title,
|
||||
async_result.year,
|
||||
async_result.category,
|
||||
async_result.recognize_cache_hit,
|
||||
)
|
||||
module.tmdb.get_info.assert_called_once_with(
|
||||
mtype=MediaType.MOVIE, tmdbid=10, raise_on_connection_error=True
|
||||
)
|
||||
module.tmdb.async_get_info.assert_awaited_once_with(
|
||||
mtype=MediaType.MOVIE, tmdbid=10, raise_on_connection_error=True
|
||||
)
|
||||
assert sync_meta.media_source == async_meta.media_source == MediaSource.TMDB
|
||||
assert sync_meta.media_id == async_meta.media_id == "10"
|
||||
assert module.cache.update.call_args_list == [
|
||||
call(sync_meta, info),
|
||||
call(async_meta, info),
|
||||
]
|
||||
|
||||
|
||||
def test_name_match_sync_async_share_ordered_fallback_plan() -> None:
|
||||
"""电视剧名称识别的带季查询与无年份回退在双入口应完全同序。"""
|
||||
module = _module_with_clients()
|
||||
meta = MetaBase("测试剧 S02 2024")
|
||||
meta.name = "测试剧"
|
||||
meta.type = MediaType.TV
|
||||
meta.year = "2024"
|
||||
meta.begin_season = 2
|
||||
result = {"id": 20}
|
||||
module.tmdb.match.side_effect = [None, result]
|
||||
module.tmdb.async_match = AsyncMock(side_effect=[None, result])
|
||||
group_seasons = [{"order": 2, "episodes": []}]
|
||||
|
||||
sync_result = module._search_by_name(meta.name, meta, group_seasons)
|
||||
async_result = asyncio.run(
|
||||
module._async_search_by_name(meta.name, meta, group_seasons)
|
||||
)
|
||||
|
||||
assert sync_result == async_result == result
|
||||
expected_calls = [
|
||||
call(
|
||||
name="测试剧",
|
||||
year="2024",
|
||||
mtype=MediaType.TV,
|
||||
season_year="2024",
|
||||
season_number=2,
|
||||
group_seasons=group_seasons,
|
||||
),
|
||||
call(name="测试剧", mtype=MediaType.TV),
|
||||
]
|
||||
assert module.tmdb.match.call_args_list == expected_calls
|
||||
assert module.tmdb.async_match.call_args_list == expected_calls
|
||||
|
||||
|
||||
def test_media_search_sync_async_share_combination_and_sorting() -> None:
|
||||
"""未知类型且有年份时,双入口应合并电影和电视剧并按日期倒序。"""
|
||||
module = _module_with_clients()
|
||||
meta = MetaBase("同名作品 2024")
|
||||
meta.name = "同名作品"
|
||||
meta.type = MediaType.UNKNOWN
|
||||
meta.year = "2024"
|
||||
movie = _movie_info(30)
|
||||
tv = {
|
||||
"id": 31,
|
||||
"media_type": MediaType.TV,
|
||||
"name": "同名作品",
|
||||
"first_air_date": "2024-08-01",
|
||||
}
|
||||
module.tmdb.search_movies.return_value = [movie]
|
||||
module.tmdb.search_tvs.return_value = [tv]
|
||||
module.tmdb.async_search_movies = AsyncMock(return_value=[movie])
|
||||
module.tmdb.async_search_tvs = AsyncMock(return_value=[tv])
|
||||
|
||||
sync_result = module.search_medias(meta, media_source=MediaSource.TMDB)
|
||||
async_result = asyncio.run(
|
||||
module.async_search_medias(meta, media_source=MediaSource.TMDB)
|
||||
)
|
||||
|
||||
assert sync_result is not None
|
||||
assert async_result is not None
|
||||
assert [item.tmdb_id for item in sync_result] == [31, 30]
|
||||
assert [item.tmdb_id for item in async_result] == [31, 30]
|
||||
module.tmdb.search_movies.assert_called_once_with("同名作品", "2024")
|
||||
module.tmdb.search_tvs.assert_called_once_with("同名作品", "2024")
|
||||
module.tmdb.async_search_movies.assert_awaited_once_with("同名作品", "2024")
|
||||
module.tmdb.async_search_tvs.assert_awaited_once_with("同名作品", "2024")
|
||||
|
||||
|
||||
def test_obtain_images_sync_async_share_query_and_mapping() -> None:
|
||||
"""图片补全双入口应选择同一接口、语言参数和最佳图片映射。"""
|
||||
module = _module_with_clients()
|
||||
images = {
|
||||
"posters": [{"file_path": "/poster.jpg", "vote_average": 8}],
|
||||
"backdrops": [{"file_path": "/backdrop.jpg", "vote_average": 7}],
|
||||
"logos": [{"file_path": "/logo.png", "vote_average": 6}],
|
||||
}
|
||||
module.tmdb.get_movie_images.return_value = images
|
||||
module.tmdb.async_get_movie_images = AsyncMock(return_value=images)
|
||||
sync_media = MediaInfo(tmdb_info=_movie_info(40))
|
||||
async_media = MediaInfo(tmdb_info=_movie_info(40))
|
||||
sync_media.original_language = "ja"
|
||||
async_media.original_language = "ja"
|
||||
|
||||
sync_result = module.obtain_images(sync_media)
|
||||
async_result = asyncio.run(module.async_obtain_images(async_media))
|
||||
|
||||
assert sync_result is sync_media
|
||||
assert async_result is async_media
|
||||
assert sync_media.poster_path == async_media.poster_path
|
||||
assert sync_media.backdrop_path == async_media.backdrop_path
|
||||
assert sync_media.logo_path == async_media.logo_path
|
||||
module.tmdb.get_movie_images.assert_called_once_with(40, original_language="ja")
|
||||
module.tmdb.async_get_movie_images.assert_awaited_once_with(
|
||||
40, original_language="ja"
|
||||
)
|
||||
Reference in New Issue
Block a user