diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 1fb9d483b..bb9dd08fc 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -14,9 +14,9 @@ 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, SubtitleInfo, TorrentInfo -from app.core.event import EventManager -from app.core.meta import MetaBase +from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo +from app.core.event import Event, EventManager +from app.core.meta import MetaBase, MetaMusic from app.core.module import ModuleManager from app.core.plugin import PluginManager from app.db.message_oper import MessageOper @@ -49,6 +49,7 @@ from app.schemas.types import ( MediaType, MediaImageType, EventType, + ChainEventType, MessageChannel, SystemConfigKey, ) @@ -704,6 +705,11 @@ class ChainBase(metaclass=ABCMeta): "recognize_media", **module_kwargs, ) + # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) + mediainfo = self._supplement_media_recognize( + meta=meta, mtype=mtype, source=source, + mediaid=requested_mediaid, mediainfo=mediainfo, + ) if mediainfo: # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID if not getattr(mediainfo, "recognize_cache_hit", False): @@ -815,6 +821,11 @@ class ChainBase(metaclass=ABCMeta): "async_recognize_media", **module_kwargs, ) + # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) + mediainfo = await self._async_supplement_media_recognize( + meta=meta, mtype=mtype, source=source, + mediaid=requested_mediaid, mediainfo=mediainfo, + ) if mediainfo: # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID if not getattr(mediainfo, "recognize_cache_hit", False): @@ -856,6 +867,163 @@ class ChainBase(metaclass=ABCMeta): return mediainfo return None + @staticmethod + def _media_recognize_plugin_payload( + meta: Optional[MetaBase], + mtype: Optional[MediaType], + source: Optional[str], + mediaid: Optional[str], + is_music: bool, + ) -> dict: + """ + 构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议, + 仅要素字段随媒体类型不同 + """ + if is_music: + return { + "title": getattr(meta, "title", None), + "artists": list(getattr(meta, "artists", None) or []), + "album": getattr(meta, "album", None), + "year": getattr(meta, "year", None), + "isrc": getattr(meta, "isrc", None), + "source": source, + "media_id": mediaid, + } + return { + "title": getattr(meta, "title", None) or getattr(meta, "name", None), + "year": getattr(meta, "year", None), + "season": getattr(meta, "begin_season", None), + "type": mtype.value if isinstance(mtype, MediaType) else None, + "source": source, + "media_id": mediaid, + } + + @classmethod + def _media_info_from_plugin( + cls, + event_data: dict, + is_music: bool, + mtype: Optional[MediaType] = None, + ) -> Optional[MediaInfo]: + """ + 解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信; + 音乐构造 MusicInfo,影视构造 MediaInfo + """ + if not isinstance(event_data, dict): + return None + plugin_info = event_data.get("mediainfo") + if not isinstance(plugin_info, dict): + return None + if not plugin_info.get("source"): + logger.warn("插件返回的媒体信息缺少数据源,忽略 ...") + return None + try: + if is_music: + if not plugin_info.get("media_id"): + logger.warn("插件返回的音乐媒体信息缺少媒体ID,忽略 ...") + return None + info: MediaInfo = MusicInfo.from_dict(plugin_info) + if not info.source or not info.media_id: + return None + return info + # 影视:插件未提供类型时使用请求推断的类型 + if not plugin_info.get("type") and mtype: + plugin_info = {**plugin_info, "type": mtype} + info = MediaInfo() + info.from_dict(plugin_info) + except Exception as err: + logger.warn(f"插件返回的媒体信息格式错误:{err}") + return None + # 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线 + if not info.source or not cls._media_info_has_identity(info): + logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...") + return None + return info + + @staticmethod + def _media_info_has_identity(mediainfo) -> bool: + """判断媒体信息是否具备远端身份(数据源原生 ID 或各元数据源 ID)""" + return bool( + getattr(mediainfo, "media_id", None) + or getattr(mediainfo, "tmdb_id", None) + or getattr(mediainfo, "douban_id", None) + or getattr(mediainfo, "bangumi_id", None) + or getattr(mediainfo, "anilist_id", None) + ) + + def _supplement_media_recognize( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType], + source: Optional[str], + mediaid: Optional[str], + mediainfo, + ): + """ + 媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时, + 广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息 + """ + is_music = ( + isinstance(meta, MetaMusic) + or mtype == MediaType.MUSIC + or isinstance(mediainfo, MusicInfo) + ) + # 已有远端身份时无需插件介入 + if mediainfo and self._media_info_has_identity(mediainfo): + return mediainfo + etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize + if not self.eventmanager.check(etype): + return mediainfo + result: Event = self.eventmanager.send_event( + etype, + self._media_recognize_plugin_payload(meta, mtype, source, mediaid, is_music), + ) + if not result: + return mediainfo + plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype) + if not plugin_info: + return mediainfo + logger.info( + f"插件补充媒体识别成功:{plugin_info.title}" + f"({plugin_info.source}:{plugin_info.media_id or plugin_info.tmdb_id or plugin_info.douban_id})" + ) + return plugin_info + + async def _async_supplement_media_recognize( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType], + source: Optional[str], + mediaid: Optional[str], + mediainfo, + ): + """媒体识别插件补充的异步版本,影视与音乐统一流程""" + is_music = ( + isinstance(meta, MetaMusic) + or mtype == MediaType.MUSIC + or isinstance(mediainfo, MusicInfo) + ) + # 已有远端身份时无需插件介入 + if mediainfo and self._media_info_has_identity(mediainfo): + return mediainfo + etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize + if not self.eventmanager.check(etype): + return mediainfo + result: Event = await self.eventmanager.async_send_event( + etype, + self._media_recognize_plugin_payload(meta, mtype, source, mediaid, is_music), + ) + if not result: + return mediainfo + plugin_info = self._media_info_from_plugin(result.event_data or {}, is_music, mtype) + if not plugin_info: + return mediainfo + logger.info( + f"插件补充媒体识别成功:{plugin_info.title}" + f"({plugin_info.source}:{plugin_info.media_id or plugin_info.tmdb_id or plugin_info.douban_id})" + ) + return plugin_info + def match_doubaninfo( self, name: str, diff --git a/app/chain/media.py b/app/chain/media.py index 71dc64c6b..111ba97e0 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -587,7 +587,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): @staticmethod def select_recognize_source( - log_name: str, log_context: str, native_fn, plugin_fn + log_name: str, log_context: str, native_fn, plugin_fn, + is_recognized=None, + plugin_event: ChainEventType = ChainEventType.NameRecognize, ) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先 @@ -596,27 +598,39 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 + :param is_recognized: 判定识别结果是否有效的谓词;音乐原生兜底结果无远端身份, + 需视为未识别才会请求辅助识别,影视默认按非空判定 + :param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件 """ + if is_recognized is None: + is_recognized = lambda result: bool(result) mediainfo = None - plugin_available = eventmanager.check(ChainEventType.NameRecognize) + plugin_available = eventmanager.check(plugin_event) if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: # 插件优先 logger.info(f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ...") - mediainfo = plugin_fn() - if not mediainfo: + helped = plugin_fn() + if is_recognized(helped): + mediainfo = helped + else: logger.info( f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别 ..." ) mediainfo = native_fn() + # 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空) + if helped and not mediainfo: + mediainfo = helped else: # 原生优先 logger.info(f"开始识别标题:{log_name} ...") mediainfo = native_fn() - if not mediainfo and plugin_available: + if not is_recognized(mediainfo) and plugin_available: logger.info( - f"未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..." + f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别 ..." ) - mediainfo = plugin_fn() + helped = plugin_fn() + if is_recognized(helped): + mediainfo = helped return mediainfo def recognize_by_meta( @@ -771,14 +785,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ if not metainfo: return None - # 音乐不经影视季集识别与辅助识别,直接走统一模块分发 - if isinstance(metainfo, MetaMusic): - return self.recognize_media( - meta=metainfo, - source=source, - ) title = metainfo.title share_meta = deepcopy(metainfo) + # 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别 + is_music = isinstance(metainfo, MetaMusic) + is_recognized = ( + (lambda result: bool(result and result.source)) if is_music else None + ) def native_recognize() -> Optional[MediaInfo]: """使用请求级数据源执行原生识别。""" @@ -799,12 +812,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group=episode_group, ) - # 按 config 中设置的识别顺序识别 + # 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程 mediainfo = self.select_recognize_source( log_name=title, log_context=title, native_fn=native_recognize, plugin_fn=plugin_recognize, + is_recognized=is_recognized, + plugin_event=( + ChainEventType.MusicNameRecognize if is_music + else ChainEventType.NameRecognize + ), ) if not mediainfo: return None @@ -835,7 +853,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group: Optional[str] = None, ) -> Optional[MediaInfo]: """ - 请求辅助识别,返回媒体信息 + 请求辅助识别,返回媒体信息;影视与音乐共用同一流程,仅要素事件与重组方式不同 :param title: 标题 :param org_meta: 原始元数据 @@ -843,6 +861,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param source: 请求级识别数据源 :param episode_group: 剧集组 """ + # 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件 + if isinstance(org_meta, MetaMusic): + return self._recognize_music_help( + title=title, + org_meta=org_meta, + share_meta=share_meta, + source=source, + ) # 发送请求事件,等待结果 result: Event = eventmanager.send_event( ChainEventType.NameRecognize, @@ -888,6 +914,89 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group=episode_group, ) + def _recognize_music_help( + self, + title: str, + org_meta: MetaMusic, + share_meta: MetaBase = None, + source: Optional[str] = None, + ) -> Optional[MediaInfo]: + """ + 请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息 + + :param title: 原始音乐标题 + :param org_meta: 原始音乐元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param source: 请求级识别数据源 + """ + # 发送音乐名称识别事件,等待插件返回标题要素 + result: Event = eventmanager.send_event( + ChainEventType.MusicNameRecognize, + { + "title": title, + "artist": org_meta.artist, + "album": org_meta.album, + "year": org_meta.year, + }, + ) + if not result: + return None + event_data = result.event_data or {} + logger.info(f"获取到音乐辅助识别结果:{event_data}") + name, artist, album, year = self._parse_music_recognize_event(event_data) + if not name: + return None + # 辅助识别要素与原始一致时无需重新匹配 + if ( + name == org_meta.title + and (not artist or artist in org_meta.artists) + and (not album or album == org_meta.album) + and (not year or year == org_meta.year) + ): + logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息") + return None + logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") + new_meta = MetaMusic( + org_string=org_meta.org_string, + title=name, + artists=[artist] if artist else list(org_meta.artists or []), + album=album or org_meta.album, + album_artist=artist or org_meta.album_artist, + year=year or org_meta.year, + isrc=org_meta.isrc, + ) + # 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底 + mediainfo = self.recognize_media( + meta=new_meta, + source=source, + share_meta=share_meta, + ) + return mediainfo if mediainfo and mediainfo.source else None + + @staticmethod + def _parse_music_recognize_event( + event_data: dict, + ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[int]]: + """ + 解析音乐辅助识别返回的标题要素,曲名为空或未知时返回 None + """ + name = None + if event_data.get("name"): + name = str(event_data["name"]).split("/")[0].strip().replace(".", " ") + artist = None + if event_data.get("artist"): + artist = str(event_data["artist"]).split("/")[0].strip() + album = None + if event_data.get("album"): + album = str(event_data["album"]).split("/")[0].strip() + year = None + year_text = str(event_data.get("year") or "").split("/")[0].strip() + if year_text.isdigit(): + year = int(year_text) + if not name or name == "Unknown": + name = None + return name, artist, album, year + def recognize_by_path( self, path: str, @@ -2215,7 +2324,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): @staticmethod async def async_select_recognize_source( - log_name: str, log_context: str, native_fn, plugin_fn + log_name: str, log_context: str, native_fn, plugin_fn, + is_recognized=None, + plugin_event: ChainEventType = ChainEventType.NameRecognize, ) -> Optional[MediaInfo]: """ 选择识别模式,插件优先或原生优先(异步版本) @@ -2224,27 +2335,38 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param log_context: 用于日志“未识别到...的媒体信息”处的上下文(如 path 或 title) :param native_fn: 原生识别函数 :param plugin_fn: 插件识别函数 + :param is_recognized: 判定识别结果是否有效的谓词,语义同同步版本 + :param plugin_event: 辅助识别对应的链式事件类型,音乐使用音乐名称识别事件 """ + if is_recognized is None: + is_recognized = lambda result: bool(result) mediainfo = None - plugin_available = eventmanager.check(ChainEventType.NameRecognize) + plugin_available = eventmanager.check(plugin_event) if settings.RECOGNIZE_PLUGIN_FIRST and plugin_available: # 插件优先 logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...") - mediainfo = await plugin_fn() - if not mediainfo: + helped = await plugin_fn() + if is_recognized(helped): + mediainfo = helped + else: logger.info( f"辅助识别未识别到 {log_context} 的媒体信息,尝试使用原生识别" ) mediainfo = await native_fn() + # 辅助结果不采信时保留原生兜底,避免丢失已有识别结果(音乐原生兜底恒非空) + if helped and not mediainfo: + mediainfo = helped else: # 原生优先 logger.info(f"识别标题:{log_name} ...") mediainfo = await native_fn() - if not mediainfo and plugin_available: + if not is_recognized(mediainfo) and plugin_available: logger.info( f"原生识别未识别到 {log_context} 的媒体信息,尝试使用辅助识别" ) - mediainfo = await plugin_fn() + helped = await plugin_fn() + if is_recognized(helped): + mediainfo = helped return mediainfo async def async_recognize_by_meta( @@ -2291,14 +2413,13 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ if not metainfo: return None - # 音乐不经影视季集识别与辅助识别,直接走统一模块分发 - if isinstance(metainfo, MetaMusic): - return await self.async_recognize_media( - meta=metainfo, - source=source, - ) title = metainfo.title share_meta = deepcopy(metainfo) + # 音乐原生兜底结果无远端身份,需按是否取得身份判定,才会请求辅助识别 + is_music = isinstance(metainfo, MetaMusic) + is_recognized = ( + (lambda result: bool(result and result.source)) if is_music else None + ) async def native_recognize() -> Optional[MediaInfo]: """异步使用请求级数据源执行原生识别。""" @@ -2319,12 +2440,17 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group=episode_group, ) - # 按 config 中设置的识别顺序识别 + # 按 config 中设置的识别顺序识别,影视与音乐共用同一选择流程 mediainfo = await self.async_select_recognize_source( log_name=title, log_context=title, native_fn=native_recognize, plugin_fn=plugin_recognize, + is_recognized=is_recognized, + plugin_event=( + ChainEventType.MusicNameRecognize if is_music + else ChainEventType.NameRecognize + ), ) if not mediainfo: return None @@ -2344,7 +2470,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group: Optional[str] = None, ) -> Optional[MediaInfo]: """ - 请求辅助识别,返回媒体信息(异步版本) + 请求辅助识别,返回媒体信息(异步版本);影视与音乐共用同一流程 :param title: 标题 :param org_meta: 原始元数据 @@ -2352,6 +2478,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param source: 请求级识别数据源 :param episode_group: 剧集组 """ + # 音乐标题要素(曲名/艺术家/专辑/年份)与影视不同,走专用名称识别事件 + if isinstance(org_meta, MetaMusic): + return await self._async_recognize_music_help( + title=title, + org_meta=org_meta, + share_meta=share_meta, + source=source, + ) # 发送请求事件,等待结果 result: Event = await eventmanager.async_send_event( ChainEventType.NameRecognize, @@ -2397,6 +2531,65 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): episode_group=episode_group, ) + async def _async_recognize_music_help( + self, + title: str, + org_meta: MetaMusic, + share_meta: MetaBase = None, + source: Optional[str] = None, + ) -> Optional[MediaInfo]: + """ + 请求插件辅助识别音乐标题要素,并按修正后的要素重新匹配媒体信息(异步版本) + + :param title: 原始音乐标题 + :param org_meta: 原始音乐元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param source: 请求级识别数据源 + """ + # 发送音乐名称识别事件,等待插件返回标题要素 + result: Event = await eventmanager.async_send_event( + ChainEventType.MusicNameRecognize, + { + "title": title, + "artist": org_meta.artist, + "album": org_meta.album, + "year": org_meta.year, + }, + ) + if not result: + return None + event_data = result.event_data or {} + logger.info(f"获取到音乐辅助识别结果:{event_data}") + name, artist, album, year = self._parse_music_recognize_event(event_data) + if not name: + return None + # 辅助识别要素与原始一致时无需重新匹配 + if ( + name == org_meta.title + and (not artist or artist in org_meta.artists) + and (not album or album == org_meta.album) + and (not year or year == org_meta.year) + ): + logger.info("音乐辅助识别与原始识别结果一致,无需重新匹配媒体信息") + return None + logger.info("音乐辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...") + new_meta = MetaMusic( + org_string=org_meta.org_string, + title=name, + artists=[artist] if artist else list(org_meta.artists or []), + album=album or org_meta.album, + album_artist=artist or org_meta.album_artist, + year=year or org_meta.year, + isrc=org_meta.isrc, + ) + # 重新识别,仅采信取得远端身份的结果,否则由选择流程保留原生兜底 + mediainfo = await self.async_recognize_media( + meta=new_meta, + source=source, + share_meta=share_meta, + ) + return mediainfo if mediainfo and mediainfo.source else None + async def async_recognize_by_path( self, path: str, diff --git a/app/schemas/types.py b/app/schemas/types.py index c456aa73d..b5f7b76f6 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -181,6 +181,12 @@ class ChainEventType(Enum): PluginDataReset = "plugin.data.reset" # 名称识别 NameRecognize = "name.recognize" + # 音乐名称识别:插件辅助解析音乐标题中的曲名、艺术家、专辑、年份要素 + MusicNameRecognize = "music.name.recognize" + # 媒体识别:原生识别未取得远端身份时,插件按已知要素匹配补充电影、电视剧媒体信息 + MediaRecognize = "media.recognize" + # 音乐媒体识别:原生识别未取得远端身份时,插件按已知要素匹配补充音乐媒体信息 + MusicMediaRecognize = "music.media.recognize" # 认证验证 AuthVerification = "auth.verification" # 认证拦截 diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py index 50b380287..0d36e69a2 100644 --- a/tests/test_media_recognize_share_statistics.py +++ b/tests/test_media_recognize_share_statistics.py @@ -40,9 +40,16 @@ def _mock_counter(monkeypatch) -> Mock: return increment +def _bare_chain() -> ChainBase: + """构造不执行初始化的识别链实例,并挂上无插件响应的事件管理器桩。""" + chain = object.__new__(ChainBase) + chain.eventmanager = Mock(check=Mock(return_value=False)) + return chain + + def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): """同步共享识别二次识别成功后应累计一次命中。""" - chain = object.__new__(ChainBase) + chain = _bare_chain() meta = _build_meta("共享识别电影") media = MediaInfo( title="共享识别电影", @@ -73,7 +80,7 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): """共享接口返回数据但二次识别失败时不应累计命中。""" - chain = object.__new__(ChainBase) + chain = _bare_chain() meta = _build_meta("共享识别失败电影") increment = _mock_counter(monkeypatch) monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) @@ -97,7 +104,7 @@ def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): """异步共享识别二次识别成功后应累计一次命中。""" - chain = object.__new__(ChainBase) + chain = _bare_chain() meta = _build_meta("异步共享识别电影") media = MediaInfo( title="异步共享识别电影", diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py new file mode 100644 index 000000000..a68309937 --- /dev/null +++ b/tests/test_music_plugin_recognize.py @@ -0,0 +1,324 @@ +"""媒体识别插件辅助测试(影视与音乐统一链路)。 + +覆盖 MediaChain 标题要素插件辅助识别(NameRecognize / MusicNameRecognize 链式事件) +与 ChainBase 媒体识别插件补充(MediaRecognize / MusicMediaRecognize 链式事件)。 +""" +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +from app.chain import ChainBase +from app.chain.media import MediaChain +from app.core.context import MediaInfo, MusicInfo +from app.core.event import Event +from app.core.meta import MetaBase, MetaMusic +from app.schemas.types import ChainEventType, MediaType + + +def _fallback_music(title: str = "晴天", **kwargs) -> MusicInfo: + """构造无远端身份的离线兜底音乐结果。""" + return MusicInfo(title=title, **kwargs) + + +def _remote_music() -> MusicInfo: + """构造带远端身份的标准音乐识别结果。""" + return MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + ) + + +def test_music_recognize_help_sends_event_and_rematches(monkeypatch): + """原生识别无远端身份时应发送音乐名称识别事件,并按修正要素重新匹配。""" + chain = MediaChain() + meta = MetaMusic(title="周杰伦 晴天 FLAC 24bit 48kHz") + remote = _remote_music() + recognize_calls = [] + + def fake_recognize_media(meta=None, source=None, **kwargs): + recognize_calls.append(meta) + # 首次返回无身份兜底,辅助识别修正要素后二次识别命中远端 + return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title) + + monkeypatch.setattr(chain, "recognize_media", fake_recognize_media) + + event = Event(ChainEventType.MusicNameRecognize, { + "title": meta.title, + "name": "晴天", + "artist": "周杰伦", + "album": "叶惠美", + "year": "2003", + }) + with patch("app.chain.media.eventmanager") as em: + em.check.return_value = True + em.send_event.return_value = event + result = chain.recognize_by_meta(meta, source="musicbrainz") + + assert result is remote + assert em.check.call_args.args[0] == ChainEventType.MusicNameRecognize + assert em.send_event.call_args.args[0] == ChainEventType.MusicNameRecognize + # 重新匹配使用辅助识别修正后的要素 + rematch_meta = recognize_calls[-1] + assert rematch_meta.title == "晴天" + assert rematch_meta.artists == ["周杰伦"] + assert rematch_meta.album == "叶惠美" + assert rematch_meta.year == 2003 + + +def test_music_recognize_keeps_fallback_without_plugin(monkeypatch): + """无插件响应音乐名称识别事件时应保留原生兜底结果。""" + chain = MediaChain() + meta = MetaMusic(title="未知曲目") + fallback = _fallback_music(title="未知曲目") + monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback)) + + with patch("app.chain.media.eventmanager") as em: + em.check.return_value = False + result = chain.recognize_by_meta(meta) + + assert result is fallback + em.send_event.assert_not_called() + + +def test_music_recognize_help_same_elements_keeps_fallback(monkeypatch): + """辅助识别要素与原始一致时不应重新识别。""" + chain = MediaChain() + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + fallback = _fallback_music(title="晴天", artists=["周杰伦"]) + recognize_mock = Mock(return_value=fallback) + monkeypatch.setattr(chain, "recognize_media", recognize_mock) + + event = Event(ChainEventType.MusicNameRecognize, { + "title": "晴天", + "name": "晴天", + "artist": "周杰伦", + }) + with patch("app.chain.media.eventmanager") as em: + em.check.return_value = True + em.send_event.return_value = event + result = chain.recognize_by_meta(meta) + + assert result is fallback + assert recognize_mock.call_count == 1 + + +def test_music_recognize_help_keeps_fallback_when_rematch_fails(monkeypatch): + """辅助要素重新匹配仍无远端身份时应保留原生兜底结果。""" + chain = MediaChain() + meta = MetaMusic(title="晴天") + fallback = _fallback_music(title="晴天") + monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback)) + + event = Event(ChainEventType.MusicNameRecognize, { + "title": "晴天", + "name": "另一个晴天", + "artist": "未知艺术家", + }) + with patch("app.chain.media.eventmanager") as em: + em.check.return_value = True + em.send_event.return_value = event + result = chain.recognize_by_meta(meta) + + assert result is fallback + + +def test_async_music_recognize_help(monkeypatch): + """异步音乐识别同样应走插件辅助识别并重匹配。""" + chain = MediaChain() + meta = MetaMusic(title="周杰伦-晴天") + remote = _remote_music() + recognize_calls = [] + + async def fake_async_recognize_media(meta=None, source=None, **kwargs): + recognize_calls.append(meta) + return remote if len(recognize_calls) > 1 else _fallback_music(title=meta.title) + + monkeypatch.setattr(chain, "async_recognize_media", fake_async_recognize_media) + + event = Event(ChainEventType.MusicNameRecognize, { + "title": meta.title, + "name": "晴天", + "artist": "周杰伦", + }) + with patch("app.chain.media.eventmanager") as em: + em.check.return_value = True + em.async_send_event = AsyncMock(return_value=event) + result = asyncio.run(chain.async_recognize_by_meta(meta)) + + assert result is remote + assert recognize_calls[-1].title == "晴天" + assert recognize_calls[-1].artists == ["周杰伦"] + + +def test_plugin_first_keeps_fallback_when_help_unidentified(monkeypatch): + """插件优先模式下辅助识别未取得身份时,应回退原生识别并保留已有兜底结果。""" + chain = MediaChain() + meta = MetaMusic(title="晴天") + fallback = _fallback_music(title="晴天") + # 辅助识别重匹配仍无身份返回 None,原生兜底不应被丢弃 + monkeypatch.setattr(chain, "recognize_media", Mock(return_value=fallback)) + + event = Event(ChainEventType.MusicNameRecognize, { + "title": "晴天", + "name": "另一个晴天", + }) + with patch("app.chain.media.eventmanager") as em, \ + patch("app.chain.media.settings") as settings_mock: + settings_mock.RECOGNIZE_PLUGIN_FIRST = True + em.check.return_value = True + em.send_event.return_value = event + result = chain.recognize_by_meta(meta) + + assert result is fallback + + +def test_chain_supplement_music_recognize_uses_plugin_result(): + """音乐媒体识别事件应允许插件按已知要素返回标准音乐信息。""" + chain = ChainBase() + meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美") + plugin_data = { + "mediainfo": { + "source": "qqmusic", + "media_id": "song-123", + "title": "晴天", + "artists": ["周杰伦"], + "album": "叶惠美", + "year": 2003, + } + } + event = Event(ChainEventType.MusicMediaRecognize, plugin_data) + with patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "send_event", return_value=event) as sender: + result = chain._supplement_media_recognize( + meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None + ) + + assert isinstance(result, MusicInfo) + assert result.source == "qqmusic" + assert result.media_id == "song-123" + # 音乐请求使用音乐媒体识别事件,载荷携带已知要素 + assert sender.call_args.args[0] == ChainEventType.MusicMediaRecognize + payload = sender.call_args.args[1] + assert payload["title"] == "晴天" + assert payload["artists"] == ["周杰伦"] + assert payload["album"] == "叶惠美" + + +def test_chain_supplement_video_recognize_uses_plugin_result(): + """影视媒体识别事件应与音乐对称,允许插件按已知要素返回标准媒体信息。""" + chain = ChainBase() + meta = MetaBase("The.Matrix.1999.1080p.BluRay") + meta.type = MediaType.MOVIE + plugin_data = { + "mediainfo": { + "source": "themoviedb", + "tmdb_id": 603, + "title": "黑客帝国", + "year": "1999", + } + } + event = Event(ChainEventType.MediaRecognize, plugin_data) + with patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "send_event", return_value=event) as sender: + result = chain._supplement_media_recognize( + meta=meta, mtype=MediaType.MOVIE, source=None, mediaid=None, mediainfo=None + ) + + assert isinstance(result, MediaInfo) + assert result.tmdb_id == 603 + # 影视请求使用媒体识别事件,插件未提供类型时使用请求推断类型 + assert sender.call_args.args[0] == ChainEventType.MediaRecognize + assert result.type == MediaType.MOVIE + + +def test_chain_supplement_media_recognize_requires_identity(): + """插件返回缺少数据源或远端身份的结果不采信,影视音乐统一。""" + chain = ChainBase() + # 音乐缺媒体ID + meta = MetaMusic(title="晴天") + fallback = _fallback_music(title="晴天") + event = Event(ChainEventType.MusicMediaRecognize, { + "mediainfo": {"source": "qqmusic", "title": "晴天"}, + }) + with patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "send_event", return_value=event): + result = chain._supplement_media_recognize( + meta=meta, mtype=None, source=None, mediaid=None, mediainfo=fallback + ) + assert result is fallback + + # 影视缺远端身份 + meta_video = MetaBase("Some.Movie") + meta_video.type = MediaType.MOVIE + event_video = Event(ChainEventType.MediaRecognize, { + "mediainfo": {"source": "themoviedb", "title": "某部电影"}, + }) + with patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "send_event", return_value=event_video): + result = chain._supplement_media_recognize( + meta=meta_video, mtype=MediaType.MOVIE, + source=None, mediaid=None, mediainfo=None, + ) + assert result is None + + +def test_chain_supplement_media_recognize_skips_identified_result(): + """已有远端身份的结果不应触发插件补充事件。""" + chain = ChainBase() + meta = MetaMusic(title="晴天") + remote = _remote_music() + with patch.object(chain.eventmanager, "check") as checker: + result = chain._supplement_media_recognize( + meta=meta, mtype=None, source=None, mediaid=None, mediainfo=remote + ) + + assert result is remote + checker.assert_not_called() + + +def test_chain_recognize_media_music_plugin_supplement(): + """统一识别入口应在原生音乐识别无身份时采信插件补充结果并统一上报。""" + chain = ChainBase() + meta = MetaMusic(title="晴天", artists=["周杰伦"]) + fallback = _fallback_music(title="晴天") + plugin_music = MusicInfo( + source="qqmusic", + media_id="song-123", + title="晴天", + artists=["周杰伦"], + ) + event = Event(ChainEventType.MusicMediaRecognize, {"mediainfo": plugin_music.to_dict()}) + + with patch.object(chain, "run_module", return_value=fallback), \ + patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "send_event", return_value=event), \ + patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: + result = chain.recognize_media(meta=meta, cache=False) + + assert result is not fallback + assert result.source == "qqmusic" + report_mock.assert_called_once() + assert report_mock.call_args.kwargs["mediainfo"] is result + + +def test_chain_async_supplement_media_recognize(): + """异步媒体识别补充应与同步行为一致(音乐)。""" + chain = ChainBase() + meta = MetaMusic(title="晴天") + event = Event(ChainEventType.MusicMediaRecognize, { + "mediainfo": {"source": "qqmusic", "media_id": "song-1", "title": "晴天"}, + }) + with patch.object(chain.eventmanager, "check", return_value=True), \ + patch.object(chain.eventmanager, "async_send_event", AsyncMock(return_value=event)): + result = asyncio.run( + chain._async_supplement_media_recognize( + meta=meta, mtype=None, source=None, mediaid=None, mediainfo=None + ) + ) + + assert isinstance(result, MusicInfo) + assert result.source == "qqmusic" + assert result.media_id == "song-1" diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 0d6dfd9c0..21e16b416 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -29,7 +29,7 @@ def _music_info() -> MusicInfo: def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch): - """MetaMusic 应绕过影视识别,由统一模块分发直接响应。""" + """MetaMusic 应与影视共用选择流程,由统一模块分发直接响应。""" meta = MetaMusic(title="晴天", artists=["周杰伦"]) expected = _music_info() chain = MediaChain() @@ -37,7 +37,11 @@ def test_media_chain_recognize_by_meta_routes_metamusic_to_module(monkeypatch): result = chain.recognize_by_meta(meta, source="musicbrainz") - chain.recognize_media.assert_called_once_with(meta=meta, source="musicbrainz") + # 音乐不再旁路辅助识别选择流程,原生识别带共享元数据与剧集组参数 + chain.recognize_media.assert_called_once() + call_kwargs = chain.recognize_media.call_args.kwargs + assert call_kwargs["meta"] is meta + assert call_kwargs["source"] == "musicbrainz" assert result is expected @@ -52,7 +56,10 @@ def test_media_chain_async_recognize_by_meta_routes_metamusic_to_module(monkeypa return await chain.async_recognize_by_meta(meta, source="musicbrainz") result = asyncio.run(runner()) - chain.async_recognize_media.assert_awaited_once_with(meta=meta, source="musicbrainz") + chain.async_recognize_media.assert_awaited_once() + call_kwargs = chain.async_recognize_media.await_args.kwargs + assert call_kwargs["meta"] is meta + assert call_kwargs["source"] == "musicbrainz" assert result is expected