mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 22:04:57 +08:00
refactor(startup): centralize chain external ports
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user