diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 524f6f8fb..91ed6112d 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -14,7 +14,7 @@ from transmission_rpc import File from app.core.cache import FileCache, AsyncFileCache, fresh, async_fresh from app.core.config import settings -from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo from app.core.event import EventManager from app.core.meta import MetaBase from app.core.module import ModuleManager @@ -705,10 +705,8 @@ class ChainBase(metaclass=ABCMeta): **module_kwargs, ) if mediainfo: - # 音乐识别结果不参与影视共享上报 - if isinstance(mediainfo, MusicInfo): - return mediainfo - if not mediainfo.recognize_cache_hit: + # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID + if not getattr(mediainfo, "recognize_cache_hit", False): MoviePilotServerHelper.report_recognize_share( meta=meta, mediainfo=mediainfo, @@ -716,7 +714,7 @@ class ChainBase(metaclass=ABCMeta): ) return mediainfo - if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share( + if not source and self._can_use_media_recognize_share( share_query_meta, tmdbid, doubanid, bangumiid, anilistid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) @@ -818,10 +816,8 @@ class ChainBase(metaclass=ABCMeta): **module_kwargs, ) if mediainfo: - # 音乐识别结果不参与影视共享上报 - if isinstance(mediainfo, MusicInfo): - return mediainfo - if not mediainfo.recognize_cache_hit: + # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID + if not getattr(mediainfo, "recognize_cache_hit", False): await MoviePilotServerHelper.async_report_recognize_share( meta=meta, mediainfo=mediainfo, @@ -829,7 +825,7 @@ class ChainBase(metaclass=ABCMeta): ) return mediainfo - if not source and mtype != MediaType.MUSIC and self._can_use_media_recognize_share( + if not source and self._can_use_media_recognize_share( share_query_meta, tmdbid, doubanid, bangumiid, anilistid ): shared_cache_meta = self._snapshot_recognize_cache_meta(meta) diff --git a/app/helper/server.py b/app/helper/server.py index d2b95dfb9..5c2ada093 100644 --- a/app/helper/server.py +++ b/app/helper/server.py @@ -2,12 +2,12 @@ import json import platform from pathlib import Path from threading import Thread -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import parse_qs, quote, urlparse, urlsplit from app.core.cache import cached from app.core.config import settings -from app.core.context import MediaInfo +from app.core.context import MediaInfo, MusicInfo from app.core.meta import MetaBase from app.db.subscribe_oper import SubscribeOper from app.db.systemconfig_oper import SystemConfigOper @@ -1366,11 +1366,11 @@ class MoviePilotServerHelper: def report_recognize_share( cls, meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], + mediainfo: Optional[Union[MediaInfo, MusicInfo]], keyword_meta: Optional[MetaBase] = None, ) -> bool: """ - 上报共享识别结果。 + 上报共享识别结果,电影、电视剧、音乐共用。 """ if not settings.MEDIA_RECOGNIZE_SHARE: return False @@ -1388,11 +1388,11 @@ class MoviePilotServerHelper: async def async_report_recognize_share( cls, meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], + mediainfo: Optional[Union[MediaInfo, MusicInfo]], keyword_meta: Optional[MetaBase] = None, ) -> bool: """ - 异步上报共享识别结果。 + 异步上报共享识别结果,电影、电视剧、音乐共用。 """ if not settings.MEDIA_RECOGNIZE_SHARE: return False @@ -1409,7 +1409,7 @@ class MoviePilotServerHelper: @classmethod def to_recognize_params(cls, item: Optional[dict]) -> Optional[dict]: """ - 将服务端返回的共享识别结果转成本地识别参数。 + 将服务端返回的共享识别结果转成本地识别参数,音乐仅携带数据源原生 ID。 """ if not isinstance(item, dict): return None @@ -1461,13 +1461,15 @@ class MoviePilotServerHelper: 统一媒体类型,兼容枚举、中文值和 agent 风格字符串。 """ normalized = media_type_to_agent(media_type) - if normalized in {"movie", "tv"}: + if normalized in {"movie", "tv", "music"}: return normalized if isinstance(media_type, str): if media_type == MediaType.MOVIE.value: return "movie" if media_type == MediaType.TV.value: return "tv" + if media_type == MediaType.MUSIC.value: + return "music" return None @staticmethod @@ -1495,9 +1497,10 @@ class MoviePilotServerHelper: media_type = cls._normalize_media_type(mtype) if media_type: return media_type - if mediainfo and mediainfo.type in {MediaType.MOVIE, MediaType.TV}: + shareable_types = {MediaType.MOVIE, MediaType.TV, MediaType.MUSIC} + if mediainfo and mediainfo.type in shareable_types: return mediainfo.type.to_agent() - if meta and meta.type in {MediaType.MOVIE, MediaType.TV}: + if meta and meta.type in shareable_types: return meta.type.to_agent() if meta and (meta.begin_season is not None or meta.begin_episode is not None): return "tv" @@ -1566,11 +1569,11 @@ class MoviePilotServerHelper: def _build_recognize_report_payload( cls, meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], + mediainfo: Optional[Union[MediaInfo, MusicInfo]], keyword_meta: Optional[MetaBase] = None, ) -> Optional[dict]: """ - 组装共享识别上报载荷。 + 组装共享识别上报载荷,电影、电视剧、音乐共用同一结构。 """ if not meta or not mediainfo: return None diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index 4dab9390b..dcd1a6067 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -1,461 +1,587 @@ -import asyncio -import sys -import unittest -from types import ModuleType -from unittest.mock import AsyncMock, patch +"""共享媒体识别链路测试。 -sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi")) -setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list) -sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc")) -setattr(sys.modules["transmission_rpc"], "File", object) -sys.modules.setdefault("psutil", ModuleType("psutil")) +覆盖本地识别成功后上报共享识别、本地识别失败后回查共享识别并二次识别、 +共享识别成功后回填本地缓存、音乐识别上报/查询载荷,以及命中缓存不重复上报等场景。 +""" +import asyncio +from unittest.mock import AsyncMock, Mock, patch from app.chain import ChainBase -from app.core.context import MediaInfo -from app.core.meta import MetaBase -from app.core.metainfo import MetaInfo from app.chain.media import MediaChain +from app.core.context import MediaInfo, MusicInfo +from app.core.meta import MetaBase, MetaMusic +from app.core.metainfo import MetaInfo from app.helper.server import MoviePilotServerHelper from app.schemas.types import MediaType -class TestMediaRecognizeShare(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.chain = ChainBase() - cls.media_chain = MediaChain() +def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase: + """构造测试用元数据。""" + meta = MetaBase(name) + meta.name = name + meta.type = media_type + return meta - @staticmethod - def _build_meta(name: str, media_type: MediaType = MediaType.UNKNOWN) -> MetaBase: - """ - 构造测试用元数据 - """ - meta = MetaBase(name) - meta.name = name - meta.type = media_type - return meta - def test_report_shared_result_after_local_recognize_success(self): - """ - 本地识别成功后应上报共享识别结果 - """ - meta = self._build_meta("测试电影", MediaType.MOVIE) - mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=100, type=MediaType.MOVIE) +def test_report_shared_result_after_local_recognize_success(): + """本地识别成功后应上报共享识别结果。""" + chain = ChainBase() + meta = _build_meta("测试电影", MediaType.MOVIE) + mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=100, type=MediaType.MOVIE) - with patch.object(self.chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=True, - ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share" - ) as query_mock: - result = self.chain.recognize_media(meta=meta, cache=False) + with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=True, + ) as report_mock, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share" + ) as query_mock: + result = chain.recognize_media(meta=meta, cache=False) - self.assertIs(result, mediainfo) - run_module.assert_called_once() - report_mock.assert_called_once_with(meta=meta, mediainfo=mediainfo, keyword_meta=meta) - query_mock.assert_not_called() + assert result is mediainfo + run_module.assert_called_once() + report_mock.assert_called_once_with(meta=meta, mediainfo=mediainfo, keyword_meta=meta) + query_mock.assert_not_called() - def test_query_shared_result_when_local_recognize_failed(self): - """ - 本地识别失败后应回查共享识别结果,并按共享ID再次识别 - """ - meta = self._build_meta("测试剧集") - shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=200, type=MediaType.TV) +def test_query_shared_result_when_local_recognize_failed(): + """本地识别失败后应回查共享识别结果,并按共享ID再次识别。""" + chain = ChainBase() + meta = _build_meta("测试剧集") + shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=200, type=MediaType.TV) + + with patch.object( + chain, + "run_module", + side_effect=[None, shared_media], + ) as run_module, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share", + return_value={"type": "tv", "tmdbid": 200, "season": 1}, + ) as query_mock, patch( + "app.chain.MoviePilotServerHelper.to_recognize_params", + return_value={ + "mtype": MediaType.TV, + "tmdbid": 200, + "doubanid": None, + "bangumiid": None, + "season": 1, + }, + ), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ), patch.object( + chain, + "_update_local_recognize_cache", + ): + result = chain.recognize_media(meta=meta, cache=False) + + assert result is shared_media + assert run_module.call_count == 2 + query_mock.assert_called_once_with(meta=meta, mtype=None, keyword_meta=meta) + second_call = run_module.call_args_list[1] + assert second_call.kwargs["tmdbid"] == 200 + assert second_call.kwargs["mtype"] == MediaType.TV + assert meta.begin_season is None + + +def test_async_query_shared_result_when_local_recognize_failed(): + """异步识别失败后也应回查共享识别结果。""" + chain = ChainBase() + meta = _build_meta("测试异步剧集") + shared_media = MediaInfo(title="测试异步剧集", year="2025", tmdb_id=300, type=MediaType.TV) + async_run_module = AsyncMock(side_effect=[None, shared_media]) + + async def runner(): with patch.object( - self.chain, - "run_module", - side_effect=[None, shared_media], - ) as run_module, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "tv", "tmdbid": 200, "season": 1}, + chain, + "async_run_module", + async_run_module, + ), patch( + "app.chain.MoviePilotServerHelper.async_query_recognize_share", + AsyncMock(return_value={"type": "tv", "tmdbid": 300, "season": 2}), ) as query_mock, patch( "app.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, - "tmdbid": 200, + "tmdbid": 300, "doubanid": None, "bangumiid": None, - "season": 1, + "season": 2, }, ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=False, + "app.chain.MoviePilotServerHelper.async_report_recognize_share", + AsyncMock(return_value=False), ), patch.object( - self.chain, - "_update_local_recognize_cache", - ): - result = self.chain.recognize_media(meta=meta, cache=False) + chain, + "_async_update_local_recognize_cache", + AsyncMock(), + ) as backfill_mock: + result = await chain.async_recognize_media(meta=meta, cache=False) + return result, query_mock, backfill_mock - self.assertIs(result, shared_media) - self.assertEqual(run_module.call_count, 2) - query_mock.assert_called_once_with(meta=meta, mtype=None, keyword_meta=meta) - second_call = run_module.call_args_list[1] - self.assertEqual(second_call.kwargs["tmdbid"], 200) - self.assertEqual(second_call.kwargs["mtype"], MediaType.TV) - self.assertIsNone(meta.begin_season) + result, query_mock, backfill_mock = asyncio.run(runner()) - def test_async_query_shared_result_when_local_recognize_failed(self): - """ - 异步识别失败后也应回查共享识别结果 - """ - meta = self._build_meta("测试异步剧集") - shared_media = MediaInfo(title="测试异步剧集", year="2025", tmdb_id=300, type=MediaType.TV) - async_run_module = AsyncMock(side_effect=[None, shared_media]) + assert result is shared_media + assert async_run_module.await_count == 2 + query_mock.assert_awaited_once_with(meta=meta, mtype=None, keyword_meta=meta) + backfill_mock.assert_awaited_once() + assert meta.begin_season is None - async def runner(): - with patch.object( - self.chain, - "async_run_module", - async_run_module, - ), patch( - "app.chain.MoviePilotServerHelper.async_query_recognize_share", - AsyncMock(return_value={"type": "tv", "tmdbid": 300, "season": 2}), - ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", - return_value={ - "mtype": MediaType.TV, - "tmdbid": 300, - "doubanid": None, - "bangumiid": None, - "season": 2, - }, - ), patch( - "app.chain.MoviePilotServerHelper.async_report_recognize_share", - AsyncMock(return_value=False), - ), patch.object( - self.chain, - "_async_update_local_recognize_cache", - AsyncMock(), - ) as backfill_mock: - result = await self.chain.async_recognize_media(meta=meta, cache=False) - return result, query_mock, backfill_mock - result, query_mock, backfill_mock = asyncio.run(runner()) +def test_backfill_local_cache_after_shared_recognize_success(): + """共享识别后二次本地识别成功时,应回填原始名称对应的本地识别缓存。""" + chain = ChainBase() + meta = _build_meta("测试缓存回填", MediaType.MOVIE) + shared_media = MediaInfo( + title="测试缓存回填", + year="2024", + tmdb_id=700, + type=MediaType.MOVIE, + source="themoviedb", + tmdb_info={"id": 700, "media_type": MediaType.MOVIE, "title": "测试缓存回填"}, + ) - self.assertIs(result, shared_media) - self.assertEqual(async_run_module.await_count, 2) - query_mock.assert_awaited_once_with(meta=meta, mtype=None, keyword_meta=meta) - backfill_mock.assert_awaited_once() - self.assertIsNone(meta.begin_season) + with patch.object( + chain, + "run_module", + side_effect=[None, shared_media, None], + ) as run_module_mock, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share", + return_value={"type": "movie", "tmdbid": 700}, + ), patch( + "app.chain.MoviePilotServerHelper.to_recognize_params", + return_value={ + "mtype": MediaType.MOVIE, + "tmdbid": 700, + "doubanid": None, + "bangumiid": None, + "season": None, + }, + ), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ): + result = chain.recognize_media(meta=meta, cache=False) - def test_backfill_local_cache_after_shared_recognize_success(self): - """ - 共享识别后二次本地识别成功时,应回填原始名称对应的本地识别缓存。 - """ - meta = self._build_meta("测试缓存回填", MediaType.MOVIE) - shared_media = MediaInfo( - title="测试缓存回填", - year="2024", - tmdb_id=700, - type=MediaType.MOVIE, - source="themoviedb", - tmdb_info={"id": 700, "media_type": MediaType.MOVIE, "title": "测试缓存回填"}, + assert result is shared_media + assert run_module_mock.call_count == 3 + update_call = run_module_mock.call_args_list[2] + assert update_call.args[0] == "update_recognize_cache" + assert update_call.kwargs["meta"] is not meta + assert update_call.kwargs["meta"].name == meta.name + assert update_call.kwargs["meta"].type == meta.type + assert update_call.kwargs["mediainfo"] is shared_media + + +def test_query_and_report_prefer_original_name_keyword(): + """查询和上报共享识别时应优先使用未应用识别词的识别名称。""" + meta = _build_meta("应用识别词后的名称", MediaType.TV) + meta.original_name = "未应用识别词的名称" + meta.year = "2024" + meta.begin_season = 1 + mediainfo = MediaInfo( + title="测试剧集", + year="2024", + tmdb_id=400, + type=MediaType.TV, + season=1, + ) + + query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) + report_payload = MoviePilotServerHelper._build_recognize_report_payload(meta=meta, mediainfo=mediainfo) + + assert query_params["keyword"] == "未应用识别词的名称" + assert report_payload["keyword"] == "未应用识别词的名称" + + +def test_query_and_report_can_use_distinct_keyword_meta(): + """共享识别应允许用原始关键字上报,同时保留辅助识别后的年份/季信息。""" + meta = _build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + meta.begin_season = 2 + + keyword_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) + keyword_meta.original_name = "辅助识别前的名称" + + mediainfo = MediaInfo( + title="测试剧集", + year="2024", + tmdb_id=401, + type=MediaType.TV, + season=2, + ) + + query_params = MoviePilotServerHelper._build_recognize_query_params( + meta=meta, + mtype=None, + keyword_meta=keyword_meta, + ) + report_payload = MoviePilotServerHelper._build_recognize_report_payload( + meta=meta, + mediainfo=mediainfo, + keyword_meta=keyword_meta, + ) + + assert query_params["keyword"] == "辅助识别前的名称" + assert query_params["year"] == "2024" + assert query_params["season"] == 2 + assert report_payload["keyword"] == "辅助识别前的名称" + assert report_payload["year"] == "2024" + assert report_payload["season"] == 2 + + +def test_query_and_report_preserve_special_season_zero(): + """共享识别查询和上报都必须保留显式特别季。""" + meta = _build_meta("测试剧特别篇", MediaType.TV) + meta.begin_season = 0 + mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0) + + query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) + report_payload = MoviePilotServerHelper._build_recognize_report_payload( + meta=meta, + mediainfo=mediainfo, + ) + + assert query_params["season"] == 0 + assert report_payload["season"] == 0 + + +def test_plugin_recognize_number_parser_preserves_zero(): + """插件辅助识别应同时接受整数和字符串形式的季 0。""" + media_chain = MediaChain() + assert media_chain._parse_recognize_event_number(0) == 0 + assert media_chain._parse_recognize_event_number("0") == 0 + assert media_chain._parse_recognize_event_number(None) is None + assert media_chain._parse_recognize_event_number("invalid") is None + + +def test_report_shared_result_with_distinct_keyword_meta(): + """辅助识别成功后应按辅助前名称上报共享结果。""" + chain = ChainBase() + meta = _build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + meta.begin_season = 1 + share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) + share_meta.original_name = "辅助识别前的名称" + mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=402, type=MediaType.TV) + + with patch.object(chain, "run_module", return_value=mediainfo), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=True, + ) as report_mock: + result = chain.recognize_media(meta=meta, share_meta=share_meta, cache=False) + + assert result is mediainfo + report_mock.assert_called_once_with( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_meta, + ) + + +def test_query_shared_result_with_distinct_keyword_meta(): + """本地识别失败后应按辅助前名称回查共享结果。""" + chain = ChainBase() + meta = _build_meta("辅助识别后的名称", MediaType.TV) + meta.year = "2024" + share_meta = _build_meta("辅助识别前的名称", MediaType.UNKNOWN) + share_meta.original_name = "辅助识别前的名称" + shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=403, type=MediaType.TV) + + with patch.object( + chain, + "run_module", + side_effect=[None, shared_media], + ), patch( + "app.chain.MoviePilotServerHelper.query_recognize_share", + return_value={"type": "tv", "tmdbid": 403, "season": 1}, + ) as query_mock, patch( + "app.chain.MoviePilotServerHelper.to_recognize_params", + return_value={ + "mtype": MediaType.TV, + "tmdbid": 403, + "doubanid": None, + "bangumiid": None, + "season": 1, + }, + ), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ), patch.object( + chain, + "_update_local_recognize_cache", + ): + result = chain.recognize_media( + meta=meta, + share_meta=share_meta, + cache=False, ) + assert result is shared_media + query_mock.assert_called_once_with( + meta=meta, + mtype=MediaType.TV, + keyword_meta=share_meta, + ) + + +def test_skip_report_when_local_recognize_hits_cache(): + """本地识别命中缓存时不应上报共享识别。""" + chain = ChainBase() + meta = _build_meta("缓存电影", MediaType.MOVIE) + mediainfo = MediaInfo(title="缓存电影", year="2024", tmdb_id=500, type=MediaType.MOVIE) + mediainfo.recognize_cache_hit = True + + with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=True, + ) as report_mock, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share" + ) as query_mock: + result = chain.recognize_media(meta=meta) + + assert result is mediainfo + run_module.assert_called_once() + report_mock.assert_not_called() + query_mock.assert_not_called() + + +def test_async_skip_report_when_local_recognize_hits_cache(): + """异步本地识别命中缓存时不应上报共享识别。""" + chain = ChainBase() + meta = _build_meta("缓存剧集", MediaType.TV) + mediainfo = MediaInfo(title="缓存剧集", year="2025", tmdb_id=600, type=MediaType.TV) + mediainfo.recognize_cache_hit = True + + async def runner(): with patch.object( - self.chain, - "run_module", - side_effect=[None, shared_media, None], - ) as run_module_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "movie", "tmdbid": 700}, - ), patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", - return_value={ - "mtype": MediaType.MOVIE, - "tmdbid": 700, - "doubanid": None, - "bangumiid": None, - "season": None, - }, - ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=False, - ): - result = self.chain.recognize_media(meta=meta, cache=False) - - self.assertIs(result, shared_media) - self.assertEqual(run_module_mock.call_count, 3) - update_call = run_module_mock.call_args_list[2] - self.assertEqual(update_call.args[0], "update_recognize_cache") - self.assertIsNot(update_call.kwargs["meta"], meta) - self.assertEqual(update_call.kwargs["meta"].name, meta.name) - self.assertEqual(update_call.kwargs["meta"].type, meta.type) - self.assertIs(update_call.kwargs["mediainfo"], shared_media) - - def test_query_and_report_prefer_original_name_keyword(self): - """ - 查询和上报共享识别时应优先使用未应用识别词的识别名称 - """ - meta = self._build_meta("应用识别词后的名称", MediaType.TV) - meta.original_name = "未应用识别词的名称" - meta.year = "2024" - meta.begin_season = 1 - mediainfo = MediaInfo( - title="测试剧集", - year="2024", - tmdb_id=400, - type=MediaType.TV, - season=1, - ) - - query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) - report_payload = MoviePilotServerHelper._build_recognize_report_payload(meta=meta, mediainfo=mediainfo) - - self.assertEqual(query_params["keyword"], "未应用识别词的名称") - self.assertEqual(report_payload["keyword"], "未应用识别词的名称") - - def test_query_and_report_can_use_distinct_keyword_meta(self): - """ - 共享识别应允许用原始关键字上报,同时保留辅助识别后的年份/季信息。 - """ - meta = self._build_meta("辅助识别后的名称", MediaType.TV) - meta.year = "2024" - meta.begin_season = 2 - - keyword_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) - keyword_meta.original_name = "辅助识别前的名称" - - mediainfo = MediaInfo( - title="测试剧集", - year="2024", - tmdb_id=401, - type=MediaType.TV, - season=2, - ) - - query_params = MoviePilotServerHelper._build_recognize_query_params( - meta=meta, - mtype=None, - keyword_meta=keyword_meta, - ) - report_payload = MoviePilotServerHelper._build_recognize_report_payload( - meta=meta, - mediainfo=mediainfo, - keyword_meta=keyword_meta, - ) - - self.assertEqual(query_params["keyword"], "辅助识别前的名称") - self.assertEqual(query_params["year"], "2024") - self.assertEqual(query_params["season"], 2) - self.assertEqual(report_payload["keyword"], "辅助识别前的名称") - self.assertEqual(report_payload["year"], "2024") - self.assertEqual(report_payload["season"], 2) - - def test_query_and_report_preserve_special_season_zero(self): - """共享识别查询和上报都必须保留显式特别季。""" - meta = self._build_meta("测试剧特别篇", MediaType.TV) - meta.begin_season = 0 - mediainfo = MediaInfo(title="测试剧", tmdb_id=402, type=MediaType.TV, season=0) - - query_params = MoviePilotServerHelper._build_recognize_query_params(meta=meta) - report_payload = MoviePilotServerHelper._build_recognize_report_payload( - meta=meta, - mediainfo=mediainfo, - ) - - self.assertEqual(query_params["season"], 0) - self.assertEqual(report_payload["season"], 0) - - def test_plugin_recognize_number_parser_preserves_zero(self): - """插件辅助识别应同时接受整数和字符串形式的季 0。""" - self.assertEqual(self.media_chain._parse_recognize_event_number(0), 0) - self.assertEqual(self.media_chain._parse_recognize_event_number("0"), 0) - self.assertIsNone(self.media_chain._parse_recognize_event_number(None)) - self.assertIsNone(self.media_chain._parse_recognize_event_number("invalid")) - - def test_report_shared_result_with_distinct_keyword_meta(self): - """ - 辅助识别成功后应按辅助前名称上报共享结果。 - """ - meta = self._build_meta("辅助识别后的名称", MediaType.TV) - meta.year = "2024" - meta.begin_season = 1 - share_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) - share_meta.original_name = "辅助识别前的名称" - mediainfo = MediaInfo(title="测试剧集", year="2024", tmdb_id=402, type=MediaType.TV) - - with patch.object(self.chain, "run_module", return_value=mediainfo), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=True, - ) as report_mock: - result = self.chain.recognize_media(meta=meta, share_meta=share_meta, cache=False) - - self.assertIs(result, mediainfo) - report_mock.assert_called_once_with( - meta=meta, - mediainfo=mediainfo, - keyword_meta=share_meta, - ) - - def test_query_shared_result_with_distinct_keyword_meta(self): - """ - 本地识别失败后应按辅助前名称回查共享结果。 - """ - meta = self._build_meta("辅助识别后的名称", MediaType.TV) - meta.year = "2024" - share_meta = self._build_meta("辅助识别前的名称", MediaType.UNKNOWN) - share_meta.original_name = "辅助识别前的名称" - shared_media = MediaInfo(title="测试剧集", year="2024", tmdb_id=403, type=MediaType.TV) - - with patch.object( - self.chain, - "run_module", - side_effect=[None, shared_media], - ), patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", - return_value={"type": "tv", "tmdbid": 403, "season": 1}, - ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", - return_value={ - "mtype": MediaType.TV, - "tmdbid": 403, - "doubanid": None, - "bangumiid": None, - "season": 1, - }, - ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=False, - ), patch.object( - self.chain, - "_update_local_recognize_cache", - ): - result = self.chain.recognize_media( - meta=meta, - share_meta=share_meta, - cache=False, - ) - - self.assertIs(result, shared_media) - query_mock.assert_called_once_with( - meta=meta, - mtype=MediaType.TV, - keyword_meta=share_meta, - ) - - def test_skip_report_when_local_recognize_hits_cache(self): - """ - 本地识别命中缓存时不应上报共享识别 - """ - meta = self._build_meta("缓存电影", MediaType.MOVIE) - mediainfo = MediaInfo(title="缓存电影", year="2024", tmdb_id=500, type=MediaType.MOVIE) - mediainfo.recognize_cache_hit = True - - with patch.object(self.chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", - return_value=True, + chain, + "async_run_module", + AsyncMock(return_value=mediainfo), + ) as async_run_module, patch( + "app.chain.MoviePilotServerHelper.async_report_recognize_share", + AsyncMock(return_value=True), ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share" + "app.chain.MoviePilotServerHelper.async_query_recognize_share", + AsyncMock(), ) as query_mock: - result = self.chain.recognize_media(meta=meta) + result = await chain.async_recognize_media(meta=meta) + return result, async_run_module, report_mock, query_mock - self.assertIs(result, mediainfo) - run_module.assert_called_once() - report_mock.assert_not_called() - query_mock.assert_not_called() + result, async_run_module, report_mock, query_mock = asyncio.run(runner()) - def test_async_skip_report_when_local_recognize_hits_cache(self): - """ - 异步本地识别命中缓存时不应上报共享识别 - """ - meta = self._build_meta("缓存剧集", MediaType.TV) - mediainfo = MediaInfo(title="缓存剧集", year="2025", tmdb_id=600, type=MediaType.TV) - mediainfo.recognize_cache_hit = True + assert result is mediainfo + async_run_module.assert_awaited_once() + report_mock.assert_not_awaited() + query_mock.assert_not_awaited() - async def runner(): - with patch.object( - self.chain, - "async_run_module", - AsyncMock(return_value=mediainfo), - ) as async_run_module, patch( - "app.chain.MoviePilotServerHelper.async_report_recognize_share", - AsyncMock(return_value=True), - ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.async_query_recognize_share", - AsyncMock(), - ) as query_mock: - result = await self.chain.async_recognize_media(meta=meta) - return result, async_run_module, report_mock, query_mock - result, async_run_module, report_mock, query_mock = asyncio.run(runner()) +def test_recognize_by_meta_can_skip_obtain_images(): + """标题识别可显式关闭图片拉取。""" + media_chain = MediaChain() + meta = MetaInfo("测试电影") + mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE) - self.assertIs(result, mediainfo) - async_run_module.assert_awaited_once() - report_mock.assert_not_awaited() - query_mock.assert_not_awaited() + with patch.object( + media_chain, + "recognize_media", + return_value=mediainfo, + ) as recognize_mock, patch.object( + media_chain, + "obtain_images", + ) as obtain_images_mock: + result = media_chain.recognize_by_meta(meta, obtain_images=False) - def test_recognize_by_meta_can_skip_obtain_images(self): - """ - 标题识别可显式关闭图片拉取。 - """ - meta = MetaInfo("测试电影") - mediainfo = MediaInfo(title="测试电影", year="2024", tmdb_id=404, type=MediaType.MOVIE) + assert result is mediainfo + recognize_mock.assert_called_once() + obtain_images_mock.assert_not_called() + +def test_recognize_by_meta_reports_with_original_keyword_after_plugin_help(): + """辅助识别后应继续使用辅助前关键字进行共享上报。""" + media_chain = MediaChain() + meta = MetaInfo("辅助前名称") + plugin_media = MediaInfo(title="辅助后名称", year="2024", tmdb_id=405, type=MediaType.TV) + + with patch.object( + media_chain, + "select_recognize_source", + side_effect=lambda **kwargs: kwargs["plugin_fn"](), + ), patch.object( + media_chain, + "recognize_help", + return_value=plugin_media, + ) as recognize_help_mock, patch.object( + media_chain, + "obtain_images", + ): + result = media_chain.recognize_by_meta(meta, obtain_images=False) + + assert result is plugin_media + assert recognize_help_mock.call_args.kwargs["share_meta"].name == "辅助前名称" + + +def test_async_recognize_by_meta_can_skip_obtain_images(): + """异步标题识别可显式关闭图片拉取。""" + media_chain = MediaChain() + meta = MetaInfo("测试异步电影") + mediainfo = MediaInfo(title="测试异步电影", year="2025", tmdb_id=406, type=MediaType.MOVIE) + + async def runner(): with patch.object( - self.media_chain, - "recognize_media", - return_value=mediainfo, + media_chain, + "async_recognize_media", + AsyncMock(return_value=mediainfo), ) as recognize_mock, patch.object( - self.media_chain, - "obtain_images", + media_chain, + "async_obtain_images", + AsyncMock(), ) as obtain_images_mock: - result = self.media_chain.recognize_by_meta(meta, obtain_images=False) + result = await media_chain.async_recognize_by_meta( + meta, + obtain_images=False, + ) + return result, recognize_mock, obtain_images_mock - self.assertIs(result, mediainfo) - recognize_mock.assert_called_once() - obtain_images_mock.assert_not_called() + result, recognize_mock, obtain_images_mock = asyncio.run(runner()) - def test_recognize_by_meta_reports_with_original_keyword_after_plugin_help(self): - """ - 辅助识别后应继续使用辅助前关键字进行共享上报。 - """ - meta = MetaInfo("辅助前名称") - plugin_media = MediaInfo(title="辅助后名称", year="2024", tmdb_id=405, type=MediaType.TV) + assert result is mediainfo + recognize_mock.assert_awaited_once() + obtain_images_mock.assert_not_called() - with patch.object( - self.media_chain, - "select_recognize_source", - side_effect=lambda **kwargs: kwargs["plugin_fn"](), - ), patch.object( - self.media_chain, - "recognize_help", - return_value=plugin_media, - ) as recognize_help_mock, patch.object( - self.media_chain, - "obtain_images", - ): - result = self.media_chain.recognize_by_meta(meta, obtain_images=False) - self.assertIs(result, plugin_media) - self.assertEqual(recognize_help_mock.call_args.kwargs["share_meta"].name, "辅助前名称") +def _music_info() -> MusicInfo: + """构造带远端身份的标准音乐信息。""" + return MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + ) - def test_async_recognize_by_meta_can_skip_obtain_images(self): - """ - 异步标题识别可显式关闭图片拉取。 - """ - meta = MetaInfo("测试异步电影") - mediainfo = MediaInfo(title="测试异步电影", year="2025", tmdb_id=406, type=MediaType.MOVIE) - async def runner(): - with patch.object( - self.media_chain, - "async_recognize_media", - AsyncMock(return_value=mediainfo), - ) as recognize_mock, patch.object( - self.media_chain, - "async_obtain_images", - AsyncMock(), - ) as obtain_images_mock: - result = await self.media_chain.async_recognize_by_meta( - meta, - obtain_images=False, - ) - return result, recognize_mock, obtain_images_mock +def test_music_report_payload_includes_source_and_media_id(): + """音乐上报载荷应携带 music 类型与数据源原生身份,其余媒体 ID 恒为 None。""" + meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003) + payload = MoviePilotServerHelper._build_recognize_report_payload( + meta=meta, + mediainfo=_music_info(), + ) - result, recognize_mock, obtain_images_mock = asyncio.run(runner()) + assert payload["keyword"] == "晴天" + assert payload["type"] == "music" + assert payload["title"] == "晴天" + assert payload["year"] == "2003" + assert payload["season"] is None + assert payload["tmdbid"] is None + assert payload["doubanid"] is None + assert payload["media_source"] == "musicbrainz" + assert payload["media_id"] == "recording-1" - self.assertIs(result, mediainfo) - recognize_mock.assert_awaited_once() - obtain_images_mock.assert_not_awaited() + +def test_music_report_payload_skips_fallback_without_remote_identity(): + """无远端身份的兜底音乐结果不应上报。""" + meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) + fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) + payload = MoviePilotServerHelper._build_recognize_report_payload( + meta=meta, + mediainfo=fallback, + ) + + assert payload is None + + +def test_music_query_params_build_for_music_type(): + """音乐查询参数应携带 music 类型、年份且不包含季。""" + meta = MetaMusic(title="晴天", year=2003) + keyword_meta = MetaMusic(title="晴天") + + params = MoviePilotServerHelper._build_recognize_query_params( + meta=meta, + mtype=MediaType.MUSIC, + keyword_meta=keyword_meta, + ) + + assert params["keyword"] == "晴天" + assert params["type"] == "music" + assert params["year"] == "2003" + assert "season" not in params + + +def test_chain_recognize_media_reports_music_share_result(): + """音乐识别成功且有远端身份时,应与影视一样走统一共享上报。""" + chain = ChainBase() + meta = MetaMusic(title="晴天", artists=["周杰伦"], year=2003) + music = _music_info() + + with patch.object(chain, "run_module", return_value=music), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=True, + ) as report_mock, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share" + ) as query_mock: + result = chain.recognize_media(meta=meta, cache=False) + + assert result is music + report_mock.assert_called_once_with(meta=meta, mediainfo=music, keyword_meta=meta) + query_mock.assert_not_called() + + +def test_chain_recognize_media_queries_music_share_when_local_failed(): + """音乐本地识别失败后应回查共享识别并按数据源原生 ID 二次识别。""" + chain = ChainBase() + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + music = _music_info() + + with patch.object( + chain, + "run_module", + side_effect=[None, music], + ) as run_module, patch( + "app.chain.MoviePilotServerHelper.query_recognize_share", + return_value={"type": "music", "media_source": "musicbrainz", "media_id": "recording-1"}, + ), patch( + "app.chain.MoviePilotServerHelper.to_recognize_params", + return_value={ + "mtype": MediaType.MUSIC, + "source": "musicbrainz", + "mediaid": "recording-1", + "tmdbid": None, + "doubanid": None, + "bangumiid": None, + "anilistid": None, + "season": None, + }, + ), patch( + "app.chain.MoviePilotServerHelper.report_recognize_share", + return_value=False, + ), patch.object( + chain, + "_update_local_recognize_cache", + ): + result = chain.recognize_media(meta=meta, cache=False) + + assert result is music + assert run_module.call_count == 2 + second_call = run_module.call_args_list[1] + assert second_call.kwargs["mtype"] == MediaType.MUSIC + assert second_call.kwargs["source"] == "musicbrainz" + assert second_call.kwargs["mediaid"] == "recording-1" + + +def test_chain_recognize_media_skips_music_report_for_fallback_result(): + """兜底无远端身份的音乐结果不上报共享识别,由载荷构建返回空保证。""" + chain = ChainBase() + meta = MetaMusic(title="未知曲目", artists=["未知艺术家"]) + fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) + + with patch.object(chain, "run_module", return_value=fallback), patch( + "app.chain.MoviePilotServerHelper.recognize_report" + ) as report_mock, patch( + "app.chain.settings.MEDIA_RECOGNIZE_SHARE", True + ): + result = chain.recognize_media(meta=meta, cache=False) + + assert result is fallback + report_mock.assert_not_called() diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 254b375eb..8c9bbc49a 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -3,7 +3,7 @@ 覆盖 MediaChain 同步/异步 ``recognize_by_meta`` 与 ``recognize_by_path`` 按 ``MetaMusic`` 路由到音乐模块,以及 MusicBrainz 模块 ``recognize_media`` / ``async_recognize_media`` 对音乐请求的详情、搜索匹配与兜底分支,ChainBase -对 MusicInfo 结果短路返回(不参与影视共享上报)。 +对 MusicInfo 结果与影视统一走共享识别上报。 """ import asyncio from unittest.mock import AsyncMock, Mock, patch @@ -148,8 +148,8 @@ def test_musicbrainz_module_async_recognize_media(monkeypatch): assert result is expected -def test_chain_recognize_media_returns_musicinfo_without_shared_report(): - """ChainBase.recognize_media 收到 MusicInfo 结果应直接返回,不触发影视共享上报。""" +def test_chain_recognize_media_returns_musicinfo_and_reports_share(): + """ChainBase.recognize_media 收到 MusicInfo 结果应与影视统一上报共享识别。""" expected = _music_info() chain = MediaChain() chain.run_module = Mock(return_value=expected) @@ -158,22 +158,25 @@ def test_chain_recognize_media_returns_musicinfo_without_shared_report(): ) as report_mock: result = chain.recognize_media(meta=MetaMusic(title="晴天")) - report_mock.assert_not_called() + report_mock.assert_called_once() + assert report_mock.call_args.kwargs["mediainfo"] is expected assert result is expected -def test_chain_async_recognize_media_returns_musicinfo_without_shared_report(): - """异步 ChainBase 收到 MusicInfo 结果应直接返回,不触发影视共享上报。""" +def test_chain_async_recognize_media_returns_musicinfo_and_reports_share(): + """异步 ChainBase 收到 MusicInfo 结果应与影视统一上报共享识别。""" expected = _music_info() chain = MediaChain() chain.async_run_module = AsyncMock(return_value=expected) with patch( - "app.helper.server.MoviePilotServerHelper.async_report_recognize_share" + "app.helper.server.MoviePilotServerHelper.async_report_recognize_share", + AsyncMock(), ) as report_mock: async def runner(): return await chain.async_recognize_media(meta=MetaMusic(title="晴天")) result = asyncio.run(runner()) - report_mock.assert_not_called() + report_mock.assert_awaited_once() + assert report_mock.call_args.kwargs["mediainfo"] is expected assert result is expected