refactor backend module architecture

This commit is contained in:
jxxghp
2026-08-14 15:45:38 +08:00
parent 557cc0e2e3
commit 7b3444c366
716 changed files with 10378 additions and 7709 deletions
+4 -4
View File
@@ -6,7 +6,7 @@
import sys
# 必须早于首个 import app.db(其在 import 期即按 CONFIG_PATH 连库):prepare_backend 内部
# 先隔离 CONFIG_DIR、补 app.helper.sites 垫片,再建表。app/testing 仅依赖标准库、import 不连库,
# 先隔离 CONFIG_DIR、补 app.infrastructure.sites 垫片,再建表。app/testing 仅依赖标准库、import 不连库,
# 故此处先 import 再调用是安全的。
from app.testing.bootstrap import prepare_backend
@@ -33,7 +33,7 @@ def pytest_sessionfinish(session, exitstatus):
_report_session_cleanup_error(session, "agent blocking executors", err)
try:
from app.helper.thread import ThreadHelper
from app.platform.thread import ThreadHelper
helper = ThreadHelper.get_existing_instance()
if helper:
@@ -42,14 +42,14 @@ def pytest_sessionfinish(session, exitstatus):
_report_session_cleanup_error(session, "thread helper", err)
try:
from app.helper.message import stop_message
from app.messaging.message import stop_message
stop_message()
except Exception as err:
_report_session_cleanup_error(session, "message service", err)
try:
from app.log import LoggerManager
from app.platform.log import LoggerManager
LoggerManager.shutdown()
except Exception as err:
+2 -2
View File
@@ -4,9 +4,9 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.core.config import ConfigModel
from app.platform.config import ConfigModel
from app.modules.acoustid import AcoustIdModule
from app.utils.http import AsyncRequestUtils, RequestUtils
from app.foundation.http import AsyncRequestUtils, RequestUtils
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
+85 -85
View File
@@ -20,8 +20,8 @@ from app.agent.middleware.subagents import (
SUBAGENT_TASK_TOOL_NAME,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.config import settings
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
from app.platform.config import settings
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
class _FakeGraphState:
@@ -341,7 +341,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
manager = AgentManager()
with (
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[{
patch("app.agent.orchestrator.load_jobs_metadata", new=AsyncMock(return_value=[{
"id": "job-1",
"name": "测试任务",
"description": "desc",
@@ -364,7 +364,7 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
manager = AgentManager()
with (
patch("app.agent.load_jobs_metadata", new=AsyncMock(return_value=[])),
patch("app.agent.orchestrator.load_jobs_metadata", new=AsyncMock(return_value=[])),
patch.object(manager, "process_message", new=AsyncMock()) as process_message,
):
await manager.heartbeat_check_jobs()
@@ -404,28 +404,28 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch("app.agent.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.orchestrator.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
@@ -459,38 +459,38 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(skill_tool),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
@@ -511,37 +511,37 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
@@ -600,40 +600,40 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(
activity_tool
),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
@@ -655,9 +655,9 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 5),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch(
"app.agent.create_subagent_middlewares",
"app.agent.orchestrator.create_subagent_middlewares",
return_value=(
["subagent"],
[
@@ -667,35 +667,35 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
),
),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.ToolSelectorMiddleware", side_effect=_tool_selector),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
await agent._create_agent(streaming=False)
@@ -715,37 +715,37 @@ class AgentBackgroundOutputTest(unittest.IsolatedAsyncioTestCase):
with (
patch.object(settings, "LLM_MAX_TOOLS", 0),
patch.object(agent, "_initialize_llm", new=AsyncMock(return_value=object())),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.create_subagent_middlewares", return_value=([], [])),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="PROMPT"),
patch("app.agent.orchestrator.create_subagent_middlewares", return_value=([], [])),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
side_effect=lambda *args, **kwargs: _fake_skills_middleware(),
),
patch("app.agent.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
side_effect=lambda *args, **kwargs: "patch",
),
patch("app.agent.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.InMemorySaver", return_value="checkpointer"),
patch("app.agent.create_agent", side_effect=lambda **kwargs: kwargs),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
patch("app.agent.orchestrator.InMemorySaver", return_value="checkpointer"),
patch("app.agent.orchestrator.create_agent", side_effect=lambda **kwargs: kwargs),
):
created = await agent._create_agent(streaming=False)
+1 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import Mock, patch
import pytest
from app.helper.agent import matches_channel_admin, resolve_config_principal_ids
from app.messaging.agent import matches_channel_admin, resolve_config_principal_ids
from app.modules.discord import DiscordModule
from app.modules.feishu.feishu import Feishu
from app.modules.qqbot import QQBotModule
+1 -1
View File
@@ -7,7 +7,7 @@ from langchain_core.messages import AIMessage, HumanMessage
from app.agent import HEARTBEAT_SESSION_PREFIX, MoviePilotAgent
from app.agent.memory import memory_manager
from app.db.agentchat_oper import AgentChatOper
from app.utils.identity import SYSTEM_INTERNAL_USER_ID
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
def test_agent_chat_oper_saves_display_messages_with_channel():
+1 -1
View File
@@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.agent.tools.impl._torrent_search_utils import simplify_search_result
from app.agent.tools.impl.get_search_results import GetSearchResultsTool
from app.core.context import Context, TorrentInfo
from app.domain.context import Context, TorrentInfo
def _build_context(
+35 -35
View File
@@ -16,7 +16,7 @@ from app.agent.tools.catalog import (
ToolIdentityAmbiguousError,
)
from app.agent.tools.impl.mcp import create_external_mcp_tools
from app.core.config import settings
from app.platform.config import settings
from app.schemas.agent import AgentMcpServerConfig
@@ -75,12 +75,12 @@ async def test_create_agent_reuses_cached_graph_when_signature_matches():
"_agent_bundle_signature",
new=AsyncMock(return_value=("sig",)),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
"app.agent.orchestrator.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch("app.agent.create_agent") as create_agent:
), patch("app.agent.orchestrator.create_agent") as create_agent:
graph = await agent._create_agent(streaming=False)
assert graph is cached_graph
@@ -117,13 +117,13 @@ async def test_fresh_catalog_cache_hit_skips_tool_and_mcp_discovery() -> None:
"_initialize_local_tool_catalogs",
side_effect=AssertionError("tool catalog rebuilt"),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
"app.agent.orchestrator.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(side_effect=AssertionError("MCP discovery called")),
):
graph = await agent._create_agent(streaming=False)
@@ -182,28 +182,28 @@ async def test_expired_unchanged_catalog_renews_freshness() -> None:
agent,
"_sync_model_profile",
), patch(
"app.agent.ServerToolRegistry.resolve_web_search",
"app.agent.orchestrator.ServerToolRegistry.resolve_web_search",
return_value=SimpleNamespace(use_local_web_search=True),
), patch(
"app.agent.LLMHelper.get_server_tools",
"app.agent.orchestrator.LLMHelper.get_server_tools",
return_value=[],
), patch(
"app.agent.prompt_manager.get_agent_prompt",
"app.agent.orchestrator.prompt_manager.get_agent_prompt",
return_value="prompt",
), patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
return_value=SimpleNamespace(name="skills", tools=[]),
), patch(
"app.agent.create_subagent_middlewares",
"app.agent.orchestrator.create_subagent_middlewares",
return_value=([], []),
), patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
), patch(
"app.agent.agent_mcp_manager.config_signature",
"app.agent.orchestrator.agent_mcp_manager.config_signature",
return_value="mcp-config",
), patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(return_value=[]),
):
graph = await agent._create_agent(streaming=False)
@@ -412,69 +412,69 @@ async def test_graph_keeps_mcp_first_winner_and_catalogs_all_collisions(
),
patch.object(agent, "_sync_model_profile"),
patch(
"app.agent.PluginManager.get_plugin_agent_tools_revision",
"app.agent.orchestrator.PluginManager.get_plugin_agent_tools_revision",
return_value=0,
),
patch(
"app.agent.agent_mcp_manager.config_signature",
"app.agent.orchestrator.agent_mcp_manager.config_signature",
return_value="mcp-config",
),
patch(
"app.agent.agent_mcp_manager.list_enabled_tool_specs",
"app.agent.orchestrator.agent_mcp_manager.list_enabled_tool_specs",
new=AsyncMock(return_value=specs),
),
patch(
"app.agent.ServerToolRegistry.resolve_web_search",
"app.agent.orchestrator.ServerToolRegistry.resolve_web_search",
return_value=SimpleNamespace(use_local_web_search=True),
),
patch("app.agent.LLMHelper.get_server_tools", return_value=[]),
patch("app.agent.prompt_manager.get_agent_prompt", return_value="prompt"),
patch("app.agent.orchestrator.LLMHelper.get_server_tools", return_value=[]),
patch("app.agent.orchestrator.prompt_manager.get_agent_prompt", return_value="prompt"),
patch(
"app.agent.create_subagent_middlewares",
"app.agent.orchestrator.create_subagent_middlewares",
side_effect=_capture_subagents,
),
patch(
"app.agent.MoviePilotToolFactory.get_tool_selector_always_include_names",
"app.agent.orchestrator.MoviePilotToolFactory.get_tool_selector_always_include_names",
return_value=[],
),
patch(
"app.agent.SkillsMiddleware",
"app.agent.orchestrator.SkillsMiddleware",
return_value=SimpleNamespace(name="skills", tools=[skill_tool]),
),
patch(
"app.agent.ActivityLogMiddleware",
"app.agent.orchestrator.ActivityLogMiddleware",
return_value=SimpleNamespace(name="activity", tools=[activity_tool]),
),
patch(
"app.agent.JobsMiddleware",
"app.agent.orchestrator.JobsMiddleware",
return_value=SimpleNamespace(name="jobs"),
),
patch(
"app.agent.RuntimeConfigMiddleware",
"app.agent.orchestrator.RuntimeConfigMiddleware",
return_value=SimpleNamespace(name="runtime"),
),
patch(
"app.agent.MemoryMiddleware",
"app.agent.orchestrator.MemoryMiddleware",
return_value=SimpleNamespace(name="memory"),
),
patch(
"app.agent.SummarizationMiddleware",
"app.agent.orchestrator.SummarizationMiddleware",
return_value=SimpleNamespace(name="summary"),
),
patch(
"app.agent.PatchToolCallsMiddleware",
"app.agent.orchestrator.PatchToolCallsMiddleware",
return_value=SimpleNamespace(name="patch"),
),
patch(
"app.agent.UsageMiddleware",
"app.agent.orchestrator.UsageMiddleware",
return_value=SimpleNamespace(name="usage"),
),
patch(
"app.agent.ToolSelectorMiddleware",
"app.agent.orchestrator.ToolSelectorMiddleware",
side_effect=_capture_selector,
),
patch("app.agent.InMemorySaver", return_value=object()),
patch("app.agent.create_agent", side_effect=_capture_agent),
patch("app.agent.orchestrator.InMemorySaver", return_value=object()),
patch("app.agent.orchestrator.create_agent", side_effect=_capture_agent),
patch.object(settings, "LLM_MAX_TOOLS", max_tools),
]
with ExitStack() as stack:
@@ -539,7 +539,7 @@ async def test_execute_agent_sends_only_latest_message_on_cache_hit():
agent._create_agent = _create_agent
messages = [HumanMessage(content="上一轮"), HumanMessage(content="本轮")]
with patch("app.agent.eventmanager.send_event"):
with patch("app.agent.orchestrator.eventmanager.send_event"):
await agent._execute_agent(messages)
assert agent._streamed_output == "ok"
+1 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, patch
from app.agent import MoviePilotAgent
from app.agent.llm import AgentCapabilityManager, LLMHelper
from app.chain.message import MessageChain
from app.core.config import settings
from app.platform.config import settings
from app.schemas.types import MessageChannel
+1 -1
View File
@@ -15,7 +15,7 @@ from app.agent.tools.impl.send_local_file import SendLocalFileInput
from app.agent import MoviePilotAgent, AgentChain
from app.agent.llm import AgentCapabilityManager
from app.chain.message import MessageChain
from app.core.config import settings
from app.platform.config import settings
from app.agent.llm import LLMHelper
from app.modules.discord import DiscordModule
from app.modules.qqbot import QQBotModule
+2 -2
View File
@@ -10,12 +10,12 @@ from app.agent.tools.impl.ask_user_choice import (
UserChoiceOptionInput,
)
from app.agent.tools.impl.send_message import SendMessageTool
from app.helper.interaction import (
from app.messaging.interaction import (
AgentInteractionOption,
agent_interaction_manager,
)
from app.chain.message import MessageChain
from app.core.config import settings
from app.platform.config import settings
from app.schemas.types import MessageChannel
+1 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from app import agent as agent_module
import app.agent.orchestrator as agent_module
from app.agent import AgentManager
from app.agent.memory import MemoryManager
from app.startup import agent_initializer, modules_initializer
+2 -2
View File
@@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.core.config import settings
from app.platform.config import settings
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import MessageChannel
@@ -199,7 +199,7 @@ class AgentCapabilityManagerTest(unittest.TestCase):
]
with patch(
"app.helper.service.ServiceConfigHelper.get_notification_configs",
"app.extensions.service_registry.ServiceConfigHelper.get_notification_configs",
return_value=configs,
):
self.assertTrue(
+6 -6
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.agent import MoviePilotAgent
from app.core.config import settings
from app.platform.config import settings
from app.schemas import AgentLLMProviderEventData
from app.schemas.types import ChainEventType
@@ -20,7 +20,7 @@ def test_resolve_llm_runtime_config_uses_system_thinking_level(monkeypatch) -> N
return SimpleNamespace(event_data=AgentLLMProviderEventData())
with patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=return_empty_config),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
@@ -39,7 +39,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_thinking_level(monkeypatch) -
return SimpleNamespace(event_data=event_data)
with patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=override_thinking_level),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
@@ -59,7 +59,7 @@ def test_resolve_llm_runtime_config_uses_system_api_protocol(monkeypatch) -> Non
return SimpleNamespace(event_data=AgentLLMProviderEventData())
with patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=return_empty_config),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
@@ -78,7 +78,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) ->
return SimpleNamespace(event_data=event_data)
with patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=override_api_protocol),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
@@ -97,7 +97,7 @@ def test_resolve_llm_runtime_config_prefers_plugin_web_search_mode(monkeypatch)
return SimpleNamespace(event_data=event_data)
with patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=override_web_search_mode),
):
runtime_config = asyncio.run(agent._resolve_llm_runtime_config())
+5 -5
View File
@@ -8,11 +8,11 @@ from app.agent.tools.impl.ask_user_choice import (
)
from app.agent.tools.impl.send_message import SendMessageTool
from app.chain.message import MessageChain
from app.core.config import settings
from app.platform.config import settings
from app.db import SessionFactory
from app.db.message_oper import MessageOper
from app.db.models.message import Message
from app.helper.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
from app.messaging.interaction import AgentInteractionOption, agent_interaction_manager, media_interaction_manager
from app.schemas.types import MessageChannel, NotificationType
@@ -165,10 +165,10 @@ def test_ask_user_choice_message_is_not_recorded_to_message_history():
try:
with patch(
"app.core.event.EventManager.async_send_event",
"app.platform.events.EventManager.async_send_event",
new_callable=AsyncMock,
) as async_send_event, patch(
"app.helper.message.MessageQueueManager.async_send_message",
"app.messaging.message.MessageQueueManager.async_send_message",
new_callable=AsyncMock,
) as async_send_message:
result = asyncio.run(
@@ -202,7 +202,7 @@ def test_agent_final_reply_disables_notification_history():
)
with patch(
"app.agent.AgentChain.async_post_message",
"app.agent.orchestrator.AgentChain.async_post_message",
new_callable=AsyncMock,
) as async_post_message:
asyncio.run(agent.send_agent_message("已完成处理"))
+2 -2
View File
@@ -25,7 +25,7 @@ from app.agent.tools.impl.recognize_media import RecognizeMediaTool
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
from app.agent.tools.impl.search_media import SearchMediaTool
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.core.context import (
from app.domain.context import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_ARTIST,
Context,
@@ -34,7 +34,7 @@ from app.core.context import (
MusicInfo,
TorrentInfo,
)
from app.core.meta import MetaMusic
from app.domain.meta.metamusic import MetaMusic
from app.schemas.types import MediaSource, MediaType
+1 -1
View File
@@ -1,7 +1,7 @@
from unittest.mock import patch
from app.agent.prompt import prompt_manager
from app.core.config import settings
from app.platform.config import settings
from app.schemas.types import MessageChannel
+1 -1
View File
@@ -1,6 +1,6 @@
from app.agent.prompt import PromptManager
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsInput
from app.core.config import settings
from app.platform.config import settings
def test_moviepilot_info_does_not_expose_api_token_or_database_password(monkeypatch) -> None:
+3 -3
View File
@@ -6,7 +6,7 @@ from unittest.mock import patch
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.helper.ocr import OcrHelper
from app.integrations.ocr import OcrHelper
class _FakeResponse:
@@ -67,7 +67,7 @@ def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
image_b64 = base64.b64encode(b"captcha-image").decode()
image_url = f"data:image/png;base64,{image_b64}"
with patch("app.helper.ocr.RequestUtils") as request_utils:
with patch("app.integrations.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "a8k2"}
)
@@ -86,7 +86,7 @@ def test_ocr_helper_normalizes_data_url_base64_padding():
"""data:image 地址缺少 padding 时应补齐后提交给 OCR 服务。"""
image_url = "data:image/jpeg;base64,YWJjZA"
with patch("app.helper.ocr.RequestUtils") as request_utils:
with patch("app.integrations.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "z9k2"}
)
@@ -14,7 +14,7 @@ from app.agent.tools.impl.read_file import ReadFileTool
from app.agent.tools.impl.write_file import WriteFileTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.agent import MoviePilotAgent
from app.core.config import settings
from app.platform.config import settings
from app.modules.feishu import FeishuModule
from app.modules.telegram import TelegramModule
from app.schemas.types import MessageChannel
@@ -355,7 +355,7 @@ def test_channel_agent_admin_user_id_does_not_bypass_user_lookup():
username="normal-user",
)
with patch("app.agent.UserOper") as user_oper:
with patch("app.agent.orchestrator.UserOper") as user_oper:
user_oper.return_value.async_get_by_name.return_value = SimpleNamespace(
is_superuser=False
)
@@ -377,7 +377,7 @@ def test_channel_agent_rejects_local_admin_username_without_trusted_principal():
)
agent.is_channel_admin = False
with patch("app.agent.UserOper") as user_oper:
with patch("app.agent.orchestrator.UserOper") as user_oper:
user_oper.return_value.async_get_by_name = AsyncMock(
return_value=SimpleNamespace(is_superuser=True)
)
@@ -400,7 +400,7 @@ def test_channel_agent_accepts_trusted_admin_principal_without_local_user():
)
agent.is_channel_admin = True
with patch("app.agent.UserOper") as user_oper:
with patch("app.agent.orchestrator.UserOper") as user_oper:
context = asyncio.run(
agent._build_tool_context(should_dispatch_reply=True)
)
+5 -5
View File
@@ -36,13 +36,13 @@ from app.agent.tools.impl.update_agent_task import (
UpdateAgentTaskTool,
)
from app.agent.tools.tags import ToolTag
from app.core.config import settings
from app.platform.config import settings
from app.db import SessionFactory
from app.db.agenttask_oper import AgentTaskOper
from app.db.models.agenttask import AgentTask
from app.schemas import ScheduleInfo
from app.scheduler import Scheduler
from app.utils.timer import TimerUtils
from app.platform.scheduling import TimerUtils
class _FakeAgentTaskScheduler:
@@ -385,7 +385,7 @@ async def test_interrupted_date_task_manual_run_disables_and_removes_job(
scheduler.init_agent_task_jobs()
process_message = AsyncMock(return_value="执行完成")
monkeypatch.setattr("app.agent.agent_manager.process_message", process_message)
monkeypatch.setattr("app.agent.orchestrator.agent_manager.process_message", process_message)
assert await scheduler.execute_agent_task(
task.id,
@@ -410,7 +410,7 @@ async def test_scheduler_propagates_scheduled_trigger_source(monkeypatch) -> Non
task = _add_agent_task("cron", "0 * * * *", "scheduled-source")
scheduler = _build_agent_task_scheduler()
execute = AsyncMock(return_value=(True, "执行完成"))
monkeypatch.setattr("app.agent.agent_manager.execute_scheduled_task", execute)
monkeypatch.setattr("app.agent.orchestrator.agent_manager.execute_scheduled_task", execute)
assert await scheduler.execute_agent_task(task.id) == (True, "执行完成")
execute.assert_awaited_once_with(task.id, trigger_source="scheduled")
@@ -1131,7 +1131,7 @@ async def test_background_agent_final_message_is_broadcast() -> None:
)
with patch(
"app.agent.AgentChain.async_post_message",
"app.agent.orchestrator.AgentChain.async_post_message",
new_callable=AsyncMock,
) as post_message:
await agent.send_agent_message("任务完成", title="MoviePilot助手")
+1 -1
View File
@@ -8,7 +8,7 @@ from app.agent.tools.impl.search_web import (
DEFAULT_SEARCH_ENGINE,
SearchWebTool,
)
from app.core.config import settings
from app.platform.config import settings
class TestAgentSearchWebTool(unittest.TestCase):
+2 -2
View File
@@ -202,7 +202,7 @@ def test_confirm_executes_once_without_model_or_history() -> None:
patch.object(agent, "_is_system_admin_context", new=AsyncMock(return_value=True)),
patch.object(agent, "_execute_agent", new=AsyncMock()) as execute_agent,
patch.object(agent, "_save_display_history_messages") as save_display,
patch("app.agent.memory_manager.save_agent_messages") as save_messages,
patch("app.agent.orchestrator.memory_manager.save_agent_messages") as save_messages,
patch.object(
QuerySystemSettingsTool,
"_load_setting_value",
@@ -402,7 +402,7 @@ def test_private_delivery_requests_literal_plain_text() -> None:
response = SimpleNamespace(success=True)
with patch(
"app.agent.AgentChain.send_direct_message",
"app.agent.orchestrator.AgentChain.send_direct_message",
return_value=response,
) as send_direct:
delivered = asyncio.run(
+2 -2
View File
@@ -23,7 +23,7 @@ from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import Field
import app.agent as agent_module
import app.agent.orchestrator as agent_module
from app.agent.memory import memory_manager
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
from app.agent.middleware.summarization import (
@@ -1221,7 +1221,7 @@ def test_summary_failure_preserves_database_history():
agent.send_agent_message = AsyncMock()
with (
patch("app.agent.eventmanager.send_event") as send_usage_event,
patch("app.agent.orchestrator.eventmanager.send_event") as send_usage_event,
):
result, _ = asyncio.run(
agent._execute_agent(
+1 -1
View File
@@ -9,7 +9,7 @@ from app.agent.tools.impl._system_setting_utils import list_setting_specs
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.core.config import Settings, settings
from app.platform.config import Settings, settings
from app.schemas.types import SystemConfigKey
+1 -1
View File
@@ -337,7 +337,7 @@ async def test_query_task_returns_owner_scoped_ten_recent_runs(monkeypatch) -> N
@pytest.mark.anyio
async def test_agent_manager_records_manual_trigger_source(monkeypatch) -> None:
"""真实执行入口应把手动触发来源写入对应 run。"""
monkeypatch.setattr("app.agent.settings.AI_AGENT_ENABLE", True)
monkeypatch.setattr("app.agent.orchestrator.settings.AI_AGENT_ENABLE", True)
task = _add_task("run-manager")
manager = AgentManager()
captured = {}
+5 -5
View File
@@ -6,7 +6,7 @@ from langchain_core.messages import AIMessage
from app.agent import MoviePilotAgent
from app.agent.memory import memory_manager
from app.core.config import settings
from app.platform.config import settings
from app.schemas.types import ChainEventType, EventType
@@ -62,11 +62,11 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None:
with (
patch(
"app.agent.eventmanager.async_send_event",
"app.agent.orchestrator.eventmanager.async_send_event",
new=AsyncMock(side_effect=select_provider),
) as send_event,
patch(
"app.agent.LLMHelper.get_llm",
"app.agent.orchestrator.LLMHelper.get_llm",
new=AsyncMock(return_value=fake_llm),
) as get_llm,
):
@@ -132,7 +132,7 @@ def test_execute_agent_broadcasts_usage_on_success() -> None:
with (
patch.object(agent, "_create_agent", new=create_agent),
patch.object(memory_manager, "save_agent_messages"),
patch("app.agent.eventmanager.send_event") as send_event,
patch("app.agent.orchestrator.eventmanager.send_event") as send_event,
):
asyncio.run(agent._execute_agent([]))
@@ -174,7 +174,7 @@ def test_execute_agent_broadcasts_usage_on_failure() -> None:
with (
patch.object(agent, "_create_agent", new=create_agent),
patch("app.agent.eventmanager.send_event") as send_event,
patch("app.agent.orchestrator.eventmanager.send_event") as send_event,
):
result, _ = asyncio.run(agent._execute_agent([]))
+1 -1
View File
@@ -11,7 +11,7 @@ from app.agent.tools.catalog import (
ToolIdentityAmbiguousError,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.plugin import PluginManager
from app.extensions.plugin_manager import PluginManager
class _Arguments(BaseModel):
+2 -2
View File
@@ -12,8 +12,8 @@ from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.ask_user_choice import AskUserChoiceInput, AskUserChoiceTool
from app.agent.tools.impl.send_local_file import SendLocalFileTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
from app.extensions.plugin_manager import PluginManager
from app.foundation.singleton import Singleton
class DemoAgentTool(MoviePilotTool):
+1 -1
View File
@@ -7,7 +7,7 @@ import pytest
from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
import app.agent as agent_module
import app.agent.orchestrator as agent_module
from app.agent.middleware.activity_log import ActivityLogMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.policy import AgentPolicyMiddleware
+1 -1
View File
@@ -13,7 +13,7 @@ from app.agent.middleware.subagents import is_subagent_stream_metadata
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
from app.api.endpoints.openai import _OpenAIStreamingHandler
from app.core.config import settings
from app.platform.config import settings
from app.schemas.message import MessageResponse
from app.schemas.types import MessageChannel, NotificationType
+1 -1
View File
@@ -180,7 +180,7 @@ def test_create_agent_config_uses_llm_max_iterations():
agent._create_agent = _create_agent
agent.stream_handler.stop_streaming = lambda: asyncio.sleep(0, result=(False, ""))
with patch("app.agent.settings.LLM_MAX_ITERATIONS", 7):
with patch("app.agent.orchestrator.settings.LLM_MAX_ITERATIONS", 7):
await agent._execute_agent([])
return fake_agent.config
@@ -26,7 +26,7 @@ def test_update_download_tasks_resolves_downloader_and_updates_all_supported_fie
未显式传下载器时应先按 Hash 解析任务所属下载器再一次性执行多项修改
"""
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
hash_value = "a" * 40
+1 -1
View File
@@ -3,7 +3,7 @@ from datetime import date
from unittest.mock import AsyncMock, Mock
from app.api.endpoints import anilist as anilist_endpoint
from app.core.context import MediaInfo
from app.domain.context import MediaInfo
from app.modules.anilist import AniListModule
from app.modules.anilist.anilist import AniListApi
+3 -3
View File
@@ -4,9 +4,9 @@ from xml.dom import minidom
import pytest
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.domain.scraper import MediaScraperHelper
from app.modules.anilist import AniListModule
from app.modules.anilist.anilist import AniListApi
from app.schemas.types import MediaSource, MediaType
+5 -5
View File
@@ -3,7 +3,7 @@ from unittest.mock import patch
import pytest
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.schemas.types import MediaType
@@ -59,10 +59,10 @@ def test_metainfo_path_inherits_bangumi_id_from_parent() -> None:
def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
"""当前Rust扩展缺少新字段时应直接使用Python解析器。"""
with patch(
"app.core.metainfo.rust_accel.supports_extended_media_ids",
"app.infrastructure.rust.supports_extended_media_ids",
return_value=False,
), patch(
"app.core.metainfo.rust_accel.find_metainfo",
"app.infrastructure.rust.find_metainfo",
side_effect=AssertionError("旧Rust扩展不应处理扩展来源ID"),
):
_, metainfo = find_metainfo("Frieren [anilist=154587]")
@@ -81,7 +81,7 @@ def test_extended_ids_fall_back_when_installed_rust_is_old() -> None:
)
def test_python_metainfo_rejects_zero_identity_and_removes_tag(title: str) -> None:
"""Python 标签解析器应移除零值标签,但不得生成媒体身份。"""
with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None):
with patch("app.infrastructure.rust.find_metainfo", return_value=None):
parsed_title, metainfo = find_metainfo(title)
assert metainfo["media_source"] is None
@@ -100,7 +100,7 @@ def test_metainfo_normalizes_zero_identity_from_old_rust_extension() -> None:
},
}
with patch(
"app.core.metainfo.rust_accel.find_metainfo",
"app.infrastructure.rust.find_metainfo",
return_value=rust_result,
):
parsed_title, metainfo = find_metainfo("Movie [tmdbid=0]")
+1 -1
View File
@@ -17,7 +17,7 @@ from app.api.endpoints import storage as storage_endpoint
from app.api.endpoints import system as system_endpoint
from app.api.endpoints import transfer as transfer_endpoint
from app.api.endpoints import user as user_endpoint
from app.core.security import verify_resource_token
from app.security.access import verify_resource_token
from app.db.user_oper import (
get_current_active_manage_user,
get_current_active_manage_user_async,
+1 -1
View File
@@ -17,7 +17,7 @@ from app.factory import (
localized_unhandled_exception_handler,
localized_validation_exception_handler,
)
from app.helper.locale import LocaleHelper
from app.platform.localization import LocaleHelper
from app.schemas.common import JsonData
from app.schemas.response import Response
+354
View File
@@ -0,0 +1,354 @@
import ast
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
APP_ROOT = PROJECT_ROOT / "app"
LEGACY_ROOTS = ("app.core", "app.helper", "app.utils")
LEGACY_MODULES = {"app.log"}
IMPLEMENTATION_ROOTS = (
"app.agent.skills",
"app.domain",
"app.extensions",
"app.foundation",
"app.infrastructure",
"app.integrations",
"app.messaging",
"app.platform",
"app.security",
"app.services",
)
CYCLE_ROOTS = (*IMPLEMENTATION_ROOTS, "app.compat", "app.sdk")
RETIRED_CANONICAL_FILES = (
"app/infrastructure/package_installer.py",
"app/infrastructure/resource_updater.py",
"app/infrastructure/rust_accel.py",
"app/messaging/agent_bridge.py",
"app/platform/config_reload.py",
"app/platform/rate_limit.py",
"app/platform/thread_pool.py",
"app/services/filter_rules.py",
"app/services/transfer_history.py",
"app/extensions/module_loader.py",
"app/extensions/plugin_market.py",
"app/extensions/plugin_repository.py",
"app/infrastructure/http.py",
"app/integrations/rss.py",
"app/security/two_factor.py",
"app/infrastructure/gc.py",
"app/infrastructure/web.py",
"app/security/url_safety.py",
"app/domain/mediaserver.py",
"app/domain/nfo.py",
"app/log.py",
"app/foundation/diagnostics.py",
"app/infrastructure/log.py",
"app/startup/diagnostics_initializer.py",
"app/startup/log_initializer.py",
"app/messaging/notification.py",
"app/messaging/webpush.py",
)
FORBIDDEN_CAPABILITY_IMPORTS = {
"foundation": {
"domain", "extensions", "infrastructure", "integrations", "messaging",
"platform", "security", "services", "sdk", "compat", "log",
},
"domain": {
"extensions", "integrations", "messaging", "security", "services",
"sdk", "compat", "db", "infrastructure", "platform", "log",
},
"platform": {
"domain", "extensions", "integrations", "messaging", "security",
"services", "sdk", "compat",
},
"infrastructure": {
"domain", "extensions", "integrations", "messaging", "security",
"services", "sdk", "compat",
},
"extensions": {"messaging", "security", "services", "sdk", "compat"},
"integrations": {
"extensions", "messaging", "security", "services", "sdk", "compat",
},
"messaging": {"integrations", "security", "services", "sdk", "compat"},
"security": {"extensions", "messaging", "services", "sdk", "compat"},
"services": {"integrations", "messaging", "sdk", "compat"},
"compat": {
"domain", "extensions", "foundation", "infrastructure", "integrations",
"messaging", "platform", "security", "services", "sdk",
},
}
def _discover_modules() -> dict[str, Path]:
"""建立实际 Python 模块名到源码路径的映射。"""
modules: dict[str, Path] = {}
for path in APP_ROOT.rglob("*.py"):
relative = path.relative_to(PROJECT_ROOT).with_suffix("")
parts = list(relative.parts)
if parts[-1] == "__init__":
parts.pop()
modules[".".join(parts)] = path
return modules
def _resolve_imports(
module_name: str,
path: Path,
known_modules: set[str],
) -> set[str]:
"""解析一个模块的静态导入,并计入 Python 必然初始化的父包。"""
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
package = module_name if path.name == "__init__.py" else module_name.rpartition(".")[0]
dependencies: set[str] = set()
for node in ast.walk(tree):
candidates: list[str] = []
if isinstance(node, ast.Import):
candidates.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
if node.level:
package_parts = package.split(".")
base = ".".join(package_parts[: len(package_parts) - node.level + 1])
imported_module = ".".join(
part for part in (base, node.module or "") if part
)
else:
imported_module = node.module or ""
if imported_module:
candidates.append(imported_module)
candidates.extend(
f"{imported_module}.{alias.name}"
for alias in node.names
if alias.name != "*"
)
for candidate in candidates:
parts = candidate.split(".")
dependencies.update(
parent
for index in range(2, len(parts))
if (parent := ".".join(parts[:index])) in known_modules
)
if candidate in known_modules:
dependencies.add(candidate)
dependencies.discard(module_name)
return dependencies
def _strongly_connected_components(
graph: dict[str, set[str]],
) -> list[set[str]]:
"""使用 Tarjan 算法返回依赖图中的非平凡强连通分量。"""
indices: dict[str, int] = {}
low_links: dict[str, int] = {}
stack: list[str] = []
on_stack: set[str] = set()
components: list[set[str]] = []
def visit(module_name: str) -> None:
"""深度遍历一个模块并在根节点处收集强连通分量。"""
indices[module_name] = len(indices)
low_links[module_name] = indices[module_name]
stack.append(module_name)
on_stack.add(module_name)
for dependency in graph[module_name]:
if dependency not in indices:
visit(dependency)
low_links[module_name] = min(
low_links[module_name], low_links[dependency]
)
elif dependency in on_stack:
low_links[module_name] = min(
low_links[module_name], indices[dependency]
)
if low_links[module_name] != indices[module_name]:
return
component: set[str] = set()
while stack:
dependency = stack.pop()
on_stack.remove(dependency)
component.add(dependency)
if dependency == module_name:
break
if len(component) > 1:
components.append(component)
for module_name in sorted(graph):
if module_name not in indices:
visit(module_name)
return components
def _legacy_imports(path: Path) -> set[str]:
"""提取源码中的静态和常量动态旧路径导入。"""
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
imports: set[str] = set()
for node in ast.walk(tree):
candidates: list[str] = []
if isinstance(node, ast.Import):
candidates.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
candidates.append(node.module)
elif isinstance(node, ast.Call) and node.args:
argument = node.args[0]
if isinstance(argument, ast.Constant) and isinstance(argument.value, str):
candidates.append(argument.value)
imports.update(
candidate
for candidate in candidates
if candidate in LEGACY_MODULES or candidate.startswith(LEGACY_ROOTS)
)
return imports
def test_legacy_roots_contain_no_python_sources():
"""旧目录只能作为运行时虚拟包存在,仓库中不得重新出现源码。"""
leftovers = sorted(
str(path.relative_to(PROJECT_ROOT))
for root_name in ("core", "helper", "utils")
for path in (APP_ROOT / root_name).rglob("*.py")
)
assert leftovers == []
def test_retired_canonical_filenames_do_not_return():
"""能力包应使用包内语境明确的短文件名,避免再次出现冗余角色后缀。"""
leftovers = [
relative_path
for relative_path in RETIRED_CANONICAL_FILES
if (PROJECT_ROOT / relative_path).exists()
]
assert leftovers == []
def test_host_code_does_not_import_legacy_roots():
"""除插件和兼容层外,宿主代码必须使用 canonical 路径。"""
violations: dict[str, set[str]] = {}
for path in APP_ROOT.rglob("*.py"):
relative = path.relative_to(APP_ROOT)
if relative.parts[0] in {"compat", "plugins"}:
continue
imports = _legacy_imports(path)
if imports:
violations[str(relative)] = imports
assert violations == {}
def test_migrated_modules_are_not_in_import_cycles():
"""任何 canonical 迁移模块都不得进入完整应用依赖图的环。"""
modules = _discover_modules()
known_modules = set(modules)
graph = {
name: _resolve_imports(name, path, known_modules)
for name, path in modules.items()
}
relevant_cycles = [
sorted(component)
for component in _strongly_connected_components(graph)
if any(name.startswith(CYCLE_ROOTS) for name in component)
]
assert relevant_cycles == []
def test_canonical_layers_do_not_depend_on_sdk_or_compat():
"""canonical 实现层不得反向依赖面向插件的 SDK 或兼容层。"""
violations: dict[str, set[str]] = {}
modules = _discover_modules()
known_modules = set(modules)
for module_name, path in modules.items():
if not module_name.startswith(IMPLEMENTATION_ROOTS):
continue
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if dependency.startswith(("app.sdk", "app.compat"))
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_capability_packages_do_not_import_forbidden_upper_layers():
"""能力包只能依赖其明确允许的下层或同层协作包。"""
modules = _discover_modules()
known_modules = set(modules)
violations: dict[str, set[str]] = {}
for module_name, path in modules.items():
parts = module_name.split(".")
if len(parts) < 2:
continue
source_package = parts[1]
forbidden_packages = FORBIDDEN_CAPABILITY_IMPORTS.get(source_package)
if not forbidden_packages:
continue
dependencies = _resolve_imports(module_name, path, known_modules)
forbidden = {
dependency
for dependency in dependencies
if len(dependency.split(".")) >= 2
and dependency.split(".")[1] in forbidden_packages
}
if forbidden:
violations[module_name] = forbidden
assert violations == {}
def test_platform_log_is_a_dependency_leaf():
"""底层可引用平台日志,但日志模块本身不得反向导入应用模块。"""
modules = _discover_modules()
dependencies = _resolve_imports(
"app.platform.log",
modules["app.platform.log"],
set(modules),
)
assert {
dependency
for dependency in dependencies
if dependency.startswith("app.")
} == set()
def test_foundation_does_not_emit_runtime_logs():
"""基础机制不初始化日志系统,运行期诊断由上层调用方负责。"""
violations: list[str] = []
for path in (APP_ROOT / "foundation").rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import) and any(
alias.name == "logging" for alias in node.names
):
violations.append(str(path.relative_to(PROJECT_ROOT)))
break
if isinstance(node, ast.ImportFrom) and node.module in {
"logging",
"app.platform.log",
}:
violations.append(str(path.relative_to(PROJECT_ROOT)))
break
assert violations == []
def test_cache_contract_does_not_import_concrete_adapters():
"""平台缓存契约和内存机制不得反向导入基础设施适配器。"""
modules = _discover_modules()
dependencies = _resolve_imports(
"app.platform.cache",
modules["app.platform.cache"],
set(modules),
)
assert {
dependency
for dependency in dependencies
if dependency.startswith("app.infrastructure")
} == set()
def test_resource_adapter_does_not_restart_process():
"""资源下载安装适配器不得反向调用进程重启能力。"""
modules = _discover_modules()
dependencies = _resolve_imports(
"app.infrastructure.resource",
modules["app.infrastructure.resource"],
set(modules),
)
assert "app.platform.runtime" not in dependencies
+2 -2
View File
@@ -4,8 +4,8 @@ import time
import httpx
import pytest
from app.utils import http as http_module
from app.utils.http import AsyncRequestUtils
from app.foundation import http as http_module
from app.foundation.http import AsyncRequestUtils
PROXY = "http://proxy.example:7890"
URL = "https://raw.githubusercontent.com/demo/repo/main/package.json"
+14 -14
View File
@@ -3,14 +3,14 @@ from types import SimpleNamespace
from unittest.mock import Mock
from app.chain.media import MediaChain
from app.core.context import MusicInfo
from app.core.meta.metamusic import (
from app.domain.context import MusicInfo
from app.domain.meta.metamusic import (
audio_quality_score,
audio_quality_tier,
format_audio_quality,
parse_audio_quality,
)
from app.helper.audio import AudioMetadataHelper
from app.services.audio import AudioMetadataHelper
from app.schemas.types import MUSIC_ENTITY_ALBUM
@@ -38,7 +38,7 @@ def test_read_audio_metadata_maps_easy_tags(monkeypatch):
sample_rate=44100,
),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read(Path("/music/08 - Get Lucky.flac"))
@@ -100,7 +100,7 @@ def test_music_info_serialization_exposes_derived_audio_quality():
def test_music_info_from_meta_preserves_track_and_audio_evidence():
"""核心元数据转换应保留整理、刮削和通知依赖的曲序与实际音频参数。"""
from app.core.meta import MetaMusic
from app.domain.meta.metamusic import MetaMusic
info = MusicInfo.from_meta(MetaMusic(
title="Get Lucky",
@@ -142,7 +142,7 @@ def test_parse_compact_audio_quality_tokens_without_false_sample_bitrate():
def test_read_audio_metadata_falls_back_to_filename(monkeypatch):
"""无法读取标签时应保留可用于手动整理的文件名元数据。"""
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: None)
meta = AudioMetadataHelper.read(Path("/music/Unknown Track.mp3"))
@@ -156,7 +156,7 @@ def test_read_audio_tags_does_not_fill_from_filename(monkeypatch):
tags={"title": ["Tagged Title"]},
info=SimpleNamespace(length=180),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read_tags(Path("/music/Daft Punk - Get Lucky 2013.flac"))
@@ -174,7 +174,7 @@ def test_read_audio_tags_ignores_invalid_musicbrainz_id(monkeypatch):
},
info=SimpleNamespace(length=180),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read_tags(Path("/music/track.flac"))
@@ -201,7 +201,7 @@ def test_read_audio_metadata_fallback_uses_dynamic_filename_parser(tmp_path, mon
"S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
)
audio_path.write_bytes(b"fake-flac")
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: None)
meta = AudioMetadataHelper.read(audio_path)
@@ -227,7 +227,7 @@ def test_read_audio_metadata_partial_tags_use_filename_for_missing_fields(
sample_rate=44100,
),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read(audio_path)
@@ -251,7 +251,7 @@ def test_read_audio_metadata_distinguishes_alac_inside_m4a(monkeypatch):
sample_rate=96000,
),
)
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
meta = AudioMetadataHelper.read(Path("/music/Lossless Track.m4a"))
@@ -276,7 +276,7 @@ def test_write_audio_metadata_maps_music_info_to_easy_tags(monkeypatch):
self.saved = True
audio = FakeAudio()
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
success = AudioMetadataHelper.write(
Path("/music/08 - Get Lucky.flac"),
@@ -319,7 +319,7 @@ def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypat
"""模拟 Mutagen 保存。"""
audio = FakeAudio()
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
success = AudioMetadataHelper.write(
Path("/music/Random Access Memories.flac"),
@@ -338,7 +338,7 @@ def test_write_audio_metadata_does_not_write_album_id_as_recording_tag(monkeypat
def test_write_audio_metadata_can_embed_cover_without_rewriting_tags(monkeypatch):
"""音乐封面策略应能在标签策略关闭时独立执行。"""
audio = SimpleNamespace(tags={"title": ["Original"]})
monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio)
monkeypatch.setattr("app.services.audio.MutagenFile", lambda *_args, **_kwargs: audio)
write_cover = Mock()
monkeypatch.setattr(AudioMetadataHelper, "_write_cover", write_cover)
+1 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.chain.media import MediaChain
from app.core.context import MediaInfo
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
+2 -2
View File
@@ -1,8 +1,8 @@
from unittest.mock import Mock
from xml.dom import minidom
from app.core.meta import MetaBase
from app.helper.scraper import MediaScraperHelper
from app.domain.meta.metabase import MetaBase
from app.domain.scraper import MediaScraperHelper
from app.modules.bangumi import BangumiModule
from app.schemas.types import MediaSource, MediaType
+4 -4
View File
@@ -19,7 +19,7 @@ def test_build_inputs_separates_video_and_music_domains():
"title",
"path",
}
assert all(not value.lower().endswith(tuple(benchmark.metainfo_module.settings.RMT_AUDIOEXT))
assert all(not value.lower().endswith(benchmark.get_audio_extensions())
for kind, value, _subtitle in music_once if kind == "music_query")
@@ -49,7 +49,7 @@ def test_parse_input_uses_public_production_entries(monkeypatch):
def test_selected_meta_parser_disables_and_restores_all_fast_paths(monkeypatch):
"""Python 对照上下文应屏蔽影视和音乐 Rust 入口,并完整恢复原函数。"""
rust_accel = benchmark.metainfo_module.rust_accel
rust_accel = benchmark.rust_accel
parser_names = (
"parse_metainfo",
"parse_metainfo_path",
@@ -142,7 +142,7 @@ def test_video_projection_ignores_python_parser_internal_state():
def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
"""运行前检查应拒绝关闭的 Rust 和缺少音乐入口的旧扩展。"""
rust_accel = benchmark.metainfo_module.rust_accel
rust_accel = benchmark.rust_accel
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=False))
with pytest.raises(RuntimeError, match="未启用"):
@@ -158,7 +158,7 @@ def test_validate_rust_runtime_rejects_disabled_and_old_extensions(monkeypatch):
def test_validate_rust_runtime_requires_successful_music_probe(monkeypatch):
"""音乐 Rust 入口存在但实际回退 Python 时也必须拒绝执行基准。"""
rust_accel = benchmark.metainfo_module.rust_accel
rust_accel = benchmark.rust_accel
extension = SimpleNamespace(parse_metamusic_fast=Mock())
monkeypatch.setattr(rust_accel, "is_enabled", Mock(return_value=True))
monkeypatch.setattr(rust_accel, "_moviepilot_rust", extension)
+4 -4
View File
@@ -9,11 +9,11 @@ from app import schemas
from app.chain.scraping import ScrapingChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.context import MediaInfo
from app.core.event import Event
from app.core.metainfo import MetaInfoPath
from app.domain.context import MediaInfo
from app.platform.events import Event
from app.domain.metainfo import MetaInfoPath
from app.db.models.transferhistory import TransferHistory
from app.log import logger
from app.platform.log import logger
from app.schemas.types import EventType
from tests.cases.files import bluray_files
+3 -3
View File
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from app.agent.tools.impl.browse_webpage import BrowserAction, BrowseWebpageTool
from app.helper.browser import BrowserSessionHelper, PlaywrightHelper
from app.infrastructure.browser import BrowserSessionHelper, PlaywrightHelper
class _FakeResponse:
@@ -168,7 +168,7 @@ def test_default_emulation_uses_cloakbrowser_context():
page = _FakePage()
context = _FakeContext([page])
with patch("app.helper.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object(
with patch("app.infrastructure.browser.settings.BROWSER_EMULATION", "cloakbrowser"), patch.object(
PlaywrightHelper,
"_PlaywrightHelper__launch_cloakbrowser_context",
return_value=context,
@@ -197,7 +197,7 @@ def test_legacy_playwright_emulation_uses_cloakbrowser_context():
page = _FakePage()
context = _FakeContext([page])
with patch("app.helper.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object(
with patch("app.infrastructure.browser.settings.BROWSER_EMULATION", "Playwright"), patch.object(
PlaywrightHelper,
"_PlaywrightHelper__launch_cloakbrowser_context",
return_value=context,
+18 -11
View File
@@ -2,21 +2,24 @@ import asyncio
import os
import threading
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
from app.core.cache import (
from app.infrastructure.cache import (
AsyncFileBackend,
AsyncMemoryBackend,
AsyncRedisBackend,
AsyncFileCache,
FileBackend,
RedisBackend,
)
from app.platform.cache import (
AsyncFileCache,
AsyncMemoryBackend,
FileCache,
MemoryBackend,
RedisBackend,
cached,
)
from app.core.config import settings
from app.helper.redis import AsyncRedisHelper, RedisHelper, serialize
from app.platform.config import settings
from app.infrastructure.redis import AsyncRedisHelper, RedisHelper, serialize
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
"""
@@ -142,7 +145,11 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
monkeypatch.setattr(modules_initializer, "DisplayHelper", lambda: None)
monkeypatch.setattr(modules_initializer, "DohHelper", lambda: None)
monkeypatch.setattr(modules_initializer, "SitesHelper", lambda: None)
monkeypatch.setattr(modules_initializer, "ResourceHelper", lambda: None)
monkeypatch.setattr(
modules_initializer,
"ResourceHelper",
lambda: SimpleNamespace(check=lambda: False),
)
monkeypatch.setattr(modules_initializer, "user_auth", lambda: None)
monkeypatch.setattr(modules_initializer, "ModuleManager", lambda: None)
monkeypatch.setattr(modules_initializer.EventManager, "start", lambda self: None)
@@ -535,8 +542,8 @@ def test_redis_helper_uses_blocking_pool_settings(monkeypatch):
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/2")
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 7)
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 3)
monkeypatch.setattr("app.helper.redis.redis.BlockingConnectionPool.from_url", fake_from_url)
monkeypatch.setattr("app.helper.redis.redis.Redis", FakeClient)
monkeypatch.setattr("app.infrastructure.redis.redis.BlockingConnectionPool.from_url", fake_from_url)
monkeypatch.setattr("app.infrastructure.redis.redis.Redis", FakeClient)
helper = RedisHelper()
helper.close()
@@ -614,8 +621,8 @@ def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch):
monkeypatch.setattr(settings, "CACHE_BACKEND_URL", "redis://cache:6379/3")
monkeypatch.setattr(settings, "CACHE_REDIS_MAX_CONNECTIONS", 9)
monkeypatch.setattr(settings, "CACHE_REDIS_POOL_TIMEOUT", 4)
monkeypatch.setattr("app.helper.redis.AsyncBlockingConnectionPool.from_url", fake_from_url)
monkeypatch.setattr("app.helper.redis.Redis", FakeAsyncClient)
monkeypatch.setattr("app.infrastructure.redis.AsyncBlockingConnectionPool.from_url", fake_from_url)
monkeypatch.setattr("app.infrastructure.redis.Redis", FakeAsyncClient)
config_calls = asyncio.run(run_connect())
+4 -4
View File
@@ -46,8 +46,8 @@ def load_cli_module():
app_module = ModuleType("app")
core_module = ModuleType("app.core")
helper_module = ModuleType("app.helper")
config_module = ModuleType("app.core.config")
system_module = ModuleType("app.helper.system")
config_module = ModuleType("app.platform.config")
system_module = ModuleType("app.platform.runtime")
version_module = ModuleType("version")
psutil_module = ModuleType("psutil")
@@ -68,8 +68,8 @@ def load_cli_module():
"app": app_module,
"app.core": core_module,
"app.helper": helper_module,
"app.core.config": config_module,
"app.helper.system": system_module,
"app.platform.config": config_module,
"app.platform.runtime": system_module,
"version": version_module,
"psutil": psutil_module,
}
+1 -1
View File
@@ -10,7 +10,7 @@ import asyncio
from typing import List
from unittest import IsolatedAsyncioTestCase
from app.utils.coalesce import (
from app.platform.coalesce import (
CoalesceDecision,
CoalesceSummary,
EventCoalescer,
+1 -1
View File
@@ -1,6 +1,6 @@
from typing import Any
from app.core.config import settings
from app.platform.config import settings
def test_update_float_setting_accepts_json_integer(monkeypatch) -> None:
+14 -17
View File
@@ -1,20 +1,17 @@
from unittest import TestCase
from unittest.mock import patch
from app.core.meta.customization import CustomizationMatcher
from app.domain.meta import customization as customization_module
from app.domain.meta.customization import CustomizationMatcher
class CustomizationMatcherTest(TestCase):
def test_match_uses_latest_customization_setting(self):
"""自定义占位符修改后,下一次识别应直接使用新配置。"""
matcher = CustomizationMatcher()
values = [["GROUP"], ["TEAM"]]
def test_match_uses_latest_customization_setting(monkeypatch):
"""自定义占位符修改后,下一次识别应直接使用新配置。"""
matcher = CustomizationMatcher()
values = [["GROUP"], ["TEAM"]]
monkeypatch.setattr(
customization_module,
"_customization_provider",
lambda: values[0],
)
with patch.object(
matcher.systemconfig,
"get",
side_effect=lambda _: values[0],
):
self.assertEqual(matcher.match("[GROUP][TEAM] Movie"), "GROUP")
values[0] = ["TEAM"]
self.assertEqual(matcher.match("[GROUP][TEAM] Movie"), "TEAM")
assert matcher.match("[GROUP][TEAM] Movie") == "GROUP"
values[0] = ["TEAM"]
assert matcher.match("[GROUP][TEAM] Movie") == "TEAM"
+2 -2
View File
@@ -1,8 +1,8 @@
from app.db import SessionFactory
from app.db.models.transferhistory import TransferHistory
from app.schemas.types import MediaSource, MediaType
from app.utils import system as system_module
from app.utils.system import SystemUtils
from app.infrastructure import system as system_module
from app.infrastructure.system import SystemUtils
def test_dashboard_system_info_returns_runtime_environment(monkeypatch):
+1 -1
View File
@@ -12,7 +12,7 @@ from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.models.message import Message
from app.db.models.siteuserdata import SiteUserData
from app.db.models.transferhistory import TransferHistory
from app.core.config import settings
from app.platform.config import settings
from app.scheduler import SchedulerChain
+1 -1
View File
@@ -74,7 +74,7 @@ from alembic.script import ScriptDirectory
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
from app.core.config import settings
from app.platform.config import settings
from app.db import Engine
from app.db.init import init_db, update_db
+9 -9
View File
@@ -38,17 +38,17 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
fake_bin = _write_fake_chown(tmp_path)
chown_log = tmp_path / "chown.log"
app_dir = tmp_path / "app"
helper_dir = app_dir / "app" / "helper"
resource_dir = app_dir / "app" / "infrastructure"
public_dir = tmp_path / "public"
home_dir = tmp_path / "home"
(app_dir / "app" / "plugins").mkdir(parents=True)
helper_dir.mkdir(parents=True)
resource_dir.mkdir(parents=True)
public_dir.mkdir()
(home_dir / ".cloakbrowser").mkdir(parents=True)
(home_dir / "runtime").mkdir()
(app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8")
(helper_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8")
(helper_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8")
(resource_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8")
(public_dir / "index.html").write_text("<!doctype html>\n", encoding="utf-8")
(home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8")
(home_dir / "runtime" / "state").write_text("state\n", encoding="utf-8")
@@ -64,7 +64,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
"APP_DIR": str(app_dir),
"PUBLIC_DIR": str(public_dir),
"HOME_DIR": str(home_dir),
"IMAGE_HELPER_DIR": str(helper_dir),
"IMAGE_RESOURCE_DIR": str(resource_dir),
"CONFIG_DIR": str(tmp_path / "config"),
"PUID": str(os.getuid()),
"PGID": str(os.getgid()),
@@ -192,20 +192,20 @@ def test_runtime_writable_paths_are_still_corrected(tmp_path: Path) -> None:
assert f"-R moviepilot:moviepilot {tmp_path}/home/runtime" in lines
assert f"-R moviepilot:moviepilot {tmp_path}/config /var/lib/nginx /var/log/nginx" in lines
assert "moviepilot:moviepilot /etc/hosts /tmp" in lines
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/helper" in lines
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/infrastructure" in lines
assert not any(line.startswith("-R ") and ".cloakbrowser" in line for line in lines)
assert not any(f"{tmp_path}/app " in line for line in lines)
assert not any(f"{tmp_path}/public" in line for line in lines)
def test_helper_resource_permissions_are_repaired_even_when_owner_matches(tmp_path: Path) -> None:
def test_site_resource_permissions_are_repaired_even_when_owner_matches(tmp_path: Path) -> None:
log = _run_permission_case(
tmp_path,
'correct_helper_resource_permissions',
'correct_site_resource_permissions',
)
lines = log.splitlines()
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/helper" in lines
assert f"-R moviepilot:moviepilot {tmp_path}/app/app/infrastructure" in lines
assert not any(line.startswith("-R ") and f"{tmp_path}/app " in line for line in lines)
assert not any(line.startswith("-R ") and f"{tmp_path}/public" in line for line in lines)
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
from types import SimpleNamespace
from app.core.config import settings
from app.platform.config import settings
from app.doctor import checks, run_doctor
from app.doctor.formatters import format_json_report, format_text_report
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
+1 -1
View File
@@ -1,6 +1,6 @@
import socket
from app.helper import doh
from app.infrastructure import doh
def test_doh_executor_is_lazy_and_shutdown_restores_socket(monkeypatch):
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from unittest.mock import Mock
from unittest.mock import AsyncMock
from app.core.meta import MetaBase
from app.domain.meta.metabase import MetaBase
from app.modules.douban import DoubanModule
from app.schemas.types import MediaSource, MediaType
+6 -6
View File
@@ -6,9 +6,9 @@ import pytest
import app.chain.download as download_module
from app.chain.download import DownloadChain
from app.core.config import settings
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.platform.config import settings
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.schemas import DownloaderTorrent, FileItem, NotExistMediaInfo, TransferDirectoryConf
from app.schemas.types import MediaSource, MediaType
@@ -138,7 +138,7 @@ def test_download_single_submits_download_added_to_background(monkeypatch):
"""
_FakeThreadHelper.submitted = []
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper)
@@ -258,7 +258,7 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
_FakeThreadHelper.submitted = []
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
monkeypatch.setattr(download_module, "ThreadHelper", _FakeThreadHelper)
@@ -783,7 +783,7 @@ def test_download_single_records_failure_cooldown_when_downloader_rejects(monkey
return SimpleNamespace(id=1)
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
monkeypatch.setattr(download_module, "TorrentHelper", _FakeTorrentHelper)
+1 -1
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from app import schemas
from app.api.endpoints import download as download_endpoint
from app.core.context import MediaInfo
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource, MediaType
+15 -15
View File
@@ -11,9 +11,9 @@ import app.chain.download as download_module
from app.agent.tools.impl.add_download_tasks import AddDownloadTasksTool
from app.agent.tools.impl.update_download_tasks import UpdateDownloadTasksTool
from app.chain.download import DownloadChain
from app.core.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.helper.directory import validate_download_save_path
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
from app.services.directory import validate_download_save_path
from app.schemas import DownloaderTorrent, TransferDirectoryConf
from app.schemas.types import MediaSource, MediaType
@@ -133,7 +133,7 @@ def _nested_download_dirs():
@pytest.fixture(autouse=True)
def patch_download_dirs(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _download_dirs(),
)
@@ -158,7 +158,7 @@ def test_validate_download_save_path_accepts_legacy_remote_path_without_storage_
def test_validate_download_save_path_prefers_configured_local_root(monkeypatch):
"""无前缀路径同时命中本地和远程根目录时应保持本地语义。"""
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: [
TransferDirectoryConf(
name="远程下载",
@@ -191,7 +191,7 @@ def test_validate_download_save_path_accepts_windows_configured_root_and_childre
expected,
):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _windows_download_dirs(),
)
@@ -213,7 +213,7 @@ def test_validate_download_save_path_rejects_windows_paths_outside_configured_ro
save_path,
):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _windows_download_dirs(),
)
@@ -255,7 +255,7 @@ def _build_tv_media() -> MediaInfo:
def test_resolve_media_download_dir_applies_configured_root_classification(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
@@ -271,7 +271,7 @@ def test_resolve_media_download_dir_applies_configured_root_classification(monke
def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
@@ -287,7 +287,7 @@ def test_resolve_media_download_dir_keeps_configured_child_path_exact(monkeypatc
def test_resolve_media_download_dir_applies_remote_root_classification(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
@@ -304,7 +304,7 @@ def test_resolve_media_download_dir_applies_remote_root_classification(monkeypat
def test_resolve_media_download_dir_accepts_legacy_remote_root_without_storage_prefix(monkeypatch):
"""订阅中的旧版远程根路径应按对应存储和分类配置解析。"""
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
@@ -320,7 +320,7 @@ def test_resolve_media_download_dir_accepts_legacy_remote_root_without_storage_p
def test_resolve_media_download_dir_uses_matching_media_specific_root(monkeypatch):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _media_specific_download_dirs(),
)
@@ -348,7 +348,7 @@ def test_resolve_media_download_dir_uses_exact_nested_root_configuration(
expected,
):
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _nested_download_dirs(),
)
@@ -429,7 +429,7 @@ def test_download_single_rejects_event_overridden_bad_save_path_before_downloade
def test_download_single_applies_configured_root_classification(monkeypatch):
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
chain = _build_download_chain()
@@ -450,7 +450,7 @@ def test_download_single_accepts_legacy_remote_root_without_storage_prefix(monke
"""旧订阅的无前缀远程根应以正确 FileURI 提交给下载模块。"""
monkeypatch.setattr(download_module.eventmanager, "send_event", lambda *args, **kwargs: None)
monkeypatch.setattr(
"app.helper.directory.DirectoryHelper.get_download_dirs",
"app.services.directory.DirectoryHelper.get_download_dirs",
lambda _self: _classified_download_dirs(),
)
chain = _build_download_chain()
+14 -14
View File
@@ -13,12 +13,12 @@ def _load_downloader_base():
app_module.__path__ = []
helper_module = types.ModuleType("app.helper")
helper_module.__path__ = []
service_module = types.ModuleType("app.helper.service")
service_module = types.ModuleType("app.extensions.service_registry")
schemas_module = types.ModuleType("app.schemas")
schema_types_module = types.ModuleType("app.schemas.types")
utils_module = types.ModuleType("app.utils")
utils_module.__path__ = []
mixins_module = types.ModuleType("app.utils.mixins")
mixins_module = types.ModuleType("app.platform.reload")
class StorageSchema(Enum):
Local = "local"
@@ -75,11 +75,11 @@ def _load_downloader_base():
stub_modules = {
"app": app_module,
"app.helper": helper_module,
"app.helper.service": service_module,
"app.extensions.service_registry": service_module,
"app.schemas": schemas_module,
"app.schemas.types": schema_types_module,
"app.utils": utils_module,
"app.utils.mixins": mixins_module,
"app.platform.reload": mixins_module,
}
module_path = repo_root / "app" / "modules" / "__init__.py"
@@ -101,7 +101,7 @@ def _load_transmission_module():
app_module.__path__ = []
core_module = types.ModuleType("app.core")
core_module.__path__ = []
cache_module = types.ModuleType("app.core.cache")
cache_module = types.ModuleType("app.platform.cache")
modules_module = types.ModuleType("app.modules")
modules_module.__path__ = []
transmission_package_module = types.ModuleType("app.modules.transmission")
@@ -109,12 +109,12 @@ def _load_transmission_module():
transmission_client_module = types.ModuleType("app.modules.transmission.transmission")
schemas_module = types.ModuleType("app.schemas")
schema_types_module = types.ModuleType("app.schemas.types")
config_module = types.ModuleType("app.core.config")
metainfo_module = types.ModuleType("app.core.metainfo")
log_module = types.ModuleType("app.log")
config_module = types.ModuleType("app.platform.config")
metainfo_module = types.ModuleType("app.domain.metainfo")
log_module = types.ModuleType("app.platform.log")
utils_module = types.ModuleType("app.utils")
utils_module.__path__ = []
string_module = types.ModuleType("app.utils.string")
string_module = types.ModuleType("app.domain.string")
transmission_rpc_module = types.ModuleType("transmission_rpc")
torrentool_module = types.ModuleType("torrentool")
torrentool_module.__path__ = []
@@ -233,17 +233,17 @@ def _load_transmission_module():
stub_modules = {
"app": app_module,
"app.core": core_module,
"app.core.cache": cache_module,
"app.core.config": config_module,
"app.core.metainfo": metainfo_module,
"app.log": log_module,
"app.platform.cache": cache_module,
"app.platform.config": config_module,
"app.domain.metainfo": metainfo_module,
"app.platform.log": log_module,
"app.modules": modules_module,
"app.modules.transmission": transmission_package_module,
"app.modules.transmission.transmission": transmission_client_module,
"app.schemas": schemas_module,
"app.schemas.types": schema_types_module,
"app.utils": utils_module,
"app.utils.string": string_module,
"app.domain.string": string_module,
"transmission_rpc": transmission_rpc_module,
"torrentool": torrentool_module,
"torrentool.torrent": torrentool_torrent_module,
+7 -7
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest
from app.chain.transfer import TransferChain
from app.helper.format import EpisodeFormatRuleHelper, FormatParser, _AutoRecommendSample
from app.services.formatting import EpisodeFormatRuleHelper, FormatParser, _AutoRecommendSample
from app.schemas import EpisodeFormatRule, FileItem
@@ -22,7 +22,7 @@ def _make_file(name: str, size: int = 150 * 1024 * 1024) -> FileItem:
@pytest.fixture(autouse=True)
def _patch_media_exts(monkeypatch):
monkeypatch.setattr(
"app.helper.format.settings.RMT_MEDIAEXT",
"app.services.formatting.settings.RMT_MEDIAEXT",
[".mkv", ".mp4"],
)
@@ -233,7 +233,7 @@ def test_auto_recommend_returns_false_when_parse_raises(monkeypatch):
def _raise_parse(*args, **kwargs):
raise ValueError("broken parse")
monkeypatch.setattr("app.helper.format._match_template", _raise_parse)
monkeypatch.setattr("app.services.formatting._match_template", _raise_parse)
state, errmsg, data = helper.recommend([], samples)
@@ -432,7 +432,7 @@ def test_auto_recommend_uses_native_episode_as_fallback(monkeypatch):
]
monkeypatch.setattr(
"app.helper.format.anitopy.parse",
"app.services.formatting.anitopy.parse",
lambda _: {},
)
monkeypatch.setattr(
@@ -508,7 +508,7 @@ def test_auto_recommend_prefers_bracket_episode_over_title_sequence_native(monke
]
monkeypatch.setattr(
"app.helper.format.anitopy.parse",
"app.services.formatting.anitopy.parse",
lambda _: {},
)
monkeypatch.setattr(
@@ -541,7 +541,7 @@ def test_auto_recommend_corrects_anitopy_title_sequence_bias(monkeypatch):
return {"episode_number": "3"}
monkeypatch.setattr(
"app.helper.format.anitopy.parse",
"app.services.formatting.anitopy.parse",
_mock_parse,
)
monkeypatch.setattr(
@@ -611,7 +611,7 @@ def test_extract_episode_with_native_fallback_keeps_anitopy_range_list(monkeypat
item = _make_file("Show - 01-02 [02].mkv")
monkeypatch.setattr(
"app.helper.format.anitopy.parse",
"app.services.formatting.anitopy.parse",
lambda _: {"episode_number": ["01", "02"]},
)
monkeypatch.setattr(
+2 -2
View File
@@ -9,8 +9,8 @@ setattr(sys.modules["transmission_rpc"], "File", object)
sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.types import MediaSource, MediaType
+4 -6
View File
@@ -2,8 +2,8 @@
import pytest
from app.core.event import Event, EventManager
from app.core.plugin import PluginManager
from app.platform.events import Event, EventManager
from app.extensions.plugin_manager import PluginManager
from app.schemas.types import ChainEventType
@@ -48,10 +48,8 @@ async def test_plugin_event_error_uses_public_display_name(monkeypatch):
event_manager._EventManager__invoke_handler_by_type_sync(
FailingDiscoverPlugin.handle, event
)
await event_manager._EventManager__invoke_plugin_method_async(
plugin_manager,
FailingDiscoverPlugin.__name__,
"handle",
await event_manager._EventManager__invoke_handler_by_type_async(
FailingDiscoverPlugin.handle,
event,
)
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from app.core.config import settings
from app.platform.config import settings
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "skills" / "feedback-issue" / "scripts"
+1 -1
View File
@@ -12,7 +12,7 @@ from unittest.mock import patch
from urllib.parse import quote
from app.agent.tools.factory import MoviePilotToolFactory
from app.core.config import settings
from app.platform.config import settings
SCRIPT_DIR = Path(__file__).resolve().parents[1] / "skills" / "feedback-issue" / "scripts"
+1 -1
View File
@@ -6,7 +6,7 @@ ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.core.context import MediaInfo
from app.domain.context import MediaInfo
from app.modules.feishu.feishu import Feishu
from app.schemas import Notification
+1 -1
View File
@@ -1,7 +1,7 @@
from types import SimpleNamespace
from unittest.mock import patch
from app.core.context import MediaInfo
from app.domain.context import MediaInfo
from app.modules.filemanager import FileManagerModule
from app.schemas.types import MediaType
+1 -1
View File
@@ -1,6 +1,6 @@
from types import SimpleNamespace
from app.utils.http import RequestUtils
from app.foundation.http import RequestUtils
def test_xml_decoding_prefers_xml_declaration_over_http_default():
+3 -3
View File
@@ -18,13 +18,13 @@ class JellyfinUserResolutionTest(unittest.TestCase):
def test_loader_does_not_leave_stub_modules_in_sys_modules(self):
self.assertNotIn("_test_jellyfin_module", sys.modules)
self.assertFalse(
getattr(sys.modules.get("app.log"), "_jellyfin_test_stub", False)
getattr(sys.modules.get("app.platform.log"), "_jellyfin_test_stub", False)
)
self.assertFalse(
getattr(sys.modules.get("app.core.config"), "_jellyfin_test_stub", False)
getattr(sys.modules.get("app.platform.config"), "_jellyfin_test_stub", False)
)
self.assertFalse(
getattr(sys.modules.get("app.utils.http"), "_jellyfin_test_stub", False)
getattr(sys.modules.get("app.foundation.http"), "_jellyfin_test_stub", False)
)
def _build_client(self) -> Jellyfin:
+1 -1
View File
@@ -1,4 +1,4 @@
from app.utils.jieba import cut
from app.foundation.jieba import cut
def test_cut_accepts_legacy_hmm_argument():
+202
View File
@@ -0,0 +1,202 @@
import builtins
import importlib
import subprocess
import sys
from pathlib import Path
import pytest
from app.compat.diagnostics import (
configure_legacy_import_diagnostics,
get_legacy_import_diagnostics,
reset_legacy_import_diagnostics,
scan_plugin_legacy_imports,
)
from app.compat.imports import install_legacy_import_hook
from app.compat.manifest import (
MODULE_ALIASES,
PACKAGE_ALIASES,
PACKAGE_EXPORTS,
VIRTUAL_PACKAGES,
ModuleAlias,
)
LEGACY_PACKAGE = "legacy_compat_test"
LEGACY_MODULE = f"{LEGACY_PACKAGE}.target"
CANONICAL_PACKAGE = "canonical_compat_test"
CANONICAL_MODULE = f"{CANONICAL_PACKAGE}.target"
@pytest.fixture
def compatibility_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""创建可计数初始化次数的临时 canonical 模块和旧路径映射。"""
package_dir = tmp_path / CANONICAL_PACKAGE
package_dir.mkdir()
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(package_dir / "target.py").write_text(
"import builtins\n"
"builtins._legacy_compat_test_count = "
"getattr(builtins, '_legacy_compat_test_count', 0) + 1\n"
"TOKEN = object()\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
monkeypatch.setitem(
MODULE_ALIASES,
LEGACY_MODULE,
ModuleAlias(
target=CANONICAL_MODULE,
replacement="app.sdk.test",
introduced="test",
owner="test",
),
)
VIRTUAL_PACKAGES.add(LEGACY_PACKAGE)
install_legacy_import_hook()
reset_legacy_import_diagnostics()
yield
MODULE_ALIASES.pop(LEGACY_MODULE, None)
VIRTUAL_PACKAGES.discard(LEGACY_PACKAGE)
for module_name in (LEGACY_MODULE, LEGACY_PACKAGE, CANONICAL_MODULE, CANONICAL_PACKAGE):
sys.modules.pop(module_name, None)
if hasattr(builtins, "_legacy_compat_test_count"):
delattr(builtins, "_legacy_compat_test_count")
reset_legacy_import_diagnostics()
def test_legacy_import_reuses_canonical_module_identity(compatibility_modules):
"""旧路径先导入时应复用 canonical 模块且只执行一次源码。"""
legacy = importlib.import_module(LEGACY_MODULE)
canonical = importlib.import_module(CANONICAL_MODULE)
assert legacy is canonical
assert sys.modules[LEGACY_MODULE] is sys.modules[CANONICAL_MODULE]
assert legacy.__name__ == CANONICAL_MODULE
assert legacy.__spec__.name == CANONICAL_MODULE
assert builtins._legacy_compat_test_count == 1
def test_canonical_first_import_keeps_same_legacy_identity(compatibility_modules):
"""canonical 路径先导入时,旧路径仍应绑定同一模块对象。"""
canonical = importlib.import_module(CANONICAL_MODULE)
legacy = importlib.import_module(LEGACY_MODULE)
assert legacy is canonical
assert legacy.TOKEN is canonical.TOKEN
assert builtins._legacy_compat_test_count == 1
def test_virtual_package_blocks_unregistered_descendants(compatibility_modules):
"""合成旧父包不得向其他 Finder 泄漏未登记的子模块。"""
package = importlib.import_module(LEGACY_PACKAGE)
assert package.__path__ == []
with pytest.raises(ModuleNotFoundError, match="未在兼容映射中登记"):
importlib.import_module(f"{LEGACY_PACKAGE}.unknown")
def test_debug_diagnostics_warn_once_for_runtime_alias(compatibility_modules):
"""DEBUG 运行时兼容命中应输出一次包含迁移目标的警告。"""
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
importlib.import_module(LEGACY_MODULE)
importlib.import_module(LEGACY_MODULE)
assert len(messages) == 1
assert LEGACY_MODULE in messages[0]
assert CANONICAL_MODULE in messages[0]
assert "app.sdk.test" in messages[0]
def test_production_diagnostics_stay_silent(compatibility_modules):
"""DEBUG 关闭时兼容导入继续生效但不输出告警。"""
messages = []
configure_legacy_import_diagnostics(enabled=False, emitter=messages.append)
module = importlib.import_module(LEGACY_MODULE)
assert module is importlib.import_module(CANONICAL_MODULE)
assert messages == []
def test_plugin_scan_reports_cached_legacy_import(compatibility_modules, tmp_path: Path):
"""模块已缓存时,插件 AST 扫描仍应报告其静态旧导入。"""
importlib.import_module(LEGACY_MODULE)
reset_legacy_import_diagnostics()
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
plugin_dir = tmp_path / "sampleplugin"
plugin_dir.mkdir()
(plugin_dir / "__init__.py").write_text(
f"from {LEGACY_MODULE} import TOKEN\n",
encoding="utf-8",
)
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
scan_plugin_legacy_imports("SamplePlugin", plugin_dir)
assert len(messages) == 1
assert "插件 sampleplugin" in messages[0]
assert "__init__.py:1" in messages[0]
snapshot = get_legacy_import_diagnostics()
assert ("app.plugins.sampleplugin", LEGACY_MODULE) in snapshot["reported"]
def test_plugin_scan_accepts_utf8_bom(compatibility_modules, tmp_path: Path):
"""插件源码带 UTF-8 BOM 时仍应识别旧导入并输出迁移警告。"""
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
plugin_dir = tmp_path / "bomplugin"
plugin_dir.mkdir()
source = f"from {LEGACY_MODULE} import TOKEN\n".encode("utf-8")
(plugin_dir / "__init__.py").write_bytes(b"\xef\xbb\xbf" + source)
scan_plugin_legacy_imports("BomPlugin", plugin_dir)
assert len(messages) == 1
assert "插件 bomplugin" in messages[0]
assert LEGACY_MODULE in messages[0]
def test_manifest_aliases_reuse_real_canonical_modules():
"""正式映射表中的旧路径应在隔离进程中复用全部 canonical 模块。"""
code = """
import importlib
from app.compat.manifest import MODULE_ALIASES
for legacy_name, alias in MODULE_ALIASES.items():
try:
canonical = importlib.import_module(alias.target)
except ModuleNotFoundError:
if alias.target == "app.infrastructure.sites":
continue
raise
legacy = importlib.import_module(legacy_name)
assert legacy is canonical, (legacy_name, alias.target)
assert legacy.__name__ == alias.target, legacy_name
assert legacy.__spec__.name == alias.target, legacy_name
"""
subprocess.run(
[sys.executable, "-c", code],
cwd=Path(__file__).parents[1],
check=True,
)
def test_virtual_package_exports_resolve_exact_manifest_symbols():
"""合成旧包仅公开 manifest 声明的符号,并记录 DEBUG 兼容警告。"""
legacy_package = "app.core.meta"
sys.modules.pop(legacy_package, None)
messages = []
configure_legacy_import_diagnostics(enabled=True, emitter=messages.append)
package = importlib.import_module(legacy_package)
assert set(package.__all__) == set(PACKAGE_EXPORTS[legacy_package])
assert package.MetaBase is importlib.import_module(
"app.domain.meta.metabase"
).MetaBase
assert PACKAGE_ALIASES[legacy_package].replacement in messages[0]
reset_legacy_import_diagnostics()
+3 -11
View File
@@ -7,7 +7,7 @@ import pytest
from fastapi import FastAPI
from app.startup import lifecycle, modules_initializer
from app.utils import http as http_utils
from app.foundation import http as http_utils
def _assert_completed_once(mock: MagicMock) -> None:
@@ -227,7 +227,7 @@ def test_restart_endpoint_failure_preserves_stop_state(
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
"""命令重启失败时进程仍在运行,不能提前发布停止请求"""
from app.chain.system import SystemChain
from app.core.config import global_vars
from app.platform.config import global_vars
stop_event = threading.Event()
monkeypatch.setattr(global_vars, "STOP_EVENT", stop_event)
@@ -320,9 +320,6 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
monkeypatch.setattr(http_utils, "_MAX_SHARED_TRANSPORTS_PER_LOOP", 1)
monkeypatch.setattr(http_utils.httpx, "AsyncHTTPTransport", FakeTransport)
debug = MagicMock()
monkeypatch.setattr(http_utils.logger, "debug", debug)
async def run_test():
transport_kwargs = {
"proxy": None,
@@ -358,6 +355,7 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
await close_task
await asyncio.sleep(0)
assert eviction_tasks[0].done()
assert isinstance(eviction_tasks[0].exception(), RuntimeError)
assert evicted_transport.closed
assert active_transport.closed
with http_utils._shared_async_transports_lock:
@@ -373,12 +371,6 @@ def test_shared_http_close_waits_for_real_lru_eviction(monkeypatch):
asyncio.run(run_test())
debug.assert_any_call(
"LRU 淘汰共享 transport 时关闭失败: "
"RuntimeError('eviction close failed')"
)
def test_shared_http_close_ignores_eviction_from_other_loop():
"""当前事件循环关闭不能等待其他循环持有的淘汰任务"""
ready = threading.Event()
+4 -4
View File
@@ -120,8 +120,8 @@ def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
# 以假 settings/log 控制 helper 加载期行为;用唯一模块名加载,并以 stub_modules 上下文
# 在 import 期注入、退出后还原真实 app.core.config / app.log,避免污染其他测试。
_config_stub = ModuleType("app.core.config")
# 在 import 期注入、退出后还原真实平台配置与日志模块,避免污染其他测试。
_config_stub = ModuleType("app.platform.config")
_config_stub.settings = SimpleNamespace(
LLM_PROVIDER="global-provider",
LLM_MODEL="global-model",
@@ -136,11 +136,11 @@ _config_stub.settings = SimpleNamespace(
LLM_USE_PROXY=True,
PROXY_HOST=None,
)
_log_stub = ModuleType("app.log")
_log_stub = ModuleType("app.platform.log")
_log_stub.logger = _DummyLogger()
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "helper.py"
with stub_modules({"app.core.config": _config_stub, "app.log": _log_stub}):
with stub_modules({"app.platform.config": _config_stub, "app.platform.log": _log_stub}):
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
+11 -7
View File
@@ -29,7 +29,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
temp_path = Path(temp_dir.name)
root_dir = temp_path / "MoviePilot"
helper_dir = root_dir / "app" / "helper"
resource_dir = root_dir / "app" / "infrastructure"
runtime_dir = root_dir / ".runtime"
public_dir = root_dir / "public"
venv_dir = root_dir / "venv"
@@ -37,15 +37,15 @@ class LocalSetupUninstallTests(unittest.TestCase):
config_dir = root_dir / "config" if legacy_config else temp_path / "moviepilot-config"
temp_config_dir = config_dir / "temp"
helper_dir.mkdir(parents=True)
resource_dir.mkdir(parents=True)
runtime_dir.mkdir(parents=True)
public_dir.mkdir(parents=True)
venv_dir.mkdir(parents=True)
temp_config_dir.mkdir(parents=True)
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
(helper_dir / "user.sites.v3.bin").write_bytes(b"binary")
(resource_dir / "sites.py").write_text("generated\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_bytes(b"binary")
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
"{}", encoding="utf-8"
@@ -54,7 +54,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
stack = ExitStack()
self.addCleanup(stack.close)
stack.enter_context(patch.object(module, "ROOT", root_dir))
stack.enter_context(patch.object(module, "HELPER_DIR", helper_dir))
stack.enter_context(patch.object(module, "SITE_RESOURCE_DIR", resource_dir))
stack.enter_context(patch.object(module, "RUNTIME_DIR", runtime_dir))
stack.enter_context(patch.object(module, "PUBLIC_DIR", public_dir))
stack.enter_context(patch.object(module, "INSTALL_ENV_FILE", install_env_file))
@@ -109,8 +109,12 @@ class LocalSetupUninstallTests(unittest.TestCase):
self.assertFalse(venv_dir.exists())
self.assertFalse((root_dir / ".runtime").exists())
self.assertFalse((root_dir / "public").exists())
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v3.bin").exists())
self.assertFalse(
(root_dir / "app" / "infrastructure" / "sites.py").exists()
)
self.assertFalse(
(root_dir / "app" / "infrastructure" / "user.sites.v3.bin").exists()
)
self.assertFalse(cli_link.exists())
def test_uninstall_deletes_external_config_when_requested(self):
+2 -2
View File
@@ -7,8 +7,8 @@ from types import SimpleNamespace
from fastapi import HTTPException
from app.factory import create_app, localized_http_exception_handler
from app.helper.locale import LocaleHelper
from app.helper.progress import ProgressHelper
from app.platform.localization import LocaleHelper
from app.platform.progress import ProgressHelper
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
from app.schemas.response import Response
from version import APP_VERSION
+2 -2
View File
@@ -2,7 +2,7 @@ import threading
import time
from unittest.mock import MagicMock
from app.log import LogEntry, NonBlockingFileHandler, log_settings
from app.platform.log import LogEntry, NonBlockingFileHandler, log_settings
def test_non_blocking_file_handler_shutdown_wakes_writer_and_closes_handlers(tmp_path):
@@ -97,7 +97,7 @@ def test_non_blocking_file_handler_creates_one_handler_for_concurrent_first_writ
def close(self):
self.closed = True
monkeypatch.setattr("app.log.RotatingFileHandler", ProbeHandler)
monkeypatch.setattr("app.platform.log.RotatingFileHandler", ProbeHandler)
file_path = tmp_path / "concurrent.log"
def get_handler(started=None):
+1 -1
View File
@@ -1,4 +1,4 @@
from app.core.context import MusicInfo
from app.domain.context import MusicInfo
from app.modules.lrclib import LrclibModule
+41
View File
@@ -0,0 +1,41 @@
import os
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
MAIN_PATH = PROJECT_ROOT / "app" / "main.py"
def test_main_script_does_not_shadow_stdlib_platform(tmp_path):
"""PyCharm 脚本启动路径不得让 app/platform 遮蔽标准库 platform。"""
probe = "\n".join(
(
"import runpy",
"import sys",
f"sys.path.insert(0, {str(MAIN_PATH.parent)!r})",
"sys.modules.pop('platform', None)",
f"runpy.run_path({str(MAIN_PATH)!r}, run_name='pycharm_main_probe')",
"import platform",
"assert hasattr(platform, 'python_implementation')",
"assert '/app/platform/' not in str(getattr(platform, '__file__', ''))",
)
)
env = {
**os.environ,
"CONFIG_DIR": str(tmp_path / "config"),
"MOVIEPILOT_AUTO_UPDATE": "off",
}
result = subprocess.run(
[sys.executable, "-c", probe],
cwd=PROJECT_ROOT,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert result.returncode == 0, result.stderr
+2 -2
View File
@@ -5,9 +5,9 @@ from app.api.endpoints.transfer import (
query_manual_transfer_history,
)
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.platform.config import settings
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.transferhistory import (
from app.services.history import (
clear_transfer_failures,
failed_retry_count,
max_failed_retries,
+2 -2
View File
@@ -11,8 +11,8 @@ from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.manager import MoviePilotToolsManager
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.api.endpoints import mcp
from app.core.plugin import PluginManager
from app.utils.singleton import Singleton
from app.extensions.plugin_manager import PluginManager
from app.foundation.singleton import Singleton
class DemoPluginTool(MoviePilotTool):
+4 -4
View File
@@ -4,10 +4,10 @@ from unittest.mock import patch
import pytest
from app.chain.message import MediaInteractionChain, MessageChain
from app.core.event import EventManager
from app.core.context import Context, MediaInfo, TorrentInfo
from app.core.meta import MetaBase
from app.helper.interaction import media_interaction_manager, plugin_input_interaction_manager
from app.platform.events import EventManager
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.messaging.interaction import media_interaction_manager, plugin_input_interaction_manager
from app.schemas import CommingMessage, TransferDirectoryConf
from app.schemas.types import EventType, MediaSource, MediaType, MessageChannel
+2 -2
View File
@@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.api.endpoints.media import recognize
from app.core.context import MediaInfo
from app.core.meta import MetaMusic
from app.domain.context import MediaInfo
from app.domain.meta.metamusic import MetaMusic
from app.schemas.types import MediaType
+2 -2
View File
@@ -2,8 +2,8 @@ import asyncio
from unittest import TestCase
from unittest.mock import AsyncMock, Mock, patch
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.modules.douban import DoubanModule
from app.modules.themoviedb import TheMovieDbModule
from app.modules.themoviedb.scraper import TmdbScraper
+5 -4
View File
@@ -8,10 +8,11 @@ from unittest.mock import AsyncMock, Mock, patch
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.metainfo import MetaInfo
from app.helper.server import MoviePilotServerHelper
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.integrations.server import MoviePilotServerHelper
from app.schemas.types import MediaSource, MediaType
@@ -3,9 +3,9 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.helper.server import MoviePilotServerHelper
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.integrations.server import MoviePilotServerHelper
from app.schemas.types import MediaSource, MediaType, SystemConfigKey
+1 -1
View File
@@ -5,7 +5,7 @@ from fastapi.testclient import TestClient
from app import schemas
from app.api.endpoints import mediaserver as mediaserver_endpoint
from app.api.response import ResponseAPIRouter
from app.core.context import MediaInfo as CoreMediaInfo
from app.domain.context import MediaInfo as CoreMediaInfo
from app.schemas.types import MediaSource, MediaType
+4 -3
View File
@@ -4,9 +4,10 @@ import pytest
from app.api.endpoints import media as media_endpoint
from app.api.endpoints.media import recognize_file, scrape
from app.core.context import Context, MediaInfo
from app.core.meta import MetaBase, MetaMusic
from app.core.context import MusicInfo
from app.domain.context import Context, MediaInfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.context import MusicInfo
from app.schemas import FileItem, MediaType
from app.schemas.types import MediaSource
+1 -1
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI
from app.api.endpoints import media as media_endpoints
from app.api.endpoints.media import search
from app.chain import ChainBase
from app.core.security import verify_token
from app.security.access import verify_token
from app.modules.douban import DoubanModule
from app.modules.themoviedb import TheMovieDbModule
from app.schemas.types import MediaSource, MediaType
+3 -3
View File
@@ -1,10 +1,10 @@
from unittest.mock import Mock, patch
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.schemas.types import MediaSource, MediaType
from app.utils.media import (
from app.domain.media import (
build_media_key,
parse_media_source_selection,
resolve_media_identity,
+4 -4
View File
@@ -9,15 +9,15 @@ from app.testing import stub_modules
_systemconfig_stub = MagicMock()
_systemconfig_stub.SystemConfigOper.return_value.get.return_value = None
with stub_modules({
'app.helper.sites': MagicMock(),
'app.infrastructure.sites': MagicMock(),
'app.db.systemconfig_oper': _systemconfig_stub,
}):
from app import schemas
from app.chain.media import MediaChain
from app.chain.scraping import ScrapingChain, ScrapingConfig, ScrapingOption
from app.core.context import MediaInfo
from app.core.event import Event
from app.core.metainfo import MetaInfo
from app.domain.context import MediaInfo
from app.platform.events import Event
from app.domain.metainfo import MetaInfo
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
+2 -2
View File
@@ -1,4 +1,4 @@
from app.helper.service import ServiceConfigHelper
from app.extensions.service_registry import ServiceConfigHelper
from app.schemas.system import MediaServerConf
from app.schemas.types import SystemConfigKey
@@ -16,7 +16,7 @@ def test_mediaserver_conf_tolerates_blank_sync_interval():
def test_get_configs_skips_invalid_entries(monkeypatch):
"""单条配置校验失败时应跳过该条,不影响其它服务配置的加载。"""
monkeypatch.setattr(
"app.helper.service.SystemConfigOper.get",
"app.extensions.service_registry.SystemConfigOper.get",
lambda self, key: [
{"name": "good", "type": "emby", "enabled": True},
"bad-format",
+1 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import Mock
from app.chain.mediaserver import MediaServerChain
from app.schemas import MediaServerLibrary, MediaServerPlayItem
from app.utils.security import SecurityUtils
from app.security.url import SecurityUtils
class MediaServerImageSigningTest(unittest.TestCase):
+2 -2
View File
@@ -1,5 +1,5 @@
from app.core.context import MediaInfo
from app.helper.message import TemplateContextBuilder
from app.domain.context import MediaInfo
from app.messaging.message import TemplateContextBuilder
from app.schemas.types import MediaSource, MediaType
+3 -3
View File
@@ -4,13 +4,13 @@ from unittest.mock import Mock
from app.api.endpoints.message import clear_notification_message, get_notification_message
from app.chain import ChainBase
from app.core.context import Context, MediaInfo, TorrentInfo
from app.core.meta import MetaBase
from app.domain.context import Context, MediaInfo, TorrentInfo
from app.domain.meta.metabase import MetaBase
from app.db import AsyncSessionFactory, SessionFactory
from app.db.message_oper import MessageOper
from app.db.models.message import Message
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
from app.messaging.message import MessageHelper
from app.schemas import Notification, NotificationClearScope
from app.schemas.types import MediaType, NotificationType, SystemConfigKey
+1 -1
View File
@@ -140,7 +140,7 @@ class TestMessageProcessingStatus(unittest.TestCase):
"metadata": {"kind": "typing"},
}
with patch("app.agent.AgentChain") as chain_cls:
with patch("app.agent.orchestrator.AgentChain") as chain_cls:
_finish_processing_status(status, user_id="fallback")
chain_cls.return_value.finish_message_processing_status.assert_called_once_with(
+2 -2
View File
@@ -1,7 +1,7 @@
import time
from app.helper.message import MessageQueueManager, TemplateHelper, stop_message
from app.utils.singleton import SingletonClass
from app.messaging.message import MessageQueueManager, TemplateHelper, stop_message
from app.foundation.singleton import SingletonClass
def test_message_queue_stop_wakes_idle_monitor(monkeypatch):
+26 -25
View File
@@ -5,10 +5,11 @@ from unittest.mock import patch
import pytest
from app.core.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.core.meta import MetaBase, MetaMusic
from app.core.meta.metaanime import MetaAnime
from app.helper.torrent import TorrentHelper
from app.domain.metainfo import MetaInfo, MetaInfoPath, find_metainfo
from app.domain.meta.metabase import MetaBase
from app.domain.meta.metamusic import MetaMusic
from app.domain.meta.metaanime import MetaAnime
from app.services.torrent import TorrentHelper
from app.schemas.types import MediaSource, MediaType
from tests.cases.meta import meta_cases
@@ -161,7 +162,7 @@ def test_torrent_title_match_ignores_question_mark_variants():
def test_python_metainfo_fallback_preserves_xxx_movie_title():
"""Python 兜底解析不应删除合法 xXx 片名。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo("xXx 2002 1080p AMZN WEB-DL H.264 DDP 5.1-FROGWeb")
assert meta.en_name == "Xxx"
@@ -173,7 +174,7 @@ def test_python_metainfo_fallback_preserves_xxx_movie_title():
def test_python_metainfo_fallback_recognizes_eac3_audio_codec():
"""Python 兜底解析应识别 EAC3 及其声道信息。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo("Test.Movie.2026.1080p.BluRay.x264.EAC3.5.1-GROUP")
assert meta.resource_pix == "1080p"
@@ -215,7 +216,7 @@ def test_metainfo_preserves_all_resource_types(title, expected):
@pytest.mark.parametrize(("title", "expected"), RESOURCE_TYPE_CASES)
def test_python_metainfo_preserves_all_resource_types(title, expected):
"""Python 兜底解析应按顺序保留并去重所有受支持的资源类型。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title)
assert meta.resource_type == expected
@@ -276,7 +277,7 @@ def test_metainfo_music_round_trip_preserves_fields():
def test_python_subtitle_episode_range_fin_with_chinese_season():
"""Python 兜底解析应识别副标题中 [01-26Fin] 格式的集数范围(#6103)。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="JoJos Bizarre Adventure S01 2012 1080i BluRay x264 FLAC 2.0-AnimeF@ADE",
subtitle="JOJO的奇妙冒险 第一季 / JoJo's Bizarre Adventure [01-26Fin] [简繁字幕]",
@@ -305,7 +306,7 @@ def test_subtitle_episode_range_fin_with_default_parser():
def test_python_subtitle_episode_range_fin_without_chinese_marker():
"""副标题无中文季集标记时也应识别 [01-38Fin] 集数范围。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
subtitle="Some Show [01-38Fin]",
@@ -318,7 +319,7 @@ def test_python_subtitle_episode_range_fin_without_chinese_marker():
def test_python_subtitle_episode_range_end_variant():
"""END/完结 等完结标记变体同样应识别为集数范围。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta_end = MetaInfo(
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
subtitle="Some Show 01-24 END",
@@ -336,7 +337,7 @@ def test_python_subtitle_episode_range_end_variant():
def test_python_subtitle_year_range_not_treated_as_episodes():
"""年份范围(如 2019-2020)不得误识别为集数。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="Some Collection 2020 1080p WEB-DL H264-GRP",
subtitle="A Collection [2019-2020Fin]",
@@ -349,7 +350,7 @@ def test_python_subtitle_year_range_not_treated_as_episodes():
def test_python_subtitle_episode_range_fin_rejects_numeric_suffix():
"""Python 兜底解析不得把带数字后缀的完结范围截断识别为集数。"""
for subtitle in ("Some Show [01-26Fin]2", "Some Show 01-26Fin 2"):
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="Some Show S01 2022 1080p WEB-DL H264-GRP",
subtitle=subtitle,
@@ -389,7 +390,7 @@ def test_custom_words_episode_offset_supports_multiplication_expression():
r"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\1.Part1 && S06 <> .Part1 >> 2*EP-1"
]
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1",
custom_words=custom_words,
@@ -407,7 +408,7 @@ def test_custom_words_episode_offset_supports_repeated_ep_expression():
"""测试集数偏移表达式支持重复使用 EP 占位符。"""
custom_words = ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title="旧名 第03集", custom_words=custom_words)
assert meta.name == "新名"
@@ -419,7 +420,7 @@ def test_custom_words_episode_offset_rejects_implicit_ep_expression():
"""测试集数偏移表达式不把 2EP 当作隐式乘法或字符串拼接。"""
custom_words = ["旧名 => 新名 && 第 <> 集 >> 2EP"]
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title="旧名 第03集", custom_words=custom_words)
assert meta.name == "新名"
@@ -448,7 +449,7 @@ def test_custom_words_support_special_season_zero_parameter():
"Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"
]
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title="Test Show 01", custom_words=custom_words)
assert meta.media_source == MediaSource.TMDB
@@ -468,7 +469,7 @@ def test_find_metainfo_supports_episode_group_parameter():
def test_find_metainfo_does_not_support_episode_group_alias():
"""测试 e_group 不会被当作剧集组参数识别。"""
group_id = "5ad0ec240e0a26303f00d84d"
with patch("app.core.metainfo.rust_accel.find_metainfo", return_value=None):
with patch("app.infrastructure.rust.find_metainfo", return_value=None):
_, metainfo = find_metainfo(f"物语系列 {{[tmdbid=46195;type=tv;e_group={group_id};s=1]}}")
assert metainfo["episode_group"] is None
@@ -482,7 +483,7 @@ def test_video_bit_extracted_for_video_title():
def test_special_season_zero_enables_whole_season_resource_parsing():
"""只有 S00、没有集号的整季标题仍应识别后续编码信息。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title="Demo Show S00 X265 AAC")
assert meta.begin_season == 0
@@ -497,7 +498,7 @@ def test_anime_parser_preserves_numeric_special_season_zero():
"anime_season": 0,
"episode_number": "1",
}
with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed):
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=parsed):
meta = MetaAnime(title="Demo Anime S00E01")
assert meta.begin_season == 0
@@ -505,7 +506,7 @@ def test_anime_parser_preserves_numeric_special_season_zero():
assert meta.type == MediaType.TV
parsed["anime_season"] = [0, "1"]
with patch("app.core.meta.metaanime.anitopy.parse", return_value=parsed):
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=parsed):
ranged_meta = MetaAnime(title="Demo Anime S00-S01")
assert ranged_meta.begin_season == 0
@@ -525,9 +526,9 @@ def test_anime_parser_ignores_empty_and_invalid_season_values():
"episode_number": "1",
}
with patch("app.core.meta.metaanime.anitopy.parse", return_value=empty):
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=empty):
empty_meta = MetaAnime(title="Demo Anime E01")
with patch("app.core.meta.metaanime.anitopy.parse", return_value=invalid_list):
with patch("app.domain.meta.metaanime.anitopy.parse", return_value=invalid_list):
invalid_meta = MetaAnime(title="Demo Anime E01")
assert empty_meta.begin_season is None
@@ -536,7 +537,7 @@ def test_anime_parser_ignores_empty_and_invalid_season_values():
def test_hdr_vivid_effect_extracted_for_video_title():
"""测试合并写法 HDRVivid 可识别为资源效果。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(
title="Never-Ending Summer 2026 S01E18-S01E19 2160p WEB-DL 50Fps "
"HDRVivid H265 10bit AAC-XXWEB"
@@ -559,7 +560,7 @@ def test_video_bit_extracted_for_anime_title():
def test_streaming_platform_word_kept_in_movie_title():
"""测试正式片名中的流媒体平台词不会被预置清理规则移除。"""
with patch("app.core.metainfo.rust_accel.parse_metainfo", return_value=None):
with patch("app.infrastructure.rust.parse_metainfo", return_value=None):
meta = MetaInfo(title="Amazon Forever 2004 1080p WEB-DL")
assert meta.name == "Amazon Forever"
assert meta.year == "2004"
@@ -589,7 +590,7 @@ def test_custom_identifier_uses_source_specific_id_and_returns_unified_identity(
def test_generic_media_identity_is_not_custom_identifier_syntax():
"""通用身份字段不得因 Rust 扩展版本差异被自定义识别词解析器接收。"""
with patch(
"app.core.metainfo.rust_accel.find_metainfo",
"app.infrastructure.rust.find_metainfo",
side_effect=AssertionError("通用身份标签必须绕过 Rust 解析器"),
):
_, metainfo = find_metainfo(
+9 -9
View File
@@ -3,7 +3,7 @@ from typing import Optional
import pytest
from app.core.meta import (
from app.domain.meta.metamusic import (
MetaMusic,
MusicNameContext,
MusicNameParseResult,
@@ -11,7 +11,7 @@ from app.core.meta import (
MusicNamePattern,
MusicNameRegistry,
)
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.domain.metainfo import MetaInfo, MetaInfoPath
def parse_title(title: str) -> MetaMusic:
@@ -48,7 +48,7 @@ def test_music_name_registry_supports_dynamic_pattern_and_parser(monkeypatch):
)
try:
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("自定义注册表不应调用 Rust")
),
@@ -100,7 +100,7 @@ def test_music_name_registry_same_name_replacement_falls_back_to_python(monkeypa
MusicNameRegistry.register_parser(replacement, replace=True)
try:
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("同名替换解析器时不应调用 Rust")
),
@@ -139,7 +139,7 @@ def test_parse_query_uses_rust_and_maps_music_fields(monkeypatch):
return parsed
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
parse_metamusic,
)
@@ -155,7 +155,7 @@ def test_parse_query_uses_rust_and_maps_music_fields(monkeypatch):
def test_apply_title_preserves_existing_fields_from_rust(monkeypatch):
"""Rust 解析只应补充空字段,不覆盖标签或文件后缀证据。"""
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
lambda *_args, **_kwargs: {
"title": "Rust 曲名",
"artists": ["Rust 歌手"],
@@ -202,7 +202,7 @@ def test_apply_title_preserves_existing_fields_from_rust(monkeypatch):
def test_apply_title_falls_back_when_rust_returns_none(monkeypatch):
"""Rust wrapper 不可用时应完整执行现有 Python 命名解析。"""
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
lambda *_args, **_kwargs: None,
)
@@ -231,7 +231,7 @@ def test_metainfo_audio_suffix_remains_authoritative_with_rust(monkeypatch):
}
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
parse_metamusic,
)
filename = "S H E - S H E十七音乐会 2018 WEB-DL 1080P AVC AAC-FHDMv.flac"
@@ -263,7 +263,7 @@ def test_metainfo_path_uses_rust_once_and_keeps_python_directory_context(
}
monkeypatch.setattr(
"app.core.meta.metamusic.rust_accel.parse_metamusic",
"app.infrastructure.rust.parse_metamusic",
parse_metamusic,
)
path = MetaInfoPath(
@@ -5,8 +5,8 @@ import pytest
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from app.api.endpoints import mfa as mfa_endpoint
from app.helper import passkey as passkey_helper
from app.helper.passkey import (
from app.security import passkey as passkey_helper
from app.security.passkey import (
PassKeyHelper,
PassKeyRegistrationOriginMismatchError,
PassKeyRegistrationVerificationError,

Some files were not shown because too many files have changed in this diff Show More