From 1f076eba04d6e996ce71ec0f1e59696bb598de09 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 22:24:23 +0800 Subject: [PATCH] refactor: use injected eventmanager in chain instance methods --- app/chain/download.py | 2 +- app/chain/media.py | 24 ++++++------ app/chain/site.py | 6 +-- app/chain/subscribe.py | 6 +-- app/chain/transfer.py | 5 +-- tests/test_bangumi_media_type.py | 48 ++++++++++++------------ tests/test_download_chain.py | 30 +++++++++++++++ tests/test_music_plugin_recognize.py | 56 ++++++++++++++++------------ 8 files changed, 108 insertions(+), 69 deletions(-) diff --git a/app/chain/download.py b/app/chain/download.py index be1eccedd..960000600 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -961,7 +961,7 @@ class DownloadChain(ChainBase): downloader=downloader, origin=source, ) - event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data) + event = self.eventmanager.send_event(ChainEventType.ResourceSelection, event_data) if event and event.event_data: event_data = event.event_data if event_data.updated and event_data.updated_contexts is not None: diff --git a/app/chain/media.py b/app/chain/media.py index 04a42d371..c7fe9cb31 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -19,7 +19,7 @@ from app.domain.context import ( MusicArtistInfo, MusicInfo, ) -from app.runtime.events import eventmanager, Event +from app.runtime.events import Event from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath @@ -450,8 +450,8 @@ class MediaChain(ChainBase, metaclass=Singleton): - @staticmethod def select_recognize_source( + self, log_name: str, log_context: str, native_fn, plugin_fn, is_recognized=None, plugin_event: ChainEventType = ChainEventType.NameRecognize, @@ -470,7 +470,8 @@ class MediaChain(ChainBase, metaclass=Singleton): if is_recognized is None: is_recognized = lambda result: bool(result) mediainfo = None - plugin_available = eventmanager.check(plugin_event) + # 插件可用性检查走注入的事件管理器,避免链实例直连全局单例 + plugin_available = self.eventmanager.check(plugin_event) if get_chain_runtime_config_snapshot().recognize_plugin_first and plugin_available: # 插件优先 logger.info(f"插件识别优先模式已开启。请求辅助识别,标题:{log_name} ...") @@ -758,7 +759,7 @@ class MediaChain(ChainBase, metaclass=Singleton): music_type=music_type, ) # 发送请求事件,等待结果 - result: Event = eventmanager.send_event( + result: Event = self.eventmanager.send_event( ChainEventType.NameRecognize, { "title": title, @@ -820,7 +821,7 @@ class MediaChain(ChainBase, metaclass=Singleton): :param music_type: 音乐实体类型 """ # 发送音乐名称识别事件,等待插件返回标题要素 - result: Event = eventmanager.send_event( + result: Event = self.eventmanager.send_event( ChainEventType.MusicNameRecognize, { "title": title, @@ -1507,14 +1508,14 @@ class MediaChain(ChainBase, metaclass=Singleton): media_id=media_id, target_media_source=target_source, ) - event = eventmanager.send_event( + event = self.eventmanager.send_event( ChainEventType.MediaRecognizeConvert, event_data, ) return event_data.media_dict if event and event_data.media_dict else None - @staticmethod async def async_select_recognize_source( + self, log_name: str, log_context: str, native_fn, plugin_fn, is_recognized=None, plugin_event: ChainEventType = ChainEventType.NameRecognize, @@ -1532,7 +1533,8 @@ class MediaChain(ChainBase, metaclass=Singleton): if is_recognized is None: is_recognized = lambda result: bool(result) mediainfo = None - plugin_available = eventmanager.check(plugin_event) + # 插件可用性检查走注入的事件管理器,避免链实例直连全局单例 + plugin_available = self.eventmanager.check(plugin_event) if get_chain_runtime_config_snapshot().recognize_plugin_first and plugin_available: # 插件优先 logger.info(f"插件优先模式已开启。请求辅助识别,标题:{log_name} ...") @@ -1696,7 +1698,7 @@ class MediaChain(ChainBase, metaclass=Singleton): music_type=music_type, ) # 发送请求事件,等待结果 - result: Event = await eventmanager.async_send_event( + result: Event = await self.eventmanager.async_send_event( ChainEventType.NameRecognize, { "title": title, @@ -1758,7 +1760,7 @@ class MediaChain(ChainBase, metaclass=Singleton): :param music_type: 音乐实体类型 """ # 发送音乐名称识别事件,等待插件返回标题要素 - result: Event = await eventmanager.async_send_event( + result: Event = await self.eventmanager.async_send_event( ChainEventType.MusicNameRecognize, { "title": title, @@ -2053,7 +2055,7 @@ class MediaChain(ChainBase, metaclass=Singleton): media_id=media_id, target_media_source=target_source, ) - event = await eventmanager.async_send_event( + event = await self.eventmanager.async_send_event( ChainEventType.MediaRecognizeConvert, event_data, ) return event_data.media_dict if event and event_data.media_dict else None diff --git a/app/chain/site.py b/app/chain/site.py index 8f3fee9c9..050e95176 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -74,7 +74,7 @@ class SiteChain(InteractionChainMixin, ChainBase): payload=userdata.model_dump(), ) # 发送事件 - eventmanager.send_event(EventType.SiteRefreshed, { + self.eventmanager.send_event(EventType.SiteRefreshed, { "site_id": site.get("id") }) self._post_site_messages(site=site, userdata=userdata) @@ -163,7 +163,7 @@ class SiteChain(InteractionChainMixin, ChainBase): data={"total": total_num, "finished": index}, ) if any_site_updated: - eventmanager.send_event(EventType.SiteRefreshed, { + self.eventmanager.send_event(EventType.SiteRefreshed, { "site_id": "*" }) if progress_callback: @@ -461,7 +461,7 @@ class SiteChain(InteractionChainMixin, ChainBase): # 通知站点更新 if indexer: - eventmanager.send_event(EventType.SiteUpdated, { + self.eventmanager.send_event(EventType.SiteUpdated, { "domain": domain, }) if progress_callback: diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 8f96938f7..1130e04d4 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -934,7 +934,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): """同步执行提交后消息、事件和统计,异常不再触碰数据库事务。""" if context.notification: self.post_message(_SchemaMessage.model_validate(context.notification)) - eventmanager.send_event(EventType.SubscribeAdded, { + self.eventmanager.send_event(EventType.SubscribeAdded, { "subscribe_id": subscribe_id, "idempotency_key": ( f"subscribe.added:{subscribe_id}:" @@ -958,7 +958,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): await self.async_post_message( _SchemaMessage.model_validate(context.notification) ) - await eventmanager.async_send_event(EventType.SubscribeAdded, { + await self.eventmanager.async_send_event(EventType.SubscribeAdded, { "subscribe_id": subscribe_id, "idempotency_key": ( f"subscribe.added:{subscribe_id}:" @@ -3112,7 +3112,7 @@ class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): if subscribe.state == "P": return # 发送订阅完成判定事件,在写入 DB 前,允许外部据完结策略否决本次自动完成 - completion_event = eventmanager.send_event( + completion_event = self.eventmanager.send_event( ChainEventType.SubscribeCompletionCheck, SubscribeCompletionCheckEventData(subscribe=subscribe, mediainfo=mediainfo, meta=meta)) if completion_event and completion_event.event_data: diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 0544054a9..3b3e08781 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -18,7 +18,6 @@ from app.chain.storage import StorageChain from app.chain.tmdb import TmdbChain from app.runtime.config import global_vars from app.domain.context import MediaInfo, MusicInfo, TorrentInfo -from app.runtime.events import eventmanager from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfoPath @@ -1487,7 +1486,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo source_event_data = StorageOperSelectionEventData( storage=task.fileitem.storage, ) - source_event = eventmanager.send_event( + source_event = self.eventmanager.send_event( ChainEventType.StorageOperSelection, source_event_data ) # 使用事件返回的上下文数据 @@ -1503,7 +1502,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo target_event_data = StorageOperSelectionEventData( storage=task.target_storage, ) - target_event = eventmanager.send_event( + target_event = self.eventmanager.send_event( ChainEventType.StorageOperSelection, target_event_data ) # 使用事件返回的上下文数据 diff --git a/tests/test_bangumi_media_type.py b/tests/test_bangumi_media_type.py index 2a739d020..2c6b259c3 100644 --- a/tests/test_bangumi_media_type.py +++ b/tests/test_bangumi_media_type.py @@ -108,13 +108,14 @@ def test_media_identity_conversion_rejects_invalid_pair_without_plugin_handler() media_source=MediaSource.Bangumi, media_id="0", ) is None - with patch("app.chain.media.eventmanager.send_event", return_value=None): - assert MediaChain.convert_media_identity( - chain, - target_source=MediaSource.TheAudioDB, - media_source=MediaSource.Bangumi, - media_id="1", - ) is None + # 无插件处理器:注入返回空的 eventmanager 桩 + chain.eventmanager = Mock(send_event=Mock(return_value=None)) + assert MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TheAudioDB, + media_source=MediaSource.Bangumi, + media_id="1", + ) is None assert chain.tmdb_mtype is None assert chain.douban_mtype is None @@ -129,13 +130,14 @@ def test_media_identity_conversion_dispatches_plugin_source() -> None: event_data.media_dict.update(result) return Mock(event_data=event_data) - with patch("app.chain.media.eventmanager.send_event", side_effect=handle_event): - converted = MediaChain.convert_media_identity( - chain, - target_source=MediaSource.TMDB, - media_source=MediaSource("acme.video"), - media_id="custom-1", - ) + # 事件经注入的 eventmanager 发送,测试桩直接提供实例依赖 + chain.eventmanager = Mock(send_event=Mock(side_effect=handle_event)) + converted = MediaChain.convert_media_identity( + chain, + target_source=MediaSource.TMDB, + media_source=MediaSource("acme.video"), + media_id="custom-1", + ) assert converted == result @@ -211,15 +213,13 @@ def test_async_media_identity_conversion_dispatches_plugin_source() -> None: event_data.media_dict.update(result) return Mock(event_data=event_data) - with patch( - "app.chain.media.eventmanager.async_send_event", - new=AsyncMock(side_effect=handle_event), - ): - converted = asyncio.run(MediaChain.async_convert_media_identity( - chain, - target_source=MediaSource.Douban, - media_source=MediaSource("acme.video"), - media_id="custom-1", - )) + # 事件经注入的 eventmanager 发送,测试桩直接提供实例依赖 + chain.eventmanager = Mock(async_send_event=AsyncMock(side_effect=handle_event)) + converted = asyncio.run(MediaChain.async_convert_media_identity( + chain, + target_source=MediaSource.Douban, + media_source=MediaSource("acme.video"), + media_id="custom-1", + )) assert converted == result diff --git a/tests/test_download_chain.py b/tests/test_download_chain.py index 72f426e04..185de30a0 100644 --- a/tests/test_download_chain.py +++ b/tests/test_download_chain.py @@ -558,6 +558,8 @@ def test_batch_download_rejects_complete_coverage_when_files_do_not_cover_target monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") @@ -588,6 +590,8 @@ def test_batch_download_preserves_special_season_zero(monkeypatch): monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") @@ -623,6 +627,8 @@ def test_batch_download_rejects_complete_coverage_when_only_missing_episodes_mat monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") @@ -655,6 +661,8 @@ def test_batch_download_tries_next_episode_candidate_when_first_download_fails(m monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_single = MagicMock(side_effect=[None, "hash"]) first_context = _build_tv_context(episode_list=[1]) @@ -692,6 +700,8 @@ def test_batch_download_does_not_download_duplicate_movie_after_success(monkeypa monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_single = MagicMock(return_value="hash") first_context = SimpleNamespace( @@ -720,6 +730,8 @@ def test_batch_download_threads_custom_words_to_download_single(monkeypatch): monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_single = MagicMock(return_value="hash") context = SimpleNamespace( @@ -744,6 +756,8 @@ def test_batch_download_applies_custom_words_to_torrent_file_episodes(monkeypatc monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock( return_value=(b"torrent-content", "", ["A.Will.Eternal.S04E05.mkv"]), ) @@ -807,6 +821,8 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None error_msg = "添加种子任务失败:无法读取种子文件" chain.download = MagicMock(return_value=("qb", None, "Original", error_msg)) chain.post_message = MagicMock() @@ -943,6 +959,8 @@ def test_batch_download_skips_failed_subscription_resource_and_tries_next(monkey ) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_single = MagicMock(return_value="hash") downloads, lefts = chain.batch_download( @@ -965,6 +983,8 @@ def test_batch_download_accepts_complete_coverage_when_files_cover_target_range( monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") @@ -997,6 +1017,8 @@ def test_batch_download_rejects_complete_coverage_when_files_have_same_count_but monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") @@ -1029,6 +1051,8 @@ def test_batch_download_accepts_complete_coverage_when_title_episodes_cover_targ monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock() chain.download_single = MagicMock(return_value="hash") @@ -1062,6 +1086,8 @@ def test_batch_download_rejects_complete_coverage_when_title_episodes_are_partia monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock() chain.download_single = MagicMock(return_value="hash") @@ -1095,6 +1121,8 @@ def test_batch_download_complete_coverage_ignores_allowed_episode_narrowing(monk monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock() chain.download_single = MagicMock(return_value="hash") @@ -1129,6 +1157,8 @@ def test_batch_download_keeps_count_check_without_complete_coverage(monkeypatch) monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None) chain = DownloadChain.__new__(DownloadChain) + chain.eventmanager = MagicMock() + chain.eventmanager.send_event.return_value = None chain.download_torrent = MagicMock(return_value=(b"torrent-content", "", ["demo.mkv"])) chain.download_single = MagicMock(return_value="hash") diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index 41b7df94b..ac0adaa00 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -4,7 +4,7 @@ 与 ChainBase 媒体识别插件补充(MediaRecognize / MusicMediaRecognize 链式事件)。 """ import asyncio -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from app.chain import ChainBase from app.chain.media import MediaChain @@ -66,10 +66,12 @@ def test_music_recognize_help_sends_event_and_rematches(monkeypatch): "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, media_source="musicbrainz") + # 事件经注入的 eventmanager 发送,直接替换实例依赖并保留断言能力 + em = MagicMock() + em.check.return_value = True + em.send_event.return_value = event + monkeypatch.setattr(chain, "eventmanager", em) + result = chain.recognize_by_meta(meta, media_source="musicbrainz") assert result is remote assert em.check.call_args.args[0] == ChainEventType.MusicNameRecognize @@ -101,9 +103,10 @@ def test_music_recognize_keeps_fallback_without_plugin(monkeypatch): 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) + em = MagicMock() + em.check.return_value = False + monkeypatch.setattr(chain, "eventmanager", em) + result = chain.recognize_by_meta(meta) assert result is fallback em.send_event.assert_not_called() @@ -122,10 +125,11 @@ def test_music_recognize_help_same_elements_keeps_fallback(monkeypatch): "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) + em = MagicMock() + em.check.return_value = True + em.send_event.return_value = event + monkeypatch.setattr(chain, "eventmanager", em) + result = chain.recognize_by_meta(meta) assert result is fallback assert recognize_mock.call_count == 1 @@ -143,10 +147,11 @@ def test_music_recognize_help_keeps_fallback_when_rematch_fails(monkeypatch): "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) + em = MagicMock() + em.check.return_value = True + em.send_event.return_value = event + monkeypatch.setattr(chain, "eventmanager", em) + result = chain.recognize_by_meta(meta) assert result is fallback @@ -169,10 +174,12 @@ def test_async_music_recognize_help(monkeypatch): "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)) + # 事件经注入的 eventmanager 发送,直接替换实例依赖并保留断言能力 + em = MagicMock() + em.check.return_value = True + em.async_send_event = AsyncMock(return_value=event) + monkeypatch.setattr(chain, "eventmanager", em) + result = asyncio.run(chain.async_recognize_by_meta(meta)) assert result is remote assert recognize_calls[-1].title == "晴天" @@ -193,10 +200,11 @@ def test_plugin_first_keeps_fallback_when_help_unidentified(monkeypatch): "title": "晴天", "name": "另一个晴天", }) - with patch("app.chain.media.eventmanager") as em, \ - patch("app.runtime.config.settings.RECOGNIZE_PLUGIN_FIRST", True): - em.check.return_value = True - em.send_event.return_value = event + em = MagicMock() + em.check.return_value = True + em.send_event.return_value = event + monkeypatch.setattr(chain, "eventmanager", em) + with patch("app.runtime.config.settings.RECOGNIZE_PLUGIN_FIRST", True): result = chain.recognize_by_meta(meta) assert result is fallback