# -*- coding: utf-8 -*- import asyncio import json from copy import deepcopy from pathlib import Path from unittest import TestCase from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import parse_qsl, urlencode, urlsplit from app.core.metainfo import MetaInfo from app.chain import ChainBase from app.helper.server import MoviePilotServerHelper from app.modules.themoviedb import TheMovieDbModule from app.modules.themoviedb.tmdbv3api.tmdb import TMDb from app.schemas.types import MediaType # 离线 TMDB 响应回放:识别测试断言的是 tmdbid 优先/电影电视消歧/类型推断等逻辑, # 这些逻辑需要真实结构的 TMDB 响应才有意义,但直连 api.themoviedb.org 属于不可接受的 # 外部 IO(CI 冷缓存下单文件 ~75s 且 flaky)。这里用一次性录制的真实响应 cassette 回放 # TMDb 的 HTTP 出入口,既保持识别逻辑被真实数据驱动,又彻底离线。重新录制见提交说明。 _CASSETTE_PATH = Path(__file__).resolve().parent / "fixtures" / "tmdb_recognize_cassette.json" _CASSETTE: dict = json.loads(_CASSETTE_PATH.read_text(encoding="utf-8")) # 响应快照标记键,与 TMDb._snapshot_response 写入的结构保持一致 _MARKER = TMDb._RESPONSE_SNAPSHOT_MARKER def _cassette_key(url: str) -> str: """把 TMDB 请求 URL 归一化为 cassette 键:剥离易变的 api_key,其余 query 排序。 `_build_url` 生成形如 `/3/movie/23155?api_key=...&append_to_response=...&language=zh`, 剥离 api_key 后键在不同环境/不同 key 下保持稳定。 """ parts = urlsplit(url) query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k != "api_key") return f"{parts.path}?{urlencode(query)}" def _replay(url: str) -> dict: """按归一化键回放录制的响应快照;未命中即报错提示重新录制,避免静默漏过新请求。""" key = _cassette_key(url) if key not in _CASSETTE: raise AssertionError( f"TMDB cassette 未命中:{key};如识别流程新增请求,请重新录制 " f"tests/fixtures/tmdb_recognize_cassette.json" ) # headers 置空:识别只消费 json,丢弃录制头可规避限流/ETag 等无关分支 return {_MARKER: True, "headers": {}, "json": deepcopy(_CASSETTE[key])} def _replay_request(self, method, url, data, json=None, **kwargs): # noqa: A002 - 对齐被替换方法签名 """TMDb.request 的离线替身(同步)。""" return _replay(url) async def _replay_async_request(self, method, url, data, json=None, **kwargs): # noqa: A002 - 同上 """TMDb.async_request 的离线替身(异步)。""" return _replay(url) _PATCHERS: list = [] def setUpModule(): """整文件生效:离线化 TMDB HTTP 与共享识别 API,确保零真实请求。 ChainBase.async_recognize_media 在识别成功后会经 MoviePilotServerHelper 向 MP 服务器(movie-pilot.org)的「共享识别 API」上报/查询;识别失败时还会反向 查询。这两条链路与 TMDB 目录无关,必须一并打桩,否则 Chain 端到端用例仍会真发请求。 """ _PATCHERS.extend([ patch.object(TMDb, "request", _replay_request), patch.object(TMDb, "async_request", _replay_async_request), patch.object(MoviePilotServerHelper, "async_report_recognize_share", new=AsyncMock(return_value=None)), patch.object(MoviePilotServerHelper, "async_query_recognize_share", new=AsyncMock(return_value=None)), patch.object(MoviePilotServerHelper, "report_recognize_share", new=MagicMock(return_value=None)), patch.object(MoviePilotServerHelper, "query_recognize_share", new=MagicMock(return_value=None)), ]) started = [] try: for patcher in _PATCHERS: patcher.start() started.append(patcher) except Exception: # 仅回滚已成功启动的桩:对未启动的 patcher 调用 stop() 会抛 RuntimeError, # 既掩盖原始启动异常又中断清理;记录 started 可精确回滚、避免半启动状态泄漏。 for patcher in started: patcher.stop() _PATCHERS.clear() raise def tearDownModule(): """还原 TMDb HTTP 出口打桩,避免影响其它测试模块。""" for patcher in _PATCHERS: patcher.stop() _PATCHERS.clear() class TmdbRecognizeModuleTest(TestCase): """ TMDB模块层识别测试 模块层的 async_recognize_media 不会自动从 meta.tmdbid 提取 tmdbid, 该提取在 ChainBase 层完成,因此测试中需显式传入 tmdbid 参数。 """ @classmethod def setUpClass(cls): cls.module = TheMovieDbModule() cls.module.init_module() @classmethod def tearDownClass(cls): cls.module.stop() def _run(self, coro): return asyncio.run(coro) def test_tmdbid_priority_over_title(self): """ 当标题中包含 {tmdbid=xxx} 时,应优先使用tmdbid识别, 而非回退到标题搜索 """ meta = MetaInfo(title="空之境界 {tmdbid=938416}") self.assertEqual(meta.tmdbid, 938416) self.assertEqual(meta.cn_name, "空之境界") result = self._run( self.module.async_recognize_media( meta=meta, tmdbid=meta.tmdbid, cache=False ) ) self.assertIsNotNone(result, "应能识别到媒体信息") self.assertEqual(result.tmdb_id, 938416) def test_tmdbid_disambiguation_tv_vs_movie(self): """ 当同一tmdbid同时存在电影和电视剧时,应通过元数据消歧 tmdbid=23155 同时存在电影"空之境界 第五章 矛盾螺旋"和电视剧"TV Land Top 10" 标题包含"空之境界"应消歧为电影 """ meta = MetaInfo(title="空之境界 第五章 矛盾螺旋 (2008) {tmdbid=23155}") self.assertEqual(meta.tmdbid, 23155) result = self._run( self.module.async_recognize_media( meta=meta, tmdbid=meta.tmdbid, cache=False ) ) self.assertIsNotNone(result, "同ID存在电影和电视剧时应能通过元数据消歧") self.assertEqual(result.tmdb_id, 23155) self.assertEqual(result.type, MediaType.MOVIE) def test_tmdbid_with_explicit_type(self): """ 当标题中同时包含 tmdbid 和 type 时,应直接使用指定类型查询 """ meta = MetaInfo(title="空之境界 {tmdbid=23155}") result = self._run( self.module.async_recognize_media( meta=meta, tmdbid=meta.tmdbid, mtype=MediaType.TV, cache=False ) ) self.assertIsNotNone(result) self.assertEqual(result.tmdb_id, 23155) self.assertEqual(result.type, MediaType.TV) def test_tmdbid_only_movie_exists(self): """ tmdbid仅存在电影时,即使meta.type推断为TV也应正确识别为电影 tmdbid=496891 仅存在电影"少女与战车 最终章 ~第2话~" """ meta = MetaInfo(title="少女与战车 最终章 ~第2话~ (2019) {tmdbid=496891}") self.assertEqual(meta.tmdbid, 496891) result = self._run( self.module.async_recognize_media( meta=meta, tmdbid=meta.tmdbid, cache=False ) ) self.assertIsNotNone(result, "仅存在电影时应正确识别") self.assertEqual(result.tmdb_id, 496891) self.assertEqual(result.type, MediaType.MOVIE) class TmdbRecognizeChainTest(TestCase): """ ChainBase层识别测试(端到端) 验证从 meta.tmdbid 提取到模块识别的完整流程 """ @classmethod def setUpClass(cls): cls.chain = ChainBase() def _run(self, coro): return asyncio.run(coro) def test_chain_tmdbid_movie(self): """ 通过ChainBase识别,tmdbid对应电影应正确识别 """ meta = MetaInfo(title="空之境界 第五章 矛盾螺旋 (2008) {tmdbid=23155}") result = self._run( self.chain.async_recognize_media(meta=meta, cache=False) ) self.assertIsNotNone(result) self.assertEqual(result.tmdb_id, 23155) self.assertEqual(result.type, MediaType.MOVIE) def test_chain_tmdbid_ignores_inferred_type(self): """ 当tmdbid存在时,不应使用meta推断的类型 "第2话"会让meta.type推断为TV,但tmdbid=496891仅存在电影 """ meta = MetaInfo(title="少女与战车 最终章 ~第2话~ (2019) {tmdbid=496891}") self.assertEqual(meta.type, MediaType.TV, "meta.type应被推断为TV") self.assertEqual(meta.tmdbid, 496891) result = self._run( self.chain.async_recognize_media(meta=meta, cache=False) ) self.assertIsNotNone(result, "有tmdbid时不应因meta.type推断错误而识别失败") self.assertEqual(result.tmdb_id, 496891) self.assertEqual(result.type, MediaType.MOVIE) def test_chain_no_tmdbid_uses_inferred_type(self): """ 无tmdbid时,应正常使用meta推断的类型进行标题搜索 """ meta = MetaInfo(title="进击的巨人 S01E01") self.assertEqual(meta.type, MediaType.TV) result = self._run( self.chain.async_recognize_media(meta=meta, cache=False) ) self.assertIsNotNone(result) self.assertEqual(result.type, MediaType.TV)