From 26a3b1cda4b109dff5e72af37fb3d37ba7840bd0 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 30 Aug 2026 12:07:04 +0800 Subject: [PATCH] refactor(startup): centralize chain external ports --- app/startup/composition/chain.py | 153 ++++++++++++++++++- app/startup/initializers/chain.py | 155 +------------------- docs/rules/05-architecture.md | 6 +- tests/test_architecture_dependencies.py | 38 +++++ tests/test_chain_external_ports.py | 7 +- tests/test_episode_group_recognition.py | 4 +- tests/test_media_recognize_share.py | 60 ++++---- tests/test_media_source_routing.py | 4 +- tests/test_music_plugin_recognize.py | 2 +- tests/test_music_subscribe.py | 6 +- tests/test_music_workflows.py | 6 +- tests/test_recognize_source_selection.py | 8 +- tests/test_subscribe_chain.py | 4 +- tests/test_transfer_mounted_disk_cleanup.py | 10 +- 14 files changed, 258 insertions(+), 205 deletions(-) diff --git a/app/startup/composition/chain.py b/app/startup/composition/chain.py index 6ce403637..5e39a5a7b 100644 --- a/app/startup/composition/chain.py +++ b/app/startup/composition/chain.py @@ -1,8 +1,12 @@ """Chain 无参兼容入口与跨层依赖的宿主组合根。""" +from collections.abc import Mapping from functools import partial -from typing import Any, Callable +from pathlib import Path +from typing import Any, Callable, Optional, cast +from app.adapters.external.server import MoviePilotServerHelper +from app.adapters.system.host import SystemUtils from app.application.chain.context import ( ChainRuntimeContext, configure_chain_runtime_context_provider, @@ -13,6 +17,21 @@ from app.application.image import ( reset_wallpaper_providers, ) from app.chain.mediaserver import MediaServerChain +from app.chain._recognition import ( + RecognitionSharePort, + configure_recognition_share_port, + reset_recognition_share_port, +) +from app.chain.subscribe.notify import ( + SubscriptionSharePort, + configure_subscription_share_port, + reset_subscription_share_port, +) +from app.chain.transfer.filter import ( + NetworkFilesystemPort, + configure_network_filesystem_port, + reset_network_filesystem_port, +) from app.chain.tmdb import TmdbChain from app.db.adapters.chain import TransactionalChainDurableEventWriter from app.db.adapters.download import TransactionalDownloadFailureRepository @@ -21,12 +40,16 @@ from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRep from app.db.oper.message import MessageOper from app.db.oper.systemconfig import SystemConfigOper from app.db.session import SessionFactory +from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metabase import MetaBase from app.runtime.cache import AsyncFileCache, FileCache from app.runtime.events import EventManager from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher from app.runtime.extensions.module.manager import ModuleManager from app.runtime.extensions.plugin.manager import PluginManager from app.runtime.stop import runtime_stop_state +from app.schemas.common import JsonData +from app.schemas.types import MediaType from app.startup.composition.database import build_transactional_user_repository from app.startup.composition.runtime import RuntimeDependencies from app.startup.composition.subscription import ( @@ -125,3 +148,131 @@ def reset_chain_services() -> None: """按发布逆序撤销 Chain 无参上下文和壁纸来源。""" configure_chain_runtime_context_provider(None) reset_wallpaper_providers() + + +class _RecognitionShareAdapter: + """把 MoviePilot Server 共享识别能力适配为 Chain 窄端口。""" + + def report_recognize_share( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo | MusicInfo], + keyword_meta: Optional[MetaBase] = None, + ) -> bool: + """同步上报共享识别结果。""" + return bool(MoviePilotServerHelper.report_recognize_share( + meta=meta, mediainfo=mediainfo, keyword_meta=keyword_meta + )) + + async def async_report_recognize_share( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo | MusicInfo], + keyword_meta: Optional[MetaBase] = None, + ) -> bool: + """异步上报共享识别结果。""" + return bool(await MoviePilotServerHelper.async_report_recognize_share( + meta=meta, mediainfo=mediainfo, keyword_meta=keyword_meta + )) + + def query_recognize_share( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType] = None, + keyword_meta: Optional[MetaBase] = None, + music_type: Optional[str] = None, + ) -> Optional[dict[str, Any]]: + """同步查询共享识别结果。""" + result = MoviePilotServerHelper.query_recognize_share( + meta=meta, + mtype=mtype, + keyword_meta=keyword_meta, + **({"music_type": music_type} if music_type is not None else {}), + ) + return cast(Optional[dict[str, Any]], result) + + async def async_query_recognize_share( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType] = None, + keyword_meta: Optional[MetaBase] = None, + music_type: Optional[str] = None, + ) -> Optional[dict[str, Any]]: + """异步查询共享识别结果。""" + result = await MoviePilotServerHelper.async_query_recognize_share( + meta=meta, + mtype=mtype, + keyword_meta=keyword_meta, + **({"music_type": music_type} if music_type is not None else {}), + ) + return cast(Optional[dict[str, Any]], result) + + def to_recognize_params( + self, + item: Optional[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + """把服务端结果转换为本地识别参数。""" + return cast( + Optional[dict[str, Any]], + MoviePilotServerHelper.to_recognize_params(item), + ) + + +class _SubscriptionShareAdapter: + """把 MoviePilot Server 订阅共享能力适配为 Chain 窄端口。""" + + def report_added(self, payload: dict[str, Any]) -> bool: + """同步上报新增订阅统计。""" + return bool(MoviePilotServerHelper.sub_reg_durable(payload)) + + async def async_report_added(self, payload: dict[str, Any]) -> bool: + """异步上报新增订阅统计。""" + return bool(await MoviePilotServerHelper.async_sub_reg_durable(payload)) + + def list_shares(self) -> list[dict[str, Any]]: + """读取当前用户可见的订阅分享。""" + return cast(list[dict[str, Any]], MoviePilotServerHelper.get_subscribe_shares()) + + def report_completed(self, payload: Mapping[str, JsonData]) -> bool: + """同步上报订阅完成统计。""" + return bool(MoviePilotServerHelper.sub_done_durable(dict(payload))) + + +class _NetworkFilesystemAdapter: + """把宿主文件系统探测能力适配为整理 Chain 窄端口。""" + + def is_network_filesystem( + self, + path: Path, + *, + include_local_fuse: bool = False, + ) -> bool: + """判断路径是否位于网络或指定的本地 FUSE 文件系统。""" + return bool(SystemUtils.is_network_filesystem( + path, include_local_fuse=include_local_fuse + )) + + +def configure_chain_port_composition() -> None: + """原子装配 Chain 的共享服务与文件系统端口。""" + reset_chain_port_composition() + try: + configure_recognition_share_port( + cast(RecognitionSharePort, _RecognitionShareAdapter()) + ) + configure_subscription_share_port( + cast(SubscriptionSharePort, _SubscriptionShareAdapter()) + ) + configure_network_filesystem_port( + cast(NetworkFilesystemPort, _NetworkFilesystemAdapter()) + ) + except Exception: + reset_chain_port_composition() + raise + + +def reset_chain_port_composition() -> None: + """释放 Chain 技术端口,支持重复 lifespan 与启动失败回滚。""" + reset_network_filesystem_port() + reset_subscription_share_port() + reset_recognition_share_port() diff --git a/app/startup/initializers/chain.py b/app/startup/initializers/chain.py index 613d46c9d..2a0fa8843 100644 --- a/app/startup/initializers/chain.py +++ b/app/startup/initializers/chain.py @@ -1,155 +1,16 @@ -"""装配 Chain 所需的外部服务与系统技术端口。""" +"""Chain 外部技术端口的生命周期入口。""" -from collections.abc import Mapping -from pathlib import Path -from typing import Any, Optional, cast - -from app.adapters.external.server import MoviePilotServerHelper -from app.adapters.system.host import SystemUtils -from app.chain._recognition import ( - RecognitionSharePort, - configure_recognition_share_port, - reset_recognition_share_port, +from app.startup.composition.chain import ( + configure_chain_port_composition, + reset_chain_port_composition, ) -from app.chain.subscribe.notify import ( - SubscriptionSharePort, - configure_subscription_share_port, - reset_subscription_share_port, -) -from app.chain.transfer.filter import ( - NetworkFilesystemPort, - configure_network_filesystem_port, - reset_network_filesystem_port, -) -from app.domain.context import MediaInfo, MusicInfo -from app.domain.meta.metabase import MetaBase -from app.schemas.common import JsonData -from app.schemas.types import MediaType - - -class _RecognitionShareAdapter: - """把 MoviePilot Server 共享识别能力适配为 Chain 窄端口。""" - - def report_recognize_share( - self, - meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo | MusicInfo], - keyword_meta: Optional[MetaBase] = None, - ) -> bool: - """同步上报共享识别结果。""" - return bool(MoviePilotServerHelper.report_recognize_share( - meta=meta, mediainfo=mediainfo, keyword_meta=keyword_meta - )) - - async def async_report_recognize_share( - self, - meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo | MusicInfo], - keyword_meta: Optional[MetaBase] = None, - ) -> bool: - """异步上报共享识别结果。""" - return bool(await MoviePilotServerHelper.async_report_recognize_share( - meta=meta, mediainfo=mediainfo, keyword_meta=keyword_meta - )) - - def query_recognize_share( - self, - meta: Optional[MetaBase], - mtype: Optional[MediaType] = None, - keyword_meta: Optional[MetaBase] = None, - music_type: Optional[str] = None, - ) -> Optional[dict[str, Any]]: - """同步查询共享识别结果。""" - result = MoviePilotServerHelper.query_recognize_share( - meta=meta, - mtype=mtype, - keyword_meta=keyword_meta, - **({"music_type": music_type} if music_type is not None else {}), - ) - return cast(Optional[dict[str, Any]], result) - - async def async_query_recognize_share( - self, - meta: Optional[MetaBase], - mtype: Optional[MediaType] = None, - keyword_meta: Optional[MetaBase] = None, - music_type: Optional[str] = None, - ) -> Optional[dict[str, Any]]: - """异步查询共享识别结果。""" - result = await MoviePilotServerHelper.async_query_recognize_share( - meta=meta, - mtype=mtype, - keyword_meta=keyword_meta, - **({"music_type": music_type} if music_type is not None else {}), - ) - return cast(Optional[dict[str, Any]], result) - - def to_recognize_params( - self, - item: Optional[dict[str, Any]], - ) -> Optional[dict[str, Any]]: - """把服务端结果转换为本地识别参数。""" - return cast( - Optional[dict[str, Any]], - MoviePilotServerHelper.to_recognize_params(item), - ) - - -class _SubscriptionShareAdapter: - """把 MoviePilot Server 订阅共享能力适配为 Chain 窄端口。""" - - def report_added(self, payload: dict[str, Any]) -> bool: - """同步上报新增订阅统计。""" - return bool(MoviePilotServerHelper.sub_reg_durable(payload)) - - async def async_report_added(self, payload: dict[str, Any]) -> bool: - """异步上报新增订阅统计。""" - return bool(await MoviePilotServerHelper.async_sub_reg_durable(payload)) - - def list_shares(self) -> list[dict[str, Any]]: - """读取当前用户可见的订阅分享。""" - return cast(list[dict[str, Any]], MoviePilotServerHelper.get_subscribe_shares()) - - def report_completed(self, payload: Mapping[str, JsonData]) -> bool: - """同步上报订阅完成统计。""" - return bool(MoviePilotServerHelper.sub_done_durable(dict(payload))) - - -class _NetworkFilesystemAdapter: - """把宿主文件系统探测能力适配为整理 Chain 窄端口。""" - - def is_network_filesystem( - self, - path: Path, - *, - include_local_fuse: bool = False, - ) -> bool: - """判断路径是否位于网络或指定的本地 FUSE 文件系统。""" - return bool(SystemUtils.is_network_filesystem( - path, include_local_fuse=include_local_fuse - )) def init_chain_ports() -> None: - """原子装配 Chain 的共享服务与文件系统端口。""" - reset_chain_ports() - try: - configure_recognition_share_port( - cast(RecognitionSharePort, _RecognitionShareAdapter()) - ) - configure_subscription_share_port( - cast(SubscriptionSharePort, _SubscriptionShareAdapter()) - ) - configure_network_filesystem_port( - cast(NetworkFilesystemPort, _NetworkFilesystemAdapter()) - ) - except Exception: - reset_chain_ports() - raise + """委托组合根装配 Chain 外部服务与系统端口。""" + configure_chain_port_composition() def reset_chain_ports() -> None: - """释放 Chain 技术端口,支持重复 lifespan 与启动失败回滚。""" - reset_network_filesystem_port() - reset_subscription_share_port() - reset_recognition_share_port() + """委托组合根释放 Chain 外部服务与系统端口。""" + reset_chain_port_composition() diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 75077d4b7..8b3721925 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -150,8 +150,10 @@ only when composition functions run, and FastAPI `app.state` publication remains the lifecycle layer. `startup/composition/chain.py` owns the no-argument Chain compatibility context, typed persistence/dispatch object graph, lazy legacy Transfer command binding and -wallpaper-provider publication. `initializers/modules.py` passes the already constructed -`RuntimeDependencies` in startup order and must not recreate those dependencies. +wallpaper-provider publication. It also owns MoviePilot Server share and filesystem +Adapter wiring required by Chain ports. `initializers/chain.py` is only the lifecycle +hook; `initializers/modules.py` passes the already constructed `RuntimeDependencies` +in startup order and must not recreate those dependencies. `startup/composition/security.py` owns authentication, user lookup, PassKey and Web access provider assembly; it returns the persistence factories needed by the runtime owner, which alone constructs `AuthenticationRuntime`. diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index e908344cb..e34ab3bdc 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -2539,6 +2539,44 @@ def test_site_initializer_delegates_construction_to_composition(): assert "app.application.rss" in composition_imports +def test_chain_initializer_delegates_construction_to_composition(): + """Chain initializer 只保留生命周期入口,不得重新拥有外部技术适配器。""" + initializer_path = APP_ROOT / "startup" / "initializers" / "chain.py" + initializer_tree = ast.parse( + initializer_path.read_text(encoding="utf-8-sig"), + filename=str(initializer_path), + ) + imported_modules = { + node.module + for node in ast.walk(initializer_tree) + if isinstance(node, ast.ImportFrom) and node.module + } + assert imported_modules == {"app.startup.composition.chain"} + calls = { + ast.unparse(node.func) + for node in ast.walk(initializer_tree) + if isinstance(node, ast.Call) + } + assert calls == { + "configure_chain_port_composition", + "reset_chain_port_composition", + } + + composition_path = APP_ROOT / "startup" / "composition" / "chain.py" + composition_tree = ast.parse( + composition_path.read_text(encoding="utf-8-sig"), + filename=str(composition_path), + ) + composition_imports = { + node.module + for node in ast.walk(composition_tree) + if isinstance(node, ast.ImportFrom) and node.module + } + assert "app.adapters.external.server" in composition_imports + assert "app.adapters.system.host" in composition_imports + assert "app.chain._recognition" in composition_imports + + PROCESS_LEVEL_ROOTS = ( "app.api", "app.chain", diff --git a/tests/test_chain_external_ports.py b/tests/test_chain_external_ports.py index 306c0db10..9e54c9716 100644 --- a/tests/test_chain_external_ports.py +++ b/tests/test_chain_external_ports.py @@ -8,6 +8,7 @@ import pytest from app.chain import _recognition as recognition from app.chain.subscribe import notify as subscription from app.chain.transfer import filter as transfer +from app.startup.composition import chain as chain_composition from app.startup.initializers import chain as chain_initializer @@ -63,7 +64,7 @@ async def test_chain_adapters_preserve_sync_and_async_behavior(monkeypatch) -> N list_shares = Mock(return_value=[{"share_uid": "u1"}]) report_completed = Mock(return_value=True) filesystem = Mock(return_value=True) - helper = chain_initializer.MoviePilotServerHelper + helper = chain_composition.MoviePilotServerHelper monkeypatch.setattr(helper, "report_recognize_share", report_recognition) monkeypatch.setattr(helper, "async_report_recognize_share", async_report_recognition) monkeypatch.setattr(helper, "query_recognize_share", query_recognition) @@ -74,7 +75,7 @@ async def test_chain_adapters_preserve_sync_and_async_behavior(monkeypatch) -> N monkeypatch.setattr(helper, "get_subscribe_shares", list_shares) monkeypatch.setattr(helper, "sub_done_durable", report_completed) monkeypatch.setattr( - chain_initializer.SystemUtils, + chain_composition.SystemUtils, "is_network_filesystem", filesystem, ) @@ -104,7 +105,7 @@ def test_chain_port_initializer_rolls_back_partial_failure(monkeypatch) -> None: """任一端口装配失败时应清理已装配实例并保留原异常。""" failure = RuntimeError("subscription adapter failed") monkeypatch.setattr( - chain_initializer, + chain_composition, "configure_subscription_share_port", Mock(side_effect=failure), ) diff --git a/tests/test_episode_group_recognition.py b/tests/test_episode_group_recognition.py index 0dab16862..28d12789e 100644 --- a/tests/test_episode_group_recognition.py +++ b/tests/test_episode_group_recognition.py @@ -34,9 +34,9 @@ def test_recognize_media_uses_meta_episode_group(): ) with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, - ), patch("app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share") as query_mock: + ), patch("app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share") as query_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is mediainfo diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index 5e39060d9..027e7587d 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -57,10 +57,10 @@ def test_report_shared_result_after_local_recognize_success(): mediainfo = _tmdb_media("测试电影", 100, MediaType.MOVIE, year="2024") with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share" + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta, cache=False) @@ -82,7 +82,7 @@ def test_query_shared_result_when_local_recognize_failed(): "run_module", side_effect=[None, shared_media], ) as run_module, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "tv", "media_source": "themoviedb", @@ -90,7 +90,7 @@ def test_query_shared_result_when_local_recognize_failed(): "season": 1, }, ) as query_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -98,7 +98,7 @@ def test_query_shared_result_when_local_recognize_failed(): "season": 1, }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -130,7 +130,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "async_run_module", async_run_module, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_query_recognize_share", AsyncMock(return_value={ "type": "tv", "media_source": "themoviedb", @@ -138,7 +138,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "season": 2, }), ) as query_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -146,7 +146,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "season": 2, }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_report_recognize_share", AsyncMock(return_value=False), ), patch.object( chain, @@ -185,14 +185,14 @@ def test_backfill_local_cache_after_shared_recognize_success(): "run_module", side_effect=[None, shared_media, None], ) as run_module_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "movie", "media_source": "themoviedb", "media_id": "700", }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MOVIE, "media_source": MediaSource.TMDB, @@ -200,7 +200,7 @@ def test_backfill_local_cache_after_shared_recognize_success(): "season": None, }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media(meta=meta, cache=False) @@ -300,7 +300,7 @@ def test_report_shared_result_with_distinct_keyword_meta(): mediainfo = _tmdb_media("测试剧集", 402, MediaType.TV, year="2024") with patch.object(chain, "run_module", return_value=mediainfo), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock: result = chain.recognize_media(meta=meta, share_meta=share_meta, cache=False) @@ -328,7 +328,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "run_module", side_effect=[None, shared_media], ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "tv", "media_source": "themoviedb", @@ -336,7 +336,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "season": 1, }, ) as query_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -344,7 +344,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "season": 1, }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -372,10 +372,10 @@ def test_skip_report_when_local_recognize_hits_cache(): mediainfo.recognize_cache_hit = True with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share" + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta) @@ -398,10 +398,10 @@ def test_async_skip_report_when_local_recognize_hits_cache(): "async_run_module", AsyncMock(return_value=mediainfo), ) as async_run_module, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_report_recognize_share", AsyncMock(return_value=True), ) as report_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_query_recognize_share", AsyncMock(), ) as query_mock: result = await chain.async_recognize_media(meta=meta) @@ -592,10 +592,10 @@ def test_chain_recognize_media_reports_music_share_result(): music = _music_info() with patch.object(chain, "recognize_music_from_source", return_value=music), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share" + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta, cache=False) @@ -616,7 +616,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "recognize_music_from_source", side_effect=[None, music], ) as recognize_source, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "music", "media_source": "musicbrainz", @@ -624,7 +624,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "music_type": "recording", }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -633,7 +633,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "season": None, }, ), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -665,7 +665,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): "recognize_music_from_source", side_effect=[fallback, music], ) as recognize_source, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "music", "media_source": "musicbrainz", @@ -673,7 +673,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): "music_type": "recording", }, ) as query_share, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -710,7 +710,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "async_recognize_music_from_source", new=AsyncMock(side_effect=[fallback, music]), ) as recognize_source, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_query_recognize_share", new=AsyncMock(return_value={ "type": "music", "media_source": "musicbrainz", @@ -718,7 +718,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "music_type": "recording", }), ) as query_share, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.to_recognize_params", + "app.startup.composition.chain.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -760,10 +760,10 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result(): fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) with patch.object(chain, "recognize_music_from_source", return_value=fallback), patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value=None, ) as query_mock, patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share" + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share" ) as report_mock: result = chain.recognize_media(meta=meta, cache=False) diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index 35f9a6b04..eebe519b4 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -71,7 +71,7 @@ def test_explicit_source_recognition_reaches_modules_with_unified_identity() -> chain.run_module = Mock(return_value=media) with patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media( @@ -105,7 +105,7 @@ def test_default_recognition_passes_empty_generic_identity() -> None: meta.type = MediaType.MOVIE with patch( - "app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media(meta=meta) diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index e72ff6b41..49d36e8ce 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -465,7 +465,7 @@ def test_chain_recognize_media_music_plugin_supplement(): with patch.object(chain, "recognize_music_from_source", return_value=fallback), \ patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: + patch("app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is not fallback diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index ac42164b3..b012013ef 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -756,7 +756,7 @@ def test_subscribe_add_music_uses_explicit_entity_recognize(): with patch("app.chain.subscribe.create.MediaChain", return_value=media_chain), \ patch("app.chain.subscribe.create.add_subscribe", add_subscribe), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper"), \ + patch("app.startup.composition.chain.MoviePilotServerHelper"), \ patch.object(SubscribeChain, "_SubscribeChain__post_subscribe_added"): sid, err_msg = SubscribeChain().add( title="周杰伦 - 晴天", @@ -806,7 +806,7 @@ def test_subscribe_add_music_routes_new_album_sources( with patch("app.chain.subscribe.create.MediaChain", return_value=media_chain), \ patch("app.chain.subscribe.create.add_subscribe", add_subscribe), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper"), \ + patch("app.startup.composition.chain.MoviePilotServerHelper"), \ patch.object(SubscribeChain, "_SubscribeChain__post_subscribe_added"): sid, err_msg = SubscribeChain().add( title=title, @@ -897,7 +897,7 @@ def test_follow_preserves_album_entity_and_track_count(): chain.subscription_repository = subscribe_oper with patch("app.chain.subscribe.query.get_configured_system_config", return_value=system_config), \ patch( - "app.startup.initializers.chain.MoviePilotServerHelper.get_subscribe_shares", + "app.startup.composition.chain.MoviePilotServerHelper.get_subscribe_shares", return_value=[share], ), \ patch("app.chain.subscribe.query.MetaInfo") as video_meta, \ diff --git a/tests/test_music_workflows.py b/tests/test_music_workflows.py index b39dc60db..097cfd3db 100644 --- a/tests/test_music_workflows.py +++ b/tests/test_music_workflows.py @@ -435,7 +435,7 @@ def test_media_chain_default_recognition_only_queries_musicbrainz(monkeypatch): recognize_source = Mock(return_value=expected) monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) - with patch("app.startup.initializers.chain.MoviePilotServerHelper.report_recognize_share"): + with patch("app.startup.composition.chain.MoviePilotServerHelper.report_recognize_share"): result = chain.recognize_media(meta=meta) assert result is expected @@ -484,7 +484,7 @@ def test_default_recognition_does_not_fallback_after_musicbrainz_miss(monkeypatc monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) with patch( - "app.startup.initializers.chain.MoviePilotServerHelper.query_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.query_recognize_share", return_value=None, ): assert chain.recognize_media(meta=meta) is None @@ -506,7 +506,7 @@ def test_async_default_recognition_only_queries_musicbrainz(monkeypatch): monkeypatch.setattr(chain, "async_recognize_music_from_source", recognize_source) with patch( - "app.startup.initializers.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.startup.composition.chain.MoviePilotServerHelper.async_report_recognize_share", new=AsyncMock(), ): result = asyncio.run(chain.async_recognize_media(meta=meta)) diff --git a/tests/test_recognize_source_selection.py b/tests/test_recognize_source_selection.py index d80858e3c..69db22898 100644 --- a/tests/test_recognize_source_selection.py +++ b/tests/test_recognize_source_selection.py @@ -39,7 +39,7 @@ def test_recognize_media_with_source_only_uses_name_search(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper"): + patch("app.startup.composition.chain.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) assert result is not None @@ -70,7 +70,7 @@ def test_async_recognize_media_with_source_only_uses_name_search(): with patch.object(chain, "_async_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_async_supplement_media_recognize", side_effect=fake_supplement), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper", helper): + patch("app.startup.composition.chain.MoviePilotServerHelper", helper): result = asyncio.run( chain.async_recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) ) @@ -93,7 +93,7 @@ def test_recognize_media_accepts_string_source_only(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper"): + patch("app.startup.composition.chain.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source="themoviedb", cache=False) assert result is not None @@ -112,7 +112,7 @@ def test_recognize_media_meta_identity_same_source_uses_id(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.startup.initializers.chain.MoviePilotServerHelper"): + patch("app.startup.composition.chain.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) assert result is not None diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index f5aaf1f9f..cff3fe1c5 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -490,7 +490,7 @@ def _load_subscribe_chain_class(): import app.chain.subscribe.query as query_owner import app.chain.subscribe.refresh as refresh_owner import app.chain.subscribe.search as search_owner - import app.startup.initializers.chain as chain_initializer + import app.startup.composition.chain as chain_composition from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.subscription.contract import SubscriptionSnapshot, build_subscribe_meta from app.chain.subscribe.context import _SubscribeCreateContext @@ -563,7 +563,7 @@ def _load_subscribe_chain_class(): query_owner, search_owner, ), - "MoviePilotServerHelper": (chain_initializer,), + "MoviePilotServerHelper": (chain_composition,), "MetaInfo": (create_owner, match_owner, query_owner), "logger": ( completion_owner, diff --git a/tests/test_transfer_mounted_disk_cleanup.py b/tests/test_transfer_mounted_disk_cleanup.py index 12543c6c2..1fcd5679f 100644 --- a/tests/test_transfer_mounted_disk_cleanup.py +++ b/tests/test_transfer_mounted_disk_cleanup.py @@ -32,7 +32,7 @@ def test_enabled_cleanup_skips_filesystem_detection(): 开关开启时应保持旧行为,且不产生额外文件系统检测。 """ with patch( - "app.startup.initializers.chain.SystemUtils.is_network_filesystem" + "app.startup.composition.chain.SystemUtils.is_network_filesystem" ) as is_network_filesystem: should_delete = ( TransferChain._should_delete_empty_source_directories( @@ -51,7 +51,7 @@ def test_disabled_cleanup_keeps_mounted_local_source_directories(): 开关关闭时应保留网络或 FUSE 挂载的本地源目录。 """ with patch( - "app.startup.initializers.chain.SystemUtils.is_network_filesystem", + "app.startup.composition.chain.SystemUtils.is_network_filesystem", return_value=True, ) as is_network_filesystem: should_delete = ( @@ -73,7 +73,7 @@ def test_disabled_cleanup_still_deletes_ordinary_local_source_directories(): 开关关闭时普通本地文件系统仍应删除空目录。 """ with patch( - "app.startup.initializers.chain.SystemUtils.is_network_filesystem", + "app.startup.composition.chain.SystemUtils.is_network_filesystem", return_value=False, ): should_delete = ( @@ -92,7 +92,7 @@ def test_disabled_cleanup_does_not_change_remote_storage_cleanup(): 开关关闭时非本地存储仍应执行原有空目录清理。 """ with patch( - "app.startup.initializers.chain.SystemUtils.is_network_filesystem" + "app.startup.composition.chain.SystemUtils.is_network_filesystem" ) as is_network_filesystem: should_delete = ( TransferChain._should_delete_empty_source_directories( @@ -112,7 +112,7 @@ def test_mounted_filesystem_detection_is_cached_by_source_directory(): """ mounted_filesystem_cache = {} with patch( - "app.startup.initializers.chain.SystemUtils.is_network_filesystem", + "app.startup.composition.chain.SystemUtils.is_network_filesystem", return_value=True, ) as is_network_filesystem: for _ in range(2):