refactor(startup): centralize chain external ports

This commit is contained in:
jxxghp
2026-08-30 12:07:04 +08:00
parent f448a7b003
commit 26a3b1cda4
14 changed files with 258 additions and 205 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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, \

View File

@@ -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))

View File

@@ -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

View File

@@ -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,

View File

@@ -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):