From 7e851dbfa769b0d13c4af1f269e6ac3f18e5f779 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 16 Aug 2026 16:30:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(chain):=20=E5=A4=84=E7=90=86=E9=93=BE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=9F=9F=20mixin=20=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=9C=AA=E4=BD=BF=E7=94=A8=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=B9=B6=E6=A0=B9=E6=B2=BB=E5=85=BC=E5=AE=B9=E5=B1=82=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChainBase 拆分为 RecognitionMixin/MessageProcessingMixin/NotificationMixin - TransferChain 拆分为 7 个功能 mixin(_mixins.py),SubscribeChain 音乐订阅域拆出 _music.py - 斜杠命令交互四件套收敛为 InteractionChainMixin 委托,会话管理器移至 application 层,chain 层不再 re-export - 模块基础类收敛到 app/modules/_base(notification/mediaserver 语义重命名) - 清理 app/chain/__init__.py 24 个未使用导入,修正 49 处测试 patch 目标到实际命名空间 - 兼容层 legacy 符号不再并入 __all__,根治 schemas 初始化反向拉起 application.transfer 的循环导入 - 修复 bangumi 集数为字符串时 set_bangumi_info 抛 TypeError - 新增重复代码等架构门禁测试;capability 清单校验排除下划线内部目录 --- .gitignore | 4 + app/agent/orchestrator.py | 19 +- app/agent/tools/impl/_plugin_tool_utils.py | 21 +- app/agent/tools/impl/create_agent_task.py | 5 +- app/agent/tools/impl/delete_agent_task.py | 4 +- app/agent/tools/impl/list_slash_commands.py | 6 +- app/agent/tools/impl/query_agent_tasks.py | 5 +- app/agent/tools/impl/query_schedulers.py | 8 +- app/agent/tools/impl/run_agent_task.py | 4 +- app/agent/tools/impl/run_scheduler.py | 12 +- app/agent/tools/impl/run_slash_command.py | 9 +- app/agent/tools/impl/update_agent_task.py | 5 +- app/api/endpoints/plugin.py | 168 +- app/application/agent.py | 100 + app/application/commands.py | 45 + app/application/plugins.py | 190 ++ app/application/scheduling.py | 73 + app/application/transfer.py | 892 +++++- app/chain/__init__.py | 991 +------ app/chain/_interaction.py | 86 + app/chain/_messaging.py | 486 ++++ app/chain/_mixins.py | 1559 ++++++++++ app/chain/_music.py | 420 +++ app/chain/_recognition.py | 518 ++++ app/chain/agent.py | 14 + app/chain/message.py | 26 +- app/chain/search.py | 8 +- app/chain/site.py | 73 +- app/chain/subscribe.py | 459 +-- app/chain/transfer.py | 2509 +---------------- app/domain/context.py | 5 + app/factory.py | 5 + app/modules/_base/__init__.py | 15 + app/modules/_base/downloader.py | 109 + app/modules/_base/mediaserver.py | 192 ++ app/modules/_base/notification.py | 149 + app/modules/discord/__init__.py | 102 +- app/modules/emby/__init__.py | 146 +- app/modules/feishu/__init__.py | 13 +- app/modules/jellyfin/__init__.py | 147 +- app/modules/plex/__init__.py | 36 +- app/modules/qbittorrent/__init__.py | 96 +- app/modules/qqbot/__init__.py | 44 +- app/modules/rtorrent/__init__.py | 96 +- app/modules/slack/__init__.py | 102 +- app/modules/synologychat/__init__.py | 48 +- app/modules/telegram/__init__.py | 106 +- app/modules/transmission/__init__.py | 96 +- app/modules/trimemedia/__init__.py | 176 +- app/modules/ugreen/__init__.py | 159 +- app/modules/vocechat/__init__.py | 48 +- app/modules/wechat/__init__.py | 117 +- app/modules/wechatclawbot/__init__.py | 16 +- app/modules/zspace/__init__.py | 147 +- app/runtime/compat/imports.py | 5 +- app/runtime/compat/manifest.py | 12 + app/scheduler.py | 3 +- app/schemas/agent.py | 8 + app/startup/agent_initializer.py | 14 + app/startup/command_initializer.py | 4 + app/startup/scheduler_initializer.py | 4 + docs/rules/05-architecture.md | 34 + tests/test_agent_image_capability.py | 17 +- tests/test_agent_image_support.py | 19 +- tests/test_agent_interaction.py | 19 +- tests/test_agent_message_routing.py | 25 +- tests/test_agent_scheduled_tasks.py | 28 + tests/test_agent_session_status.py | 17 +- tests/test_api_response.py | 10 +- tests/test_architecture_dependencies.py | 118 +- tests/test_capability_registry.py | 2 + tests/test_chain_layering.py | 7 +- tests/test_discord_command_registration.py | 4 +- tests/test_downloader_path_mapping.py | 36 + tests/test_duplicate_code.py | 143 + tests/test_episode_format_helper.py | 14 +- tests/test_episode_group_recognition.py | 4 +- tests/test_manual_transfer_history.py | 19 +- tests/test_media_recognize_share.py | 66 +- .../test_media_recognize_share_statistics.py | 9 +- tests/test_media_source_routing.py | 4 +- tests/test_message_notifications.py | 43 +- tests/test_music_plugin_recognize.py | 2 +- tests/test_music_subscribe.py | 19 +- tests/test_music_transfer.py | 11 +- tests/test_music_workflows.py | 6 +- tests/test_qbittorrent_compat.py | 38 + tests/test_recognize_source_selection.py | 8 +- tests/test_slack_command_registration.py | 2 +- tests/test_slash_command_interactions.py | 6 +- tests/test_subscribe_chain.py | 13 + tests/test_system_notification_dispatch.py | 2 +- tests/test_telegram_typing_lifecycle.py | 17 +- tests/test_transfer_custom_words.py | 8 +- tests/test_transfer_failed_retry_buttons.py | 25 +- tests/test_transfer_job_manager.py | 26 +- tests/test_transfer_mounted_disk_cleanup.py | 20 +- tests/test_transfer_movie_collection.py | 16 +- tests/test_transfer_overwrite_declined.py | 10 +- tests/test_transfer_stale_tasks.py | 8 +- tests/test_transfer_sync_extra_files.py | 111 +- tests/test_transfer_tmdb_category.py | 4 + 102 files changed, 6041 insertions(+), 5888 deletions(-) create mode 100644 app/application/agent.py create mode 100644 app/application/commands.py create mode 100644 app/application/plugins.py create mode 100644 app/application/scheduling.py create mode 100644 app/chain/_interaction.py create mode 100644 app/chain/_messaging.py create mode 100644 app/chain/_mixins.py create mode 100644 app/chain/_music.py create mode 100644 app/chain/_recognition.py create mode 100644 app/chain/agent.py create mode 100644 app/modules/_base/__init__.py create mode 100644 app/modules/_base/downloader.py create mode 100644 app/modules/_base/mediaserver.py create mode 100644 app/modules/_base/notification.py create mode 100644 tests/test_duplicate_code.py diff --git a/.gitignore b/.gitignore index 5b11aa738..855d45635 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ app/plugins/** config/cookies/ config/app.env config/user.db* +config/systemconfig.db* config/sites/** config/agent/ config/logs/ @@ -26,6 +27,9 @@ config/plugins/ config/temp/ config/cache/ config/.cache/ +# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物 +app/config/ +.verify_tmp/ .runtime/ public/ .moviepilot.env diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index f4d3b6581..8bf21d07a 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -6,7 +6,6 @@ import traceback import uuid from dataclasses import dataclass from datetime import datetime, timedelta -from enum import Enum from typing import Any, Callable, Dict, List, Optional from fastapi.concurrency import run_in_threadpool @@ -68,7 +67,7 @@ from app.agent.tools.impl.mcp import ( select_legacy_mcp_tools, ) from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool -from app.chain import ChainBase +from app.chain.agent import AgentChain from app.runtime.config import settings from app.runtime.events import eventmanager from app.runtime.extensions.plugin_manager import PluginManager @@ -77,17 +76,12 @@ from app.db.oper.agenttask import AgentTaskOper from app.db.oper.user import UserOper from app.runtime.log import logger from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType +from app.schemas.agent import ReplyMode from app.schemas.message import ChannelCapabilityManager, ChannelCapability from app.schemas.types import ChainEventType, EventType, MessageChannel from app.foundation.identity import SYSTEM_INTERNAL_USER_ID -class AgentChain(ChainBase): - """Agent 业务处理链。""" - - pass - - def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None: """结束入站消息的渠道处理状态。""" if not status: @@ -321,15 +315,6 @@ class _ThinkTagStripper: self.buffer = "" -class ReplyMode(str, Enum): - """ - Agent 最终回复处理模式。 - """ - - DISPATCH = "dispatch" - CAPTURE_ONLY = "capture_only" - - HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_" UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。" AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败" diff --git a/app/agent/tools/impl/_plugin_tool_utils.py b/app/agent/tools/impl/_plugin_tool_utils.py index 5070f8354..049f7cbf9 100644 --- a/app/agent/tools/impl/_plugin_tool_utils.py +++ b/app/agent/tools/impl/_plugin_tool_utils.py @@ -70,14 +70,14 @@ def reload_plugin_runtime(plugin_id: str) -> None: 重载插件并重新注册其命令、定时任务和 API。 """ # 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。 - from app.api.endpoints.plugin import register_plugin_api - from app.command import Command - from app.scheduler import Scheduler + from app.application.plugins import register_plugin_api + from app.application.commands import init_commands + from app.application.scheduling import update_plugin_job plugin_manager = PluginManager() plugin_manager.reload_plugin(plugin_id) - Scheduler().update_plugin_job(plugin_id) - Command().init_commands(plugin_id) + update_plugin_job(plugin_id) + init_commands(plugin_id) register_plugin_api(plugin_id) @@ -333,8 +333,11 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: """ 按现有卸载逻辑移除插件,并清理运行态注册与分组信息。 """ - from app.api.endpoints.plugin import _remove_plugin_from_folders, remove_plugin_api - from app.scheduler import Scheduler + from app.application.plugins import ( + remove_plugin_api, + remove_plugin_from_folders, + ) + from app.application.scheduling import remove_plugin_job config_oper = SystemConfigOper() install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] @@ -343,7 +346,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: await config_oper.async_set(SystemConfigKey.UserInstalledPlugins, install_plugins) remove_plugin_api(plugin_id) - Scheduler().remove_plugin_job(plugin_id) + remove_plugin_job(plugin_id) plugin_manager = PluginManager() plugin_class = plugin_manager.plugins.get(plugin_id) @@ -362,7 +365,7 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]: except Exception: clone_files_removed = False - _remove_plugin_from_folders(plugin_id) + remove_plugin_from_folders(plugin_id) plugin_manager.remove_plugin(plugin_id) return { diff --git a/app/agent/tools/impl/create_agent_task.py b/app/agent/tools/impl/create_agent_task.py index d4383cc40..686a336f2 100644 --- a/app/agent/tools/impl/create_agent_task.py +++ b/app/agent/tools/impl/create_agent_task.py @@ -99,7 +99,7 @@ class CreateAgentTaskTool(MoviePilotTool): def _create_task(self, payload: CreateAgentTaskInput) -> dict: """持久化任务并立即注册到运行时调度器。""" - from app.scheduler import Scheduler + from app.application.scheduling import update_agent_task_job trigger_value = payload.trigger if payload.trigger_type == "date" and payload.delay_minutes is not None: @@ -130,8 +130,7 @@ class CreateAgentTaskTool(MoviePilotTool): source=self._source or (chat.source if chat else None), original_chat_id=chat.original_chat_id if chat else None, ) - scheduler = Scheduler() - next_run_at = scheduler.update_agent_task_job(task.id) + next_run_at = update_agent_task_job(task.id) return AgentTaskOper.to_dict( task, next_run_at=next_run_at, diff --git a/app/agent/tools/impl/delete_agent_task.py b/app/agent/tools/impl/delete_agent_task.py index 642f0d758..0f12a54ed 100644 --- a/app/agent/tools/impl/delete_agent_task.py +++ b/app/agent/tools/impl/delete_agent_task.py @@ -31,14 +31,14 @@ class DeleteAgentTaskTool(MoviePilotTool): def _delete_task(self, task_id: int) -> bool: """删除当前用户的任务并移除运行时调度。""" - from app.scheduler import Scheduler + from app.application.scheduling import remove_agent_task_job deleted = AgentTaskOper().delete( task_id=task_id, user_id=str(self._user_id), ) if deleted: - Scheduler().remove_agent_task_job(task_id) + remove_agent_task_job(task_id) return deleted async def run(self, task_id: int, **kwargs: object) -> str: diff --git a/app/agent/tools/impl/list_slash_commands.py b/app/agent/tools/impl/list_slash_commands.py index 4d2b65d47..89984c211 100644 --- a/app/agent/tools/impl/list_slash_commands.py +++ b/app/agent/tools/impl/list_slash_commands.py @@ -14,7 +14,6 @@ class ListSlashCommandsInput(BaseModel): """查询所有可用斜杠命令工具的输入参数模型""" - class ListSlashCommandsTool(MoviePilotTool): name: str = "list_slash_commands" tags: list[str] = [ @@ -41,10 +40,9 @@ class ListSlashCommandsTool(MoviePilotTool): logger.info(f"执行工具: {self.name}") try: - from app.command import Command + from app.application.commands import get_commands - command_obj = Command() - all_commands = command_obj.get_commands() + all_commands = get_commands() if not all_commands: return "当前没有可用的命令" diff --git a/app/agent/tools/impl/query_agent_tasks.py b/app/agent/tools/impl/query_agent_tasks.py index 9559e024a..e43780bb6 100644 --- a/app/agent/tools/impl/query_agent_tasks.py +++ b/app/agent/tools/impl/query_agent_tasks.py @@ -48,7 +48,7 @@ class QueryAgentTasksTool(MoviePilotTool): enabled: Optional[bool], ) -> list[dict]: """读取当前用户的任务及运行时下一次触发时间。""" - from app.scheduler import Scheduler + from app.application.scheduling import get_agent_task_next_run oper = AgentTaskOper() if task_id: @@ -56,12 +56,11 @@ class QueryAgentTasksTool(MoviePilotTool): tasks = [task] if task else [] else: tasks = oper.list(user_id=str(self._user_id), enabled=enabled) - scheduler = Scheduler() result = [] for task in tasks: data = oper.to_dict( task, - next_run_at=scheduler.get_agent_task_next_run(task.id), + next_run_at=get_agent_task_next_run(task.id), timezone=settings.TZ, ) if task_id: diff --git a/app/agent/tools/impl/query_schedulers.py b/app/agent/tools/impl/query_schedulers.py index bb787fed7..21a6eab11 100644 --- a/app/agent/tools/impl/query_schedulers.py +++ b/app/agent/tools/impl/query_schedulers.py @@ -39,13 +39,15 @@ class QuerySchedulersTool(MoviePilotTool): """查询非 Agent 自主任务的运行时定时服务。""" logger.info(f"执行工具: {self.name}") try: - from app.scheduler import AGENT_TASK_JOB_PREFIX, Scheduler + from app.application.scheduling import ( + AGENT_TASK_JOB_PREFIX, + list_scheduler_jobs, + ) - scheduler = Scheduler() agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-" schedulers = [ scheduler_item - for scheduler_item in scheduler.list() + for scheduler_item in list_scheduler_jobs() if not str(scheduler_item.id or "").startswith(agent_task_prefix) ] if schedulers: diff --git a/app/agent/tools/impl/run_agent_task.py b/app/agent/tools/impl/run_agent_task.py index 00009064a..10ebc4966 100644 --- a/app/agent/tools/impl/run_agent_task.py +++ b/app/agent/tools/impl/run_agent_task.py @@ -56,7 +56,7 @@ class RunAgentTaskTool(MoviePilotTool): async def run(self, task_id: int, **kwargs: object) -> str: """立即执行当前用户拥有且已启用的 Agent 自主定时任务。""" - from app.scheduler import Scheduler + from app.application.scheduling import start_agent_task payload = RunAgentTaskInput(task_id=task_id) status, task_name = await self.run_blocking( @@ -70,7 +70,7 @@ class RunAgentTaskTool(MoviePilotTool): return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行" if status == "running": return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发" - if not Scheduler().start_agent_task(payload.task_id): + if not start_agent_task(payload.task_id): return f"Agent 定时任务 {task_id} 尚未注册到运行时调度器,无法立即执行" return ( f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}。" diff --git a/app/agent/tools/impl/run_scheduler.py b/app/agent/tools/impl/run_scheduler.py index fb40b5435..bec87b5b1 100644 --- a/app/agent/tools/impl/run_scheduler.py +++ b/app/agent/tools/impl/run_scheduler.py @@ -46,12 +46,14 @@ class RunSchedulerTool(MoviePilotTool): @staticmethod def _run_scheduler_sync(job_id: str) -> tuple[bool, str]: """同步触发定时服务,避免调度器扫描阻塞事件循环。""" - from app.scheduler import Scheduler + from app.application.scheduling import ( + list_scheduler_jobs, + start_scheduler_job, + ) - scheduler = Scheduler() - for scheduler_item in scheduler.list(): + for scheduler_item in list_scheduler_jobs(): if scheduler_item.id == job_id: - scheduler.start(job_id) + start_scheduler_job(job_id) return True, scheduler_item.name return False, "" @@ -60,7 +62,7 @@ class RunSchedulerTool(MoviePilotTool): logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}") try: - from app.scheduler import AGENT_TASK_JOB_PREFIX + from app.application.scheduling import AGENT_TASK_JOB_PREFIX if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"): return ( diff --git a/app/agent/tools/impl/run_slash_command.py b/app/agent/tools/impl/run_slash_command.py index 565144bd9..5286cc08e 100644 --- a/app/agent/tools/impl/run_slash_command.py +++ b/app/agent/tools/impl/run_slash_command.py @@ -57,16 +57,15 @@ class RunSlashCommandTool(MoviePilotTool): if not command.startswith("/"): command = f"/{command}" - # 从全局 Command 单例中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令) - from app.command import Command + # 从命令注册表中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令) + from app.application.commands import get_command, get_commands cmd_name = command.split()[0] - command_obj = Command() - matched_command = command_obj.get(cmd_name) + matched_command = get_command(cmd_name) if not matched_command: # 列出所有可用命令帮助用户 - all_commands = command_obj.get_commands() + all_commands = get_commands() available_cmds = [ f"{cmd} - {info.get('description', '无描述')}" for cmd, info in all_commands.items() diff --git a/app/agent/tools/impl/update_agent_task.py b/app/agent/tools/impl/update_agent_task.py index 9f7afa213..a9c2689bc 100644 --- a/app/agent/tools/impl/update_agent_task.py +++ b/app/agent/tools/impl/update_agent_task.py @@ -100,7 +100,7 @@ class UpdateAgentTaskTool(MoviePilotTool): def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]: """更新当前用户的任务并刷新运行时调度。""" - from app.scheduler import Scheduler + from app.application.scheduling import update_agent_task_job oper = AgentTaskOper() task = oper.get(task_id=payload.task_id, user_id=str(self._user_id)) @@ -174,8 +174,7 @@ class UpdateAgentTaskTool(MoviePilotTool): if current and current.last_status == "running": return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"} return None - scheduler = Scheduler() - next_run_at = scheduler.update_agent_task_job(payload.task_id) + next_run_at = update_agent_task_job(payload.task_id) updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id)) return oper.to_dict( updated_task, diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 4ba7c565a..38784d409 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -12,7 +12,13 @@ from starlette.responses import StreamingResponse from app import schemas from app.api.response import ResponseAPIRouter -from app.command import Command +from app.application.plugins import ( + register_plugin_api, + remove_plugin_api, + remove_plugin_from_folders, +) +from app.application.commands import init_commands +from app.application.scheduling import remove_plugin_job, update_plugin_job from app.runtime.cache import async_fresh from app.runtime.config import settings from app.runtime.events import eventmanager @@ -26,22 +32,12 @@ from app.application.security.access import ( from app.db.models import User from app.db.oper.systemconfig import SystemConfigOper from app.api.deps import get_current_active_superuser, get_current_active_superuser_async -from app.factory import app from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.market import PluginHelper from app.runtime.log import logger -from app.scheduler import Scheduler from app.schemas.event import PluginDataResetEventData from app.schemas.types import ChainEventType, SystemConfigKey -PROTECTED_ROUTES = { - "/api/v1/openapi.json", - "/docs", - "/docs/oauth2-redirect", - "/redoc", -} -PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" - router = ResponseAPIRouter() _plugin_release_refresh_tasks: set[asyncio.Task] = set() @@ -106,117 +102,14 @@ def _schedule_plugin_release_refresh(plugin_id: str, repo_url: str) -> None: task.add_done_callback(_discard_task) -def register_plugin_api(plugin_id: Optional[str] = None): - """ - 动态注册插件 API - :param plugin_id: 插件 ID,如果为 None,则注册所有插件 - """ - _update_plugin_api_routes(plugin_id, action="add") - - -def remove_plugin_api(plugin_id: str): - """ - 动态移除单个插件的 API - :param plugin_id: 插件 ID - """ - _update_plugin_api_routes(plugin_id, action="remove") - - -def _update_plugin_api_routes(plugin_id: Optional[str], action: str): - """ - 插件 API 路由注册和移除 - :param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件 - 如果 action 为 "remove",plugin_id 必须是有效的插件 ID - :param action: "add" 或 "remove",决定是添加还是移除路由 - """ - if action not in {"add", "remove"}: - raise ValueError("Action must be 'add' or 'remove'") - - is_modified = False - existing_paths = {route.path: route for route in app.routes} - - plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids() - for plugin_id in plugin_ids: - routes_removed = _remove_routes(plugin_id) - if routes_removed: - is_modified = True - - if action != "add": - continue - # 获取插件的 API 路由信息 - plugin_apis = PluginManager().get_plugin_apis(plugin_id) - for api in plugin_apis: - api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}" - try: - api["path"] = api_path - allow_anonymous = api.pop("allow_anonymous", False) - auth_mode = api.pop("auth", "apikey") - dependencies = api.setdefault("dependencies", []) - if not allow_anonymous: - if ( - auth_mode == "bear" - and Depends(verify_token) not in dependencies - ): - dependencies.append(Depends(verify_token)) - elif Depends(verify_apikey) not in dependencies: - dependencies.append(Depends(verify_apikey)) - app.add_api_route(**api, tags=["plugin"]) - is_modified = True - logger.debug(f"Added plugin route: {api_path}") - except Exception as e: - logger.error(f"Error adding plugin route {api_path}: {str(e)}") - - if is_modified: - _clean_protected_routes(existing_paths) - app.openapi_schema = None - app.setup() - - -def _remove_routes(plugin_id: str) -> bool: - """ - 移除与单个插件相关的路由 - :param plugin_id: 插件 ID - :return: 是否有路由被移除 - """ - if not plugin_id: - return False - prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" - routes_to_remove = [ - route for route in app.routes if route.path.startswith(prefix) - ] - removed = False - for route in routes_to_remove: - try: - app.routes.remove(route) - removed = True - logger.debug(f"Removed plugin route: {route.path}") - except Exception as e: - logger.error(f"Error removing plugin route {route.path}: {str(e)}") - return removed - - -def _clean_protected_routes(existing_paths: dict): - """ - 清理受保护的路由,防止在插件操作中被删除或重复添加 - :param existing_paths: 当前应用的路由路径映射 - """ - for protected_route in PROTECTED_ROUTES: - try: - existing_route = existing_paths.get(protected_route) - if existing_route: - app.routes.remove(existing_route) - except Exception as e: - logger.error(f"Error removing protected route {protected_route}: {str(e)}") - - def register_plugin(plugin_id: str): """ 注册一个插件相关的服务 """ # 注册插件服务 - Scheduler().update_plugin_job(plugin_id) + update_plugin_job(plugin_id) # 注册菜单命令 - Command().init_commands(plugin_id) + init_commands(plugin_id) # 注册插件API register_plugin_api(plugin_id) @@ -1045,7 +938,7 @@ def uninstall_plugin( # 移除插件API remove_plugin_api(plugin_id) # 移除插件服务 - Scheduler().remove_plugin_job(plugin_id) + remove_plugin_job(plugin_id) # 判断是否为分身 plugin_manager = PluginManager() plugin_class = plugin_manager.plugins.get(plugin_id) @@ -1062,7 +955,7 @@ def uninstall_plugin( except Exception as e: logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}") # 从插件文件夹中移除该插件 - _remove_plugin_from_folders(plugin_id) + remove_plugin_from_folders(plugin_id) # 移除插件 plugin_manager.remove_plugin(plugin_id) return schemas.Response(success=True) @@ -1121,42 +1014,3 @@ def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str): except Exception as e: logger.error(f"处理插件文件夹时出错:{str(e)}") # 文件夹处理失败不影响插件分身创建的整体流程 - - -def _remove_plugin_from_folders(plugin_id: str): - """ - 从所有文件夹中移除指定的插件 - :param plugin_id: 要移除的插件ID - """ - try: - config_oper = SystemConfigOper() - # 获取插件文件夹配置 - folders = config_oper.get(SystemConfigKey.PluginFolders) or {} - - # 标记是否有修改 - modified = False - - # 遍历所有文件夹,移除指定插件 - for folder_name, folder_data in folders.items(): - if isinstance(folder_data, dict) and "plugins" in folder_data: - # 新格式:{"plugins": [...], "order": ..., "icon": ...} - if plugin_id in folder_data["plugins"]: - folder_data["plugins"].remove(plugin_id) - logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") - modified = True - elif isinstance(folder_data, list): - # 旧格式:直接是插件列表 - if plugin_id in folder_data: - folder_data.remove(plugin_id) - logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") - modified = True - - # 如果有修改,保存更新后的文件夹配置 - if modified: - config_oper.set(SystemConfigKey.PluginFolders, folders) - else: - logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") - - except Exception as e: - logger.error(f"从文件夹中移除插件时出错:{str(e)}") - # 文件夹处理失败不影响插件卸载的整体流程 diff --git a/app/application/agent.py b/app/application/agent.py new file mode 100644 index 000000000..cab4fd58f --- /dev/null +++ b/app/application/agent.py @@ -0,0 +1,100 @@ +"""Agent 编排服务门面。 + +chain 层需要触发 Agent 后台任务、渲染提示词、查询模型能力时统一经本模块调用。 +具体实现由 app.agent 在启动时注册,形成依赖倒置: + + chain -> application.agent <- agent(startup 在导入期注册) + +静态依赖图上 application 不依赖 agent,agent 作为入口层向 application +注册实现,从而拆除 chain <-> agent 的互指环。 + +注意:本模块禁止静态导入 app.agent 下的任何模块(含函数内导入), +否则会形成 agent -> chain -> application -> agent 的新环。 +未注册时的兜底注册由 startup/agent_initializer 在导入期完成。 +""" + +from typing import Any, Callable, Optional + +# 注册表:启动期由 startup/agent_initializer 填充。 +_agent_manager: Any = None +_prompt_manager: Any = None +_agent_capability_manager: Any = None +_llm_helper: Any = None +_manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None + + +def register_agent_services( + agent_manager: Any, + prompt_manager: Any, + capability_manager: Any, + llm_helper: Any, + manual_redo_prompt_builder: Optional[Callable[[Any], str]] = None, +) -> None: + """注册 Agent 服务实现(由 startup 组合根在导入期调用)。""" + global _agent_manager, _prompt_manager, _agent_capability_manager, _llm_helper + global _manual_redo_prompt_builder + _agent_manager = agent_manager + _prompt_manager = prompt_manager + _agent_capability_manager = capability_manager + _llm_helper = llm_helper + _manual_redo_prompt_builder = manual_redo_prompt_builder + + +def _ensure_registered() -> None: + """校验 Agent 服务已注册。 + + 正常启动路径由 startup/agent_initializer 在导入期注册;未注册时 + 直接抛出带指引的错误,避免在此处静态导入 app.agent 破坏依赖方向。 + """ + if _agent_manager is None: + raise RuntimeError( + "Agent 服务未注册:请先导入 app.startup.agent_initializer 完成组合根装配" + ) + + +def get_agent_manager() -> Any: + """返回 AgentManager 单例。""" + _ensure_registered() + return _agent_manager + + +def get_prompt_manager() -> Any: + """返回提示词管理器。""" + _ensure_registered() + return _prompt_manager + + +def supports_image_input( + provider: Optional[str] = None, + model: Optional[str] = None, + base_url: Optional[str] = None, + base_url_preset: Optional[str] = None, +) -> bool: + """判断当前模型是否启用了图片输入能力。""" + _ensure_registered() + return _llm_helper.supports_image_input( + provider=provider, + model=model, + base_url=base_url, + base_url_preset=base_url_preset, + ) + + +def is_audio_input_available() -> bool: + """判断语音输入能力是否可用。""" + _ensure_registered() + return _agent_capability_manager.is_audio_input_available() + + +def transcribe_audio(content: bytes, filename: str = "input.ogg") -> Optional[str]: + """把音频内容转写为文本。""" + _ensure_registered() + return _agent_capability_manager.transcribe_audio(content, filename=filename) + + +def build_manual_redo_prompt(history: Any) -> str: + """构造整理记录 AI 重新整理提示词(builder 由 agent 层注册)。""" + _ensure_registered() + if _manual_redo_prompt_builder is None: + raise RuntimeError("整理记录重新整理提示词构建器未注册") + return _manual_redo_prompt_builder(history) diff --git a/app/application/commands.py b/app/application/commands.py new file mode 100644 index 000000000..7aebbe229 --- /dev/null +++ b/app/application/commands.py @@ -0,0 +1,45 @@ +"""命令工具服务门面。 + +Agent 工具与 API 端点对命令注册表的操作统一经本模块调用, +Command 实现由 startup 组合根在导入期注册,避免 application 层 +静态依赖顶层 command 模块。 + +依赖方向: + + agent.tools / api.endpoints -> application.commands <- startup(注册 Command 类) +""" + +from typing import Any, Dict, Optional + +# Command 类:由 startup/command_initializer 在导入期注册。 +_command_class: Any = None + + +def register_command_class(command_class: Any) -> None: + """注册 Command 类(组合根在导入期调用)。""" + global _command_class + _command_class = command_class + + +def get_command_object() -> Any: + """返回命令注册表实例。""" + if _command_class is None: + raise RuntimeError( + "命令服务未初始化:请先通过 register_command_class 注册 Command 类" + ) + return _command_class() + + +def get_commands() -> Dict[str, Any]: + """返回全部已注册命令。""" + return get_command_object().get_commands() + + +def get_command(name: str) -> Optional[Any]: + """按命令名查询注册表。""" + return get_command_object().get(name) + + +def init_commands(plugin_id: Optional[str] = None) -> None: + """初始化命令(可指定单个插件)。""" + get_command_object().init_commands(plugin_id) diff --git a/app/application/plugins.py b/app/application/plugins.py new file mode 100644 index 000000000..178118e21 --- /dev/null +++ b/app/application/plugins.py @@ -0,0 +1,190 @@ +"""插件 API 动态路由服务。 + +把插件 API 的动态注册/移除从 HTTP 端点层下沉到 application 层: +FastAPI 实例由组合根(factory 创建应用后)注入,端点与 Agent 工具 +统一经本模块操作路由,消除 api.endpoints 对 factory 的反向依赖。 + +依赖方向: + + api.endpoints.plugin / agent.tools -> application.plugins <- factory(注入实例) +""" + +from typing import Optional + +from fastapi import Depends, FastAPI + +from app.application.security.access import verify_apikey, verify_token +from app.db.oper.systemconfig import SystemConfigOper +from app.runtime.config import settings +from app.runtime.extensions.plugin_manager import PluginManager +from app.runtime.log import logger +from app.schemas.types import SystemConfigKey + +PROTECTED_ROUTES = { + "/api/v1/openapi.json", + "/docs", + "/docs/oauth2-redirect", + "/redoc", +} +PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin" + +# FastAPI 应用实例:由 factory 在创建应用后调用 register_api_app 注入。 +_api_app: Optional[FastAPI] = None + + +def register_api_app(api_app: FastAPI) -> None: + """注入 FastAPI 应用实例(组合根在创建应用后调用)。""" + global _api_app + _api_app = api_app + + +def get_api_app() -> FastAPI: + """返回已注入的 FastAPI 应用实例。""" + if _api_app is None: + raise RuntimeError("插件路由服务未初始化:请先调用 register_api_app 注入应用实例") + return _api_app + + +def register_plugin_api(plugin_id: Optional[str] = None): + """ + 动态注册插件 API + :param plugin_id: 插件 ID,如果为 None,则注册所有插件 + """ + _update_plugin_api_routes(plugin_id, action="add") + + +def remove_plugin_api(plugin_id: str): + """ + 动态移除单个插件的 API + :param plugin_id: 插件 ID + """ + _update_plugin_api_routes(plugin_id, action="remove") + + +def _update_plugin_api_routes(plugin_id: Optional[str], action: str): + """ + 插件 API 路由注册和移除 + :param plugin_id: 插件 ID,如果 action 为 "add" 且 plugin_id 为 None,则处理所有插件 + 如果 action 为 "remove",plugin_id 必须是有效的插件 ID + :param action: "add" 或 "remove",决定是添加还是移除路由 + """ + if action not in {"add", "remove"}: + raise ValueError("Action must be 'add' or 'remove'") + + app = get_api_app() + is_modified = False + existing_paths = {route.path: route for route in app.routes} + + plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids() + for plugin_id in plugin_ids: + routes_removed = _remove_routes(plugin_id) + if routes_removed: + is_modified = True + + if action != "add": + continue + # 获取插件的 API 路由信息 + plugin_apis = PluginManager().get_plugin_apis(plugin_id) + for api in plugin_apis: + api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}" + try: + api["path"] = api_path + allow_anonymous = api.pop("allow_anonymous", False) + auth_mode = api.pop("auth", "apikey") + dependencies = api.setdefault("dependencies", []) + if not allow_anonymous: + if ( + auth_mode == "bear" + and Depends(verify_token) not in dependencies + ): + dependencies.append(Depends(verify_token)) + elif Depends(verify_apikey) not in dependencies: + dependencies.append(Depends(verify_apikey)) + app.add_api_route(**api, tags=["plugin"]) + is_modified = True + logger.debug(f"Added plugin route: {api_path}") + except Exception as e: + logger.error(f"Error adding plugin route {api_path}: {str(e)}") + + if is_modified: + _clean_protected_routes(existing_paths) + app.openapi_schema = None + app.setup() + + +def _remove_routes(plugin_id: str) -> bool: + """ + 移除与单个插件相关的路由 + :param plugin_id: 插件 ID + :return: 是否有路由被移除 + """ + if not plugin_id: + return False + app = get_api_app() + prefix = f"{PLUGIN_PREFIX}/{plugin_id}/" + routes_to_remove = [ + route for route in app.routes if route.path.startswith(prefix) + ] + removed = False + for route in routes_to_remove: + try: + app.routes.remove(route) + removed = True + logger.debug(f"Removed plugin route: {route.path}") + except Exception as e: + logger.error(f"Error removing plugin route {route.path}: {str(e)}") + return removed + + +def _clean_protected_routes(existing_paths: dict): + """ + 清理受保护的路由,防止在插件操作中被删除或重复添加 + :param existing_paths: 当前应用的路由路径映射 + """ + app = get_api_app() + for protected_route in PROTECTED_ROUTES: + try: + existing_route = existing_paths.get(protected_route) + if existing_route: + app.routes.remove(existing_route) + except Exception as e: + logger.error(f"Error removing protected route {protected_route}: {str(e)}") + + +def remove_plugin_from_folders(plugin_id: str): + """ + 从所有文件夹中移除指定的插件 + :param plugin_id: 要移除的插件ID + """ + try: + config_oper = SystemConfigOper() + # 获取插件文件夹配置 + folders = config_oper.get(SystemConfigKey.PluginFolders) or {} + + # 标记是否有修改 + modified = False + + # 遍历所有文件夹,移除指定插件 + for folder_name, folder_data in folders.items(): + if isinstance(folder_data, dict) and "plugins" in folder_data: + # 新格式:{"plugins": [...], "order": ..., "icon": ...} + if plugin_id in folder_data["plugins"]: + folder_data["plugins"].remove(plugin_id) + logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") + modified = True + elif isinstance(folder_data, list): + # 旧格式:直接是插件列表 + if plugin_id in folder_data: + folder_data.remove(plugin_id) + logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}") + modified = True + + # 如果有修改,保存更新后的文件夹配置 + if modified: + config_oper.set(SystemConfigKey.PluginFolders, folders) + else: + logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除") + + except Exception as e: + logger.error(f"从文件夹中移除插件时出错:{str(e)}") + # 文件夹处理失败不影响插件卸载的整体流程 diff --git a/app/application/scheduling.py b/app/application/scheduling.py new file mode 100644 index 000000000..4d8e2dfec --- /dev/null +++ b/app/application/scheduling.py @@ -0,0 +1,73 @@ +"""调度器工具服务门面。 + +Agent 工具与 API 端点对运行时调度器的操作统一经本模块调用, +Scheduler 实现由 startup 组合根在导入期注册,避免 application 层 +静态依赖顶层 scheduler 模块(scheduler 反向依赖 chain,会成环)。 + +依赖方向: + + agent.tools / api.endpoints -> application.scheduling <- startup(注册 Scheduler 类) +""" + +from typing import Any, List, Optional + +# Agent 自主定时任务在运行时调度器中的任务 ID 前缀。 +AGENT_TASK_JOB_PREFIX = "agent-task" + +# Scheduler 类:由 startup/scheduler_initializer 在导入期注册。 +_scheduler_class: Any = None + + +def register_scheduler_class(scheduler_class: Any) -> None: + """注册 Scheduler 类(组合根在导入期调用)。""" + global _scheduler_class + _scheduler_class = scheduler_class + + +def get_scheduler() -> Any: + """返回调度器实例。""" + if _scheduler_class is None: + raise RuntimeError( + "调度器服务未初始化:请先通过 register_scheduler_class 注册 Scheduler 类" + ) + return _scheduler_class() + + +def list_scheduler_jobs() -> List[Any]: + """列出运行时调度器的全部任务。""" + return get_scheduler().list() + + +def start_scheduler_job(job_id: str) -> None: + """立即运行指定的运行时定时任务。""" + get_scheduler().start(job_id) + + +def update_plugin_job(plugin_id: str) -> None: + """更新插件的定时任务。""" + get_scheduler().update_plugin_job(plugin_id) + + +def remove_plugin_job(plugin_id: str) -> None: + """移除插件的定时任务。""" + get_scheduler().remove_plugin_job(plugin_id) + + +def start_agent_task(task_id: int) -> bool: + """立即执行 Agent 自主定时任务。""" + return get_scheduler().start_agent_task(task_id) + + +def get_agent_task_next_run(task_id: int) -> Optional[Any]: + """查询 Agent 自主定时任务的下一次运行时间。""" + return get_scheduler().get_agent_task_next_run(task_id) + + +def update_agent_task_job(task_id: int) -> Optional[Any]: + """更新 Agent 自主定时任务的注册信息,返回下一次运行时间。""" + return get_scheduler().update_agent_task_job(task_id) + + +def remove_agent_task_job(task_id: int) -> None: + """移除 Agent 自主定时任务的注册信息。""" + get_scheduler().remove_agent_task_job(task_id) diff --git a/app/application/transfer.py b/app/application/transfer.py index d7b2ac888..6bdbdf50f 100644 --- a/app/application/transfer.py +++ b/app/application/transfer.py @@ -13,20 +13,38 @@ app.schemas -> app.schemas.transfer -> app.domain.* -> app.schemas.types -> app. TransferJob / TransferJobTask,那两个用 app.schemas 的同名 DTO——一个是工作项,一个是 视图,分开表达之后两边都不必再迁就对方。 """ +import asyncio +import threading +from copy import deepcopy from pathlib import Path -from typing import Callable, List, Optional, Union +from time import monotonic +from typing import Callable, Dict, List, Optional, Tuple, Union from pydantic import BaseModel, ConfigDict +from app import schemas +from app.adapters.system.host import SystemUtils +from app.application.agent import get_agent_manager, get_prompt_manager from app.domain.context import MediaInfo, MusicInfo +from app.domain.media import normalize_music_type from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.foundation import text as text_tools +from app.runtime.log import logger +from app.schemas.agent import ReplyMode from app.schemas.file import FileItem from app.schemas.history import DownloadHistory -from app.schemas.media import OptionalMediaIdentityMixin +from app.schemas.media import OptionalMediaIdentityMixin, resolve_media_identity from app.schemas.system import TransferDirectoryConf from app.schemas.tmdb import TmdbEpisode -from app.schemas.transfer import TransferInfo -from app.schemas.types import MediaSource, MediaType +from app.schemas.transfer import TransferInfo, TransferJob, TransferJobTask +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + MediaSource, + MediaType, +) + class TransferTask(OptionalMediaIdentityMixin, BaseModel): @@ -89,3 +107,869 @@ class TransferQueue(BaseModel): callback: Optional[Callable] = None # 整理结果 result: Optional[TransferInfo] = None + + +# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。 +job_lock = threading.Lock() + +class JobManager: + """ + 作业管理器 + task任务负责一个文件的整理,job作业负责一个媒体的整理 + """ + + # 整理中的作业 + _job_view: Dict[Tuple, TransferJob] = {} + # 汇总季集清单 + _season_episodes: Dict[Tuple, List[int]] = {} + # 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业 + _meta_to_media_ids: Dict[Tuple, set[Tuple]] = {} + # 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用 + _task_state_changed_at: Dict[Tuple[str, str], float] = {} + # 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活 + _active_executions: set[Tuple[str, str]] = set() + + def __init__(self): + self._job_view = {} + self._season_episodes = {} + self._meta_to_media_ids = {} + self._task_state_changed_at = {} + self._active_executions = set() + + @staticmethod + def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple: + """ + 获取元数据ID + """ + return meta.name, season + + @staticmethod + def __get_media_id(media: Optional[Union[MediaInfo, MusicInfo]] = None, + season: Optional[int] = None) -> Tuple: + """ + 获取媒体ID;音乐额外区分实体类型,并为无远端ID的曲目构造稳定身份。 + """ + if not media: + return None, season + source, media_id = resolve_media_identity(media=media) + if getattr(media, "type", None) == MediaType.MUSIC: + music_type = normalize_music_type( + getattr(media, "music_type", None), + ) or MUSIC_ENTITY_RECORDING + if source and media_id: + return "music", source, media_id, music_type + + artists = tuple( + text_tools.normalize_upper(artist) + for artist in (getattr(media, "artists", None) or []) + if text_tools.normalize_upper(artist) + ) + if music_type == MUSIC_ENTITY_ALBUM: + album_artist = text_tools.normalize_upper( + getattr(media, "album_artist", None) + or (artists[0] if artists else "") + ) + album = text_tools.normalize_upper( + getattr(media, "album", None) or getattr(media, "title", None) or "" + ) + return "music", "local", music_type, album_artist, album, getattr(media, "year", None) + + return ( + "music", + "local", + music_type, + artists, + text_tools.normalize_upper(getattr(media, "title", None) or ""), + text_tools.normalize_upper(getattr(media, "album", None) or ""), + getattr(media, "disc_number", None), + getattr(media, "track_number", None), + ) + return (source, media_id), season + + @staticmethod + def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]: + """ + 获取源文件唯一键,用于跨媒体作业识别同一个整理任务。 + """ + if not fileitem or not fileitem.path: + return None + normalized_path = ( + Path(str(fileitem.path).replace("\\", "/")).as_posix().rstrip("/") or "/" + ) + return fileitem.storage or "local", normalized_path + + def __get_id(self, task: TransferTask = None) -> Tuple: + """ + 获取作业ID + """ + if task.mediainfo: + return self.__get_media_id( + media=task.mediainfo, season=task.meta.begin_season + ) + else: + return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) + + def get_job_id(self, task: TransferTask) -> Tuple: + """返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。""" + return self.__get_id(task) + + @staticmethod + def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]: + """ + 获取媒体信息 + """ + if task.mediainfo: + # 有媒体信息 + mediainfo = deepcopy(task.mediainfo) + mediainfo.clear() + if isinstance(mediainfo, MusicInfo): + return schemas.MusicInfo(**mediainfo.to_dict()) + return schemas.MediaInfo(**mediainfo.to_dict()) + else: + # 没有媒体信息 + meta: MetaBase = task.meta + if isinstance(meta, MetaMusic): + # 未识别的音乐按已解析元数据兜底展示;音乐年份为 int, + # 不能复用 MediaInfo(year 为 str),否则触发 pydantic 校验异常 + return schemas.MusicInfo( + title=meta.name, + artists=list(meta.artists or []), + artist=meta.artist, + album=meta.album, + album_artist=meta.album_artist, + year=meta.year, + title_year=f"{meta.name} ({meta.year})" if meta.year else meta.name, + media_source=meta.media_source, + media_id=meta.media_id, + ) + return schemas.MediaInfo( + title=meta.name, + year=meta.year, + title_year=f"{meta.name} ({meta.year})", + type=meta.type.value if meta.type else None, + ) + + @staticmethod + def __get_meta(task: TransferTask) -> schemas.MetaInfo: + """ + 获取元数据 + """ + if isinstance(task.meta, MetaMusic): + return schemas.MusicMeta(**task.meta.to_dict()) + return schemas.MetaInfo(**task.meta.to_dict()) + + def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: + """ + 添加整理任务,自动分组到对应的作业中 + :return: True表示任务已添加,False表示任务无效或已存在(重复) + """ + if not all([task, task.meta, task.fileitem]): + return False + file_key = self.__get_file_key(task.fileitem) + if not file_key: + return False + with job_lock: + __mediaid__ = self.__get_id(task) + # 同一个源文件可能在识别前后落入不同作业,必须跨作业去重。 + if any( + self.__get_file_key(t.fileitem) == file_key + for job in self._job_view.values() + for t in job.tasks + ): + logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加") + return False + if __mediaid__ not in self._job_view: + self._job_view[__mediaid__] = TransferJob( + media=self.__get_media(task), + season=task.meta.begin_season, + tasks=[ + TransferJobTask( + fileitem=task.fileitem, + meta=self.__get_meta(task), + downloader=task.downloader, + download_hash=task.download_hash, + state=state, + ) + ], + ) + else: + # 不重复添加任务 + if any( + [ + self.__get_file_key(t.fileitem) == file_key + for t in self._job_view[__mediaid__].tasks + ] + ): + logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加") + return False + self._job_view[__mediaid__].tasks.append( + TransferJobTask( + fileitem=task.fileitem, + meta=self.__get_meta(task), + downloader=task.downloader, + download_hash=task.download_hash, + state=state, + ) + ) + self._task_state_changed_at[file_key] = monotonic() + # 添加季集信息 + if self._season_episodes.get(__mediaid__): + self._season_episodes[__mediaid__].extend(task.meta.episode_list) + self._season_episodes[__mediaid__] = list( + set(self._season_episodes[__mediaid__]) + ) + else: + self._season_episodes[__mediaid__] = task.meta.episode_list + return True + + def migrate_task(self, task: TransferTask) -> bool: + """ + 将任务从 meta 作业迁移到 media 作业 + """ + curr_task, source_job_id = self.__remove_task_with_job_id( + task.fileitem, preserve_execution=True + ) + if not self.add_task(task, state=curr_task.state if curr_task else "waiting"): + return False + if curr_task and task.mediainfo: + metaid = self.__get_meta_id( + meta=task.meta, season=task.meta.begin_season + ) + mediaid = self.__get_id(task) + if source_job_id == metaid and mediaid != metaid: + with job_lock: + self._meta_to_media_ids.setdefault(metaid, set()).add(mediaid) + return True + + def __is_job_done(self, job_id: Tuple) -> bool: + """ + 检查指定作业是否已完成 + """ + if job_id not in self._job_view: + return True + return all( + task.state in ["completed", "failed"] + for task in self._job_view[job_id].tasks + ) + + def __pop_job(self, job_id: Tuple): + """ + 移除指定作业和对应季集缓存 + """ + job = self._job_view.pop(job_id, None) + self._season_episodes.pop(job_id, None) + if not job: + return + for task in job.tasks: + file_key = self.__get_file_key(task.fileitem) + if file_key: + self._task_state_changed_at.pop(file_key, None) + self._active_executions.discard(file_key) + + def __remove_done_job_groups(self, job_ids: set[Tuple]): + """ + 清理已进入终态的独立作业或关联作业组。 + """ + candidates = set(job_ids) + for metaid, mediaids in list(self._meta_to_media_ids.items()): + related_ids = {metaid, *mediaids} + if not related_ids.intersection(candidates): + continue + if all(self.__is_job_done(job_id) for job_id in related_ids): + for job_id in related_ids: + self.__pop_job(job_id) + self._meta_to_media_ids.pop(metaid, None) + candidates.difference_update(related_ids) + + referenced_ids = { + job_id + for metaid, mediaids in self._meta_to_media_ids.items() + for job_id in {metaid, *mediaids} + } + for job_id in candidates - referenced_ids: + if self.__is_job_done(job_id): + self.__pop_job(job_id) + + def start_execution(self, task: TransferTask): + """ + 标记任务仍由主程序整理线程直接执行。 + + :param task: 整理任务 + """ + if not task or not task.fileitem: + return + file_key = self.__get_file_key(task.fileitem) + if not file_key: + return + with job_lock: + self._active_executions.add(file_key) + + def finish_execution(self, task: TransferTask): + """ + 结束主程序整理线程对任务的直接执行标记。 + + :param task: 整理任务 + """ + if not task or not task.fileitem: + return + file_key = self.__get_file_key(task.fileitem) + if not file_key: + return + with job_lock: + self._active_executions.discard(file_key) + + def expire_stale_running_tasks( + self, timeout_seconds: int + ) -> List[Tuple[FileItem, int]]: + """ + 将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。 + + 主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务 + 误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。 + + :param timeout_seconds: 失活超时秒数,小于等于 0 时禁用 + :return: 已失活任务及其无心跳秒数 + """ + if timeout_seconds <= 0: + return [] + + current_time = monotonic() + expired: List[Tuple[FileItem, int]] = [] + affected_job_ids: set[Tuple] = set() + with job_lock: + for mediaid, job in self._job_view.items(): + for task in job.tasks: + file_key = self.__get_file_key(task.fileitem) + if ( + not file_key + or task.state != "running" + or file_key in self._active_executions + ): + continue + updated_at = self._task_state_changed_at.get(file_key, current_time) + inactive_seconds = current_time - updated_at + if inactive_seconds < timeout_seconds: + continue + task.state = "failed" + self._task_state_changed_at[file_key] = current_time + episodes = getattr(task.meta, "episode_list", None) or [] + if mediaid in self._season_episodes: + self._season_episodes[mediaid] = list( + set(self._season_episodes[mediaid]) - set(episodes) + ) + expired.append((task.fileitem, int(inactive_seconds))) + affected_job_ids.add(mediaid) + + self.__remove_done_job_groups(affected_job_ids) + return expired + + def running_task(self, task: TransferTask): + """ + 设置任务为运行中,并刷新外部异步任务的状态心跳。 + """ + with job_lock: + __mediaid__ = self.__get_id(task) + if __mediaid__ not in self._job_view: + return + # 更新状态 + for t in self._job_view[__mediaid__].tasks: + if t.fileitem == task.fileitem: + t.state = "running" + file_key = self.__get_file_key(t.fileitem) + if file_key: + self._task_state_changed_at[file_key] = monotonic() + break + + def finish_task(self, task: TransferTask): + """ + 设置任务为完成/成功 + """ + with job_lock: + __mediaid__ = self.__get_id(task) + if __mediaid__ not in self._job_view: + return + # 更新状态 + for t in self._job_view[__mediaid__].tasks: + if t.fileitem == task.fileitem: + t.state = "completed" + file_key = self.__get_file_key(t.fileitem) + if file_key: + self._task_state_changed_at[file_key] = monotonic() + break + + def fail_task(self, task: TransferTask): + """ + 设置任务为失败 + """ + with job_lock: + __mediaid__ = self.__get_id(task) + if __mediaid__ not in self._job_view: + return + # 更新状态 + for t in self._job_view[__mediaid__].tasks: + if t.fileitem == task.fileitem: + t.state = "failed" + file_key = self.__get_file_key(t.fileitem) + if file_key: + self._task_state_changed_at[file_key] = monotonic() + break + # 移除剧集信息 + if __mediaid__ in self._season_episodes: + self._season_episodes[__mediaid__] = list( + set(self._season_episodes[__mediaid__]) + - set(task.meta.episode_list) + ) + + def fail_unfinished_task(self, task: TransferTask): + """ + 将指定任务视图中的非终态任务标记为失败 + """ + if not task or not task.fileitem: + return + file_key = self.__get_file_key(task.fileitem) + if not file_key: + return + with job_lock: + for mediaid, job in self._job_view.items(): + for job_task in job.tasks: + if self.__get_file_key(job_task.fileitem) != file_key: + continue + if job_task.state not in ["completed", "failed"]: + job_task.state = "failed" + self._task_state_changed_at[file_key] = monotonic() + if mediaid in self._season_episodes: + self._season_episodes[mediaid] = list( + set(self._season_episodes[mediaid]) + - set(task.meta.episode_list) + ) + return + + def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]: + """ + 根据文件项移除任务 + """ + task, _ = self.__remove_task_with_job_id(fileitem) + return task + + def __remove_task_with_job_id( + self, + fileitem: FileItem, + preserve_execution: bool = False, + ) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]: + """ + 根据文件项移除任务,并返回任务所在的作业ID + """ + file_key = self.__get_file_key(fileitem) + if not file_key: + return None, None + with job_lock: + for mediaid in list(self._job_view): + job = self._job_view[mediaid] + for task in job.tasks: + if self.__get_file_key(task.fileitem) == file_key: + job.tasks.remove(task) + self._task_state_changed_at.pop(file_key, None) + if not preserve_execution: + self._active_executions.discard(file_key) + # 如果没有作业了,则移除作业 + if not job.tasks: + self._job_view.pop(mediaid) + # 移除季集信息 + if mediaid in self._season_episodes: + episodes = getattr(task.meta, "episode_list", None) or [] + self._season_episodes[mediaid] = list( + set(self._season_episodes[mediaid]) + - set(episodes) + ) + return task, mediaid + return None, None + + def remove_job(self, task: TransferTask) -> Optional[TransferJob]: + """ + 移除任务对应的作业(强制,线程不安全) + """ + with job_lock: + __mediaid__ = self.__get_id(task) + if __mediaid__ in self._job_view: + job = self._job_view[__mediaid__] + self.__pop_job(__mediaid__) + return job + return None + + def try_remove_job(self, task: TransferTask): + """ + 尝试移除任务对应的作业(严格检查未完成作业,线程安全) + """ + with job_lock: + __metaid__ = self.__get_meta_id( + meta=task.meta, season=task.meta.begin_season + ) + __mediaid__ = self.__get_media_id( + media=task.mediainfo, season=task.meta.begin_season + ) + + related_media_ids = set(self._meta_to_media_ids.get(__metaid__, set())) + if task.mediainfo: + related_media_ids.add(__mediaid__) + + meta_done = self.__is_job_done(__metaid__) + media_done = all( + self.__is_job_done(mediaid) for mediaid in related_media_ids + ) + + if meta_done and media_done: + remove_ids = {__metaid__, self.__get_id(task), *related_media_ids} + for job_id in remove_ids: + self.__pop_job(job_id) + self._meta_to_media_ids.pop(__metaid__, None) + + def is_done(self, task: TransferTask) -> bool: + """ + 检查任务对应的作业是否整理完成(不管成功还是失败) + """ + with job_lock: + __metaid__ = self.__get_meta_id( + meta=task.meta, season=task.meta.begin_season + ) + __mediaid__ = self.__get_media_id( + media=task.mediainfo, season=task.meta.begin_season + ) + if __metaid__ in self._job_view: + meta_done = all( + task.state in ["completed", "failed"] + for task in self._job_view[__metaid__].tasks + ) + else: + meta_done = True + if __mediaid__ in self._job_view: + media_done = all( + task.state in ["completed", "failed"] + for task in self._job_view[__mediaid__].tasks + ) + else: + media_done = True + return meta_done and media_done + + def is_finished(self, task: TransferTask) -> bool: + """ + 检查任务对应的作业是否已完成且有成功的记录 + """ + with job_lock: + __metaid__ = self.__get_meta_id( + meta=task.meta, season=task.meta.begin_season + ) + __mediaid__ = self.__get_media_id( + media=task.mediainfo, season=task.meta.begin_season + ) + if __metaid__ in self._job_view: + meta_finished = all( + task.state in ["completed", "failed"] + for task in self._job_view[__metaid__].tasks + ) + else: + meta_finished = True + if __mediaid__ in self._job_view: + tasks = self._job_view[__mediaid__].tasks + media_finished = all( + task.state in ["completed", "failed"] for task in tasks + ) and any(task.state == "completed" for task in tasks) + else: + media_finished = True + return meta_finished and media_finished + + def is_success(self, task: TransferTask) -> bool: + """ + 检查任务对应的作业是否全部成功 + """ + with job_lock: + __metaid__ = self.__get_meta_id( + meta=task.meta, season=task.meta.begin_season + ) + __mediaid__ = self.__get_media_id( + media=task.mediainfo, season=task.meta.begin_season + ) + if __metaid__ in self._job_view: + meta_success = all( + task.state in ["completed"] + for task in self._job_view[__metaid__].tasks + ) + else: + meta_success = True + if __mediaid__ in self._job_view: + media_success = all( + task.state in ["completed"] + for task in self._job_view[__mediaid__].tasks + ) + else: + media_success = True + return meta_success and media_success + + def get_all_torrent_hashes(self) -> set[str]: + """ + 获取所有种子的哈希值集合 + """ + with job_lock: + return { + task.download_hash + for job in self._job_view.values() + for task in job.tasks + } + + def is_torrent_done(self, download_hash: str) -> bool: + """ + 检查指定种子的所有任务是否都已完成 + """ + with job_lock: + if any( + task.state not in {"completed", "failed"} + for job in self._job_view.values() + for task in job.tasks + if task.download_hash == download_hash + ): + return False + return True + + def is_torrent_success(self, download_hash: str) -> bool: + """ + 检查指定种子的所有任务是否都已成功 + """ + with job_lock: + if any( + task.state != "completed" + for job in self._job_view.values() + for task in job.tasks + if task.download_hash == download_hash + ): + return False + return True + + def has_tasks( + self, + meta: MetaBase, + mediainfo: Optional[MediaInfo] = None, + season: Optional[int] = None, + ) -> bool: + """ + 判断作业是否还有任务正在处理 + """ + with job_lock: + if mediainfo: + __mediaid__ = self.__get_media_id(media=mediainfo, season=season) + if __mediaid__ in self._job_view: + return True + + __metaid__ = self.__get_meta_id(meta=meta, season=season) + return ( + __metaid__ in self._job_view + and len(self._job_view[__metaid__].tasks) > 0 + ) + + def success_tasks( + self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None + ) -> List[TransferJobTask]: + """ + 获取作业中所有成功的任务 + """ + with job_lock: + __mediaid__ = self.__get_media_id(media=media, season=season) + if __mediaid__ not in self._job_view: + return [] + return [ + task + for task in self._job_view[__mediaid__].tasks + if task.state == "completed" + ] + + def all_tasks( + self, media: MediaInfo, season: Optional[int] = None + ) -> List[TransferJobTask]: + """ + 获取作业中全部任务 + """ + with job_lock: + __mediaid__ = self.__get_media_id(media=media, season=season) + if __mediaid__ not in self._job_view: + return [] + return self._job_view[__mediaid__].tasks + + def count(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int: + """ + 获取作业中成功总数 + """ + with job_lock: + __mediaid__ = self.__get_media_id(media=media, season=season) + if __mediaid__ not in self._job_view: + return 0 + return len( + [ + task + for task in self._job_view[__mediaid__].tasks + if task.state == "completed" + ] + ) + + def size(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int: + """ + 获取作业中所有成功文件总大小 + """ + with job_lock: + __mediaid__ = self.__get_media_id(media=media, season=season) + if __mediaid__ not in self._job_view: + return 0 + return sum( + [ + task.fileitem.size + if task.fileitem.size is not None + else ( + SystemUtils.get_directory_size(Path(task.fileitem.path)) + if task.fileitem.storage == "local" + else 0 + ) + for task in self._job_view[__mediaid__].tasks + if task.state == "completed" + ] + ) + + def total(self) -> int: + """ + 获取所有任务总数 + """ + with job_lock: + return sum([len(job.tasks) for job in self._job_view.values()]) + + def pending_total(self) -> int: + """ + 获取未到终态的任务总数。 + + 作业要等关联任务全部终态才整体移除,追更/分批场景下已完成任务会 + 跨批次残留在视图中;批次统计若用全量 total() 会把历史任务计入 + 「当前共 N 个文件」并压低进度百分比,因此只数未终态任务。 + """ + with job_lock: + return sum( + 1 + for job in self._job_view.values() + for task in job.tasks + if task.state not in ("completed", "failed") + ) + + def list_jobs(self) -> List[TransferJob]: + """ + 获取所有作业的任务列表 + """ + with job_lock: + return list(self._job_view.values()) + + def season_episodes( + self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None + ) -> List[int]: + """ + 获取作业的季集清单 + """ + with job_lock: + __mediaid__ = self.__get_media_id(media=media, season=season) + return self._season_episodes.get(__mediaid__) or [] + + +class FailedRetryScheduler: + """ + 负责失败整理记录的 debounce 聚合与 AI 重试调度。 + """ + + RETRY_TRANSFER_DEBOUNCE_SECONDS = 300 + + def __init__(self): + super().__init__() + self._retry_transfer_buffer: dict[str, list[int]] = {} + self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {} + self._retry_transfer_lock = asyncio.Lock() + + async def close(self): + async with self._retry_transfer_lock: + timers = list(self._retry_transfer_timers.values()) + self._retry_transfer_timers.clear() + self._retry_transfer_buffer.clear() + + for timer in timers: + timer.cancel() + + @staticmethod + def _build_retry_transfer_template_context( + history_ids: list[int], + ) -> tuple[str, dict[str, int | str]]: + """仅负责把失败重试任务的动态数据映射成模板变量。""" + is_batch = len(history_ids) > 1 + task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry" + template_context: dict[str, int | str] = { + "history_ids_csv": ", ".join(str(item) for item in history_ids), + "history_count": len(history_ids), + } + if not is_batch: + template_context["history_id"] = history_ids[0] + return task_type, template_context + + def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str: + """根据失败记录数量构建统一的重试整理后台任务提示词。""" + task_type, template_context = self._build_retry_transfer_template_context(history_ids) + return get_prompt_manager().render_system_task_message( + task_type, + template_context=template_context, + ) + + async def schedule_retry(self, history_id: int, group_key: str = ""): + """ + 同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。 + """ + if not group_key: + group_key = f"_default_{history_id}" + + async with self._retry_transfer_lock: + if group_key not in self._retry_transfer_buffer: + self._retry_transfer_buffer[group_key] = [] + if history_id not in self._retry_transfer_buffer[group_key]: + self._retry_transfer_buffer[group_key].append(history_id) + logger.info( + f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 " + f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)" + ) + + if group_key in self._retry_transfer_timers: + self._retry_transfer_timers[group_key].cancel() + + loop = asyncio.get_running_loop() + self._retry_transfer_timers[group_key] = loop.call_later( + self.RETRY_TRANSFER_DEBOUNCE_SECONDS, + lambda gk=group_key: asyncio.create_task(self._flush_retry_transfer(gk)), + ) + + async def _flush_retry_transfer(self, group_key: str): + """ + 延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。 + """ + async with self._retry_transfer_lock: + history_ids = self._retry_transfer_buffer.pop(group_key, []) + self._retry_transfer_timers.pop(group_key, None) + + if not history_ids: + return + + ids_str = ", ".join(str(item) for item in history_ids) + logger.info( + f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})" + ) + + try: + await get_agent_manager().run_background_prompt( + message=self._build_retry_transfer_prompt(history_ids), + session_prefix="__agent_retry_transfer_batch", + reply_mode=ReplyMode.DISPATCH, + ) + logger.info( + f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})" + ) + except Exception as err: + logger.error( + f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}" + ) + + diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 9d5e95234..b6f9b4749 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -5,27 +5,21 @@ import inspect import pickle import traceback from abc import ABCMeta -from collections.abc import Callable, Sequence +from collections.abc import Callable from datetime import datetime from pathlib import Path from typing import Optional, Any, Tuple, List, Set, Union, Dict from fastapi.concurrency import run_in_threadpool -from app.runtime.cache import FileCache, AsyncFileCache, fresh, async_fresh -from app.runtime.config import settings -from app.domain.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo -from app.runtime.events import Event, EventManager +from app.runtime.cache import FileCache, AsyncFileCache +from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo +from app.runtime.events import EventManager from app.domain.meta.metabase import MetaBase -from app.domain.meta.metamusic import MetaMusic from app.runtime.extensions.module_manager import ModuleManager from app.runtime.extensions.plugin_manager import PluginManager from app.db.oper.message import MessageOper -from app.db.oper.systemconfig import SystemConfigOper -from app.db.oper.user import UserOper -from app.application.messaging.message import MessageHelper, MessageQueueManager, MessageTemplateHelper -from app.adapters.external.server import MoviePilotServerHelper -from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.application.messaging.message import MessageHelper, MessageQueueManager from app.runtime.log import logger from app.schemas import ( RateLimitExceededException, @@ -33,17 +27,12 @@ from app.schemas import ( ExistMediaInfo, DownloaderTorrent, CommingMessage, - Notification, WebhookEventInfo, TmdbEpisode, MediaPerson, FileItem, TransferDirectoryConf, - MessageResponse, ) -from app.foundation.identity import normalize_internal_user_id -from app.schemas.media import normalize_media_source, resolve_media_identity -from app.schemas.message import ChannelCapability, ChannelCapabilityManager from app.schemas.category import CategoryConfig from app.schemas.types import ( TorrentStatus, @@ -51,15 +40,14 @@ from app.schemas.types import ( MediaSourceSelection, MediaImageType, EventType, - ChainEventType, - MessageChannel, - MediaSource, - SystemConfigKey, ) from app.foundation.reflection import ObjectUtils +from app.chain._messaging import MessageProcessingMixin, NotificationMixin +from app.chain._recognition import RecognitionMixin -class ChainBase(metaclass=ABCMeta): +class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, + metaclass=ABCMeta): """ 处理链基类 """ @@ -129,96 +117,6 @@ class ChainBase(metaclass=ABCMeta): """ self.filecache.delete(filename) - def start_message_processing_status( - self, - channel: MessageChannel, - source: Optional[str], - userid: Optional[Union[str, int]] = None, - message_id: Optional[Union[str, int]] = None, - chat_id: Optional[Union[str, int]] = None, - text: Optional[str] = None, - ) -> Optional[dict]: - """ - 启动渠道侧消息输入/处理状态。 - 具体表现由消息模块实现,例如 typing 保活或消息 reaction。 - """ - if not channel or not ChannelCapabilityManager.supports_capability( - channel, ChannelCapability.PROCESSING_STATUS - ): - return None - try: - status = self.run_module( - "mark_message_processing_started", - channel=channel, - source=source, - userid=userid, - message_id=message_id, - chat_id=chat_id, - text=text, - ) - except Exception as err: - logger.debug(f"启动消息处理状态失败: {err}") - return None - return status if isinstance(status, dict) else None - - def finish_message_processing_status( - self, - status: Optional[dict] = None, - channel: Optional[MessageChannel] = None, - source: Optional[str] = None, - userid: Optional[Union[str, int]] = None, - message_id: Optional[Union[str, int]] = None, - chat_id: Optional[Union[str, int]] = None, - ) -> None: - """ - 结束渠道侧消息输入/处理状态。 - 优先使用 start 返回的 status,缺失时使用显式渠道和消息定位参数。 - """ - target_channel = channel - if status: - try: - target_channel = MessageChannel(status.get("channel")) - except Exception: - target_channel = channel - if not target_channel or not ChannelCapabilityManager.supports_capability( - target_channel, ChannelCapability.PROCESSING_STATUS - ): - return - try: - self.run_module( - "mark_message_processing_finished", - channel=target_channel, - source=(status or {}).get("source") or source, - userid=(status or {}).get("userid") or userid, - message_id=(status or {}).get("message_id") or message_id, - chat_id=(status or {}).get("chat_id") or chat_id, - status=status, - ) - except Exception as err: - logger.debug(f"结束消息处理状态失败: {err}") - - @staticmethod - def _normalize_notification_for_dispatch( - message: Notification - ) -> Notification: - """ - 规范化待发送的通知消息。 - 后台任务会复用内部占位用户ID作为会话身份,这里在真正发送前清空, - 让消息重新走默认通知路由或基于 targets 的目标解析。 - """ - dispatch_message = copy.deepcopy(message) - dispatch_message.userid = normalize_internal_user_id( - dispatch_message.userid - ) - return dispatch_message - - @staticmethod - def _build_notice_message_data(message: Notification) -> dict: - """ - 构造消息通知事件数据。 - """ - return {**message.model_dump(exclude={"save_history"}), "type": message.mtype} - async def async_remove_cache(self, filename: str) -> None: """ 异步删除缓存,同时删除Redis和本地缓存 @@ -520,498 +418,6 @@ class ChainBase(metaclass=ABCMeta): method, result, *args, **kwargs ) - @staticmethod - def _can_use_media_recognize_share( - meta: Optional[MetaBase], - media_source: Optional[MediaSource], - media_id: Optional[str], - ) -> bool: - """ - 仅在名称识别场景下使用共享识别,显式ID识别不再重复回查 - """ - return bool( - settings.MEDIA_RECOGNIZE_SHARE - and meta - and not media_source - and not media_id - ) - - @staticmethod - def _snapshot_recognize_cache_meta(meta: Optional[MetaBase]) -> Optional[MetaBase]: - """ - 保存共享识别前的本地缓存关键元数据,用于共享成功后回填正缓存覆盖负缓存。 - """ - if not meta: - return None - return copy.deepcopy(meta) - - def _update_local_recognize_cache( - self, - meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], - ) -> None: - """ - 共享识别成功后回填本地识别缓存,避免名称负缓存导致后续重复回查共享。 - """ - if not meta or not mediainfo: - return - self.run_module( - "update_recognize_cache", - meta=meta, - mediainfo=mediainfo, - ) - - async def _async_update_local_recognize_cache( - self, - meta: Optional[MetaBase], - mediainfo: Optional[MediaInfo], - ) -> None: - """ - 异步回填本地识别缓存。 - """ - if not meta or not mediainfo: - return - await self.async_run_module( - "async_update_recognize_cache", - meta=meta, - mediainfo=mediainfo, - ) - - @staticmethod - def _record_media_recognize_share_hit() -> None: - """记录一次共享媒体识别成功命中,统计失败不影响识别结果。""" - try: - SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount) - except Exception as err: - logger.error(f"记录共享媒体识别命中次数失败:{str(err)}") - - def _run_native_media_recognize( - self, - module_kwargs: dict, - cache: bool, - ) -> Optional[MediaInfo]: - """执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子。""" - with fresh(not cache): - return self.run_module("recognize_media", **module_kwargs) - - async def _async_run_native_media_recognize( - self, - module_kwargs: dict, - cache: bool, - ) -> Optional[MediaInfo]: - """执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子。""" - async with async_fresh(not cache): - return await self.async_run_module( - "async_recognize_media", **module_kwargs - ) - - def recognize_media( - self, - meta: MetaBase = None, - mtype: Optional[MediaType] = None, - media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None, - episode_group: Optional[str] = None, - cache: bool = True, - share_meta: MetaBase = None, - music_type: Optional[str] = None, - ) -> Optional[MediaInfo]: - """ - 识别媒体信息,不含Fanart图片 - :param meta: 识别的元数据 - :param share_meta: 共享识别查询/上报使用的原始元数据 - :param mtype: 识别的媒体类型 - :param media_source: 请求级识别数据源 - :param media_id: 数据源原生ID,必须与media_source成对提供 - :param episode_group: 剧集组 - :param cache: 是否使用缓存 - :param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑 - :return: 识别的媒体信息,包括剧集信息 - """ - # 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对 - explicit_identity = media_id is not None - requested_source = normalize_media_source(media_source) or media_source - media_source, media_id = resolve_media_identity( - media=meta, - media_source=media_source, - media_id=media_id, - ) - if explicit_identity and (not media_source or not media_id): - logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id") - return None - if not media_id and requested_source is not None: - media_source = requested_source - # meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索 - meta_source, meta_id = resolve_media_identity(media=meta) - if meta_id and meta_source == requested_source: - media_source, media_id = meta_source, meta_id - if not episode_group and hasattr(meta, "episode_group"): - episode_group = meta.episode_group - if not mtype and not (media_source and media_id) and meta and meta.type in [ - MediaType.TV, MediaType.MOVIE, MediaType.MUSIC - ]: - mtype = meta.type - share_query_meta = share_meta or meta - module_kwargs = { - "meta": meta, - "mtype": mtype, - "media_source": media_source, - "media_id": media_id, - "episode_group": episode_group, - "cache": cache, - } - if music_type is not None: - module_kwargs["music_type"] = music_type - mediainfo = self._run_native_media_recognize(module_kwargs, cache) - # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) - mediainfo = self._supplement_media_recognize( - meta=meta, mtype=mtype, media_source=media_source, - media_id=media_id, mediainfo=mediainfo, - music_type=music_type, - ) - fallback_mediainfo = ( - mediainfo - if mediainfo and not self._media_info_has_identity(mediainfo) - else None - ) - if mediainfo and self._media_info_has_identity(mediainfo): - # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID - if not getattr(mediainfo, "recognize_cache_hit", False): - MoviePilotServerHelper.report_recognize_share( - meta=meta, - mediainfo=mediainfo, - keyword_meta=share_query_meta, - ) - return mediainfo - - if self._can_use_media_recognize_share( - share_query_meta, media_source, media_id - ): - shared_cache_meta = self._snapshot_recognize_cache_meta(meta) - share_query_kwargs = { - "meta": meta, - "mtype": mtype, - "keyword_meta": share_query_meta, - } - if music_type is not None: - share_query_kwargs["music_type"] = music_type - shared_item = MoviePilotServerHelper.query_recognize_share( - **share_query_kwargs, - ) - shared_params = MoviePilotServerHelper.to_recognize_params(shared_item) - if shared_params: - shared_module_kwargs = { - "meta": meta, - "mtype": shared_params.get("mtype") or mtype, - "media_source": shared_params.get("media_source"), - "media_id": shared_params.get("media_id"), - "episode_group": episode_group, - "cache": cache, - } - shared_music_type = shared_params.get("music_type") or music_type - if shared_music_type is not None: - shared_module_kwargs["music_type"] = shared_music_type - mediainfo = self._run_native_media_recognize( - shared_module_kwargs, - cache, - ) - if mediainfo and self._media_info_has_identity(mediainfo): - self._update_local_recognize_cache(shared_cache_meta, mediainfo) - self._record_media_recognize_share_hit() - return mediainfo - if mediainfo and not fallback_mediainfo: - fallback_mediainfo = mediainfo - return fallback_mediainfo - - async def async_recognize_media( - self, - meta: MetaBase = None, - mtype: Optional[MediaType] = None, - media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None, - episode_group: Optional[str] = None, - cache: bool = True, - share_meta: MetaBase = None, - music_type: Optional[str] = None, - ) -> Optional[MediaInfo]: - """ - 识别媒体信息,不含Fanart图片(异步版本) - :param meta: 识别的元数据 - :param share_meta: 共享识别查询/上报使用的原始元数据 - :param mtype: 识别的媒体类型 - :param media_source: 请求级识别数据源 - :param media_id: 数据源原生ID,必须与media_source成对提供 - :param episode_group: 剧集组 - :param cache: 是否使用缓存 - :param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑 - :return: 识别的媒体信息,包括剧集信息 - """ - # 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对 - explicit_identity = media_id is not None - requested_source = normalize_media_source(media_source) or media_source - media_source, media_id = resolve_media_identity( - media=meta, - media_source=media_source, - media_id=media_id, - ) - if explicit_identity and (not media_source or not media_id): - logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id") - return None - if not media_id and requested_source is not None: - media_source = requested_source - # meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索 - meta_source, meta_id = resolve_media_identity(media=meta) - if meta_id and meta_source == requested_source: - media_source, media_id = meta_source, meta_id - if not episode_group and hasattr(meta, "episode_group"): - episode_group = meta.episode_group - if not mtype and not (media_source and media_id) and meta and meta.type in [ - MediaType.TV, MediaType.MOVIE, MediaType.MUSIC - ]: - mtype = meta.type - share_query_meta = share_meta or meta - module_kwargs = { - "meta": meta, - "mtype": mtype, - "media_source": media_source, - "media_id": media_id, - "episode_group": episode_group, - "cache": cache, - } - if music_type is not None: - module_kwargs["music_type"] = music_type - mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache) - # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) - mediainfo = await self._async_supplement_media_recognize( - meta=meta, mtype=mtype, media_source=media_source, - media_id=media_id, mediainfo=mediainfo, - music_type=music_type, - ) - fallback_mediainfo = ( - mediainfo - if mediainfo and not self._media_info_has_identity(mediainfo) - else None - ) - if mediainfo and self._media_info_has_identity(mediainfo): - # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID - if not getattr(mediainfo, "recognize_cache_hit", False): - await MoviePilotServerHelper.async_report_recognize_share( - meta=meta, - mediainfo=mediainfo, - keyword_meta=share_query_meta, - ) - return mediainfo - - if self._can_use_media_recognize_share( - share_query_meta, media_source, media_id - ): - shared_cache_meta = self._snapshot_recognize_cache_meta(meta) - share_query_kwargs = { - "meta": meta, - "mtype": mtype, - "keyword_meta": share_query_meta, - } - if music_type is not None: - share_query_kwargs["music_type"] = music_type - shared_item = await MoviePilotServerHelper.async_query_recognize_share( - **share_query_kwargs, - ) - shared_params = MoviePilotServerHelper.to_recognize_params(shared_item) - if shared_params: - shared_module_kwargs = { - "meta": meta, - "mtype": shared_params.get("mtype") or mtype, - "media_source": shared_params.get("media_source"), - "media_id": shared_params.get("media_id"), - "episode_group": episode_group, - "cache": cache, - } - shared_music_type = shared_params.get("music_type") or music_type - if shared_music_type is not None: - shared_module_kwargs["music_type"] = shared_music_type - mediainfo = await self._async_run_native_media_recognize( - shared_module_kwargs, - cache, - ) - if mediainfo and self._media_info_has_identity(mediainfo): - await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo) - await run_in_threadpool(self._record_media_recognize_share_hit) - return mediainfo - if mediainfo and not fallback_mediainfo: - fallback_mediainfo = mediainfo - return fallback_mediainfo - - @staticmethod - def _media_recognize_plugin_payload( - meta: Optional[MetaBase], - mtype: Optional[MediaType], - media_source: Optional[MediaSource], - media_id: Optional[str], - is_music: bool, - music_type: Optional[str] = None, - ) -> dict: - """ - 构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议, - 仅要素字段随媒体类型不同 - """ - if is_music: - return { - "title": getattr(meta, "title", None), - "artists": list(getattr(meta, "artists", None) or []), - "album": getattr(meta, "album", None), - "year": getattr(meta, "year", None), - "isrc": getattr(meta, "isrc", None), - "media_source": media_source, - "media_id": media_id, - "music_type": music_type, - } - return { - "title": getattr(meta, "title", None) or getattr(meta, "name", None), - "year": getattr(meta, "year", None), - "season": getattr(meta, "begin_season", None), - "type": mtype.value if isinstance(mtype, MediaType) else None, - "media_source": media_source, - "media_id": media_id, - } - - @classmethod - def _media_info_from_plugin( - cls, - event_data: dict, - is_music: bool, - mtype: Optional[MediaType] = None, - music_type: Optional[str] = None, - ) -> Optional[MediaInfo]: - """ - 解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信; - 音乐构造 MusicInfo,影视构造 MediaInfo - """ - if not isinstance(event_data, dict): - return None - plugin_info = event_data.get("mediainfo") - if not isinstance(plugin_info, dict): - return None - if not plugin_info.get("media_source"): - logger.warn("插件返回的媒体信息缺少数据源,忽略 ...") - return None - try: - if is_music: - if not plugin_info.get("media_id"): - logger.warn("插件返回的音乐媒体信息缺少媒体ID,忽略 ...") - return None - info: MediaInfo = MusicInfo.from_dict(plugin_info) - if not info.media_source or not info.media_id: - return None - if music_type and info.music_type != music_type: - logger.warn( - f"插件返回的音乐实体类型为 {info.music_type}," - f"与请求的 {music_type} 不一致,忽略 ..." - ) - return None - return info - # 影视:插件未提供类型时使用请求推断的类型 - if not plugin_info.get("type") and mtype: - plugin_info = {**plugin_info, "type": mtype} - info = MediaInfo() - info.from_dict(plugin_info) - except Exception as err: - logger.warn(f"插件返回的媒体信息格式错误:{err}") - return None - # 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线 - if not info.media_source or not cls._media_info_has_identity(info): - logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...") - return None - return info - - @staticmethod - def _media_info_has_identity(mediainfo) -> bool: - """判断媒体信息是否具备完整的规范媒体身份。""" - media_source, media_id = resolve_media_identity(media=mediainfo) - return bool(media_source and media_id) - - def _supplement_media_recognize( - self, - meta: Optional[MetaBase], - mtype: Optional[MediaType], - media_source: Optional[MediaSource], - media_id: Optional[str], - mediainfo, - music_type: Optional[str] = None, - ): - """ - 媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时, - 广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息 - """ - is_music = ( - isinstance(meta, MetaMusic) - or mtype == MediaType.MUSIC - or isinstance(mediainfo, MusicInfo) - ) - # 已有远端身份时无需插件介入 - if mediainfo and self._media_info_has_identity(mediainfo): - return mediainfo - etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize - if not self.eventmanager.check(etype): - return mediainfo - result: Event = self.eventmanager.send_event( - etype, - self._media_recognize_plugin_payload( - meta, mtype, media_source, media_id, is_music, music_type - ), - ) - if not result: - return mediainfo - plugin_info = self._media_info_from_plugin( - result.event_data or {}, is_music, mtype, music_type - ) - if not plugin_info: - return mediainfo - logger.info( - f"插件补充媒体识别成功:{plugin_info.title}" - f"({plugin_info.media_source}:{plugin_info.media_id})" - ) - return plugin_info - - async def _async_supplement_media_recognize( - self, - meta: Optional[MetaBase], - mtype: Optional[MediaType], - media_source: Optional[MediaSource], - media_id: Optional[str], - mediainfo, - music_type: Optional[str] = None, - ): - """媒体识别插件补充的异步版本,影视与音乐统一流程""" - is_music = ( - isinstance(meta, MetaMusic) - or mtype == MediaType.MUSIC - or isinstance(mediainfo, MusicInfo) - ) - # 已有远端身份时无需插件介入 - if mediainfo and self._media_info_has_identity(mediainfo): - return mediainfo - etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize - if not self.eventmanager.check(etype): - return mediainfo - result: Event = await self.eventmanager.async_send_event( - etype, - self._media_recognize_plugin_payload( - meta, mtype, media_source, media_id, is_music, music_type - ), - ) - if not result: - return mediainfo - plugin_info = self._media_info_from_plugin( - result.event_data or {}, is_music, mtype, music_type - ) - if not plugin_info: - return mediainfo - logger.info( - f"插件补充媒体识别成功:{plugin_info.title}" - f"({plugin_info.media_source}:{plugin_info.media_id})" - ) - return plugin_info - def match_doubaninfo( self, name: str, @@ -1281,7 +687,7 @@ class ChainBase(metaclass=ABCMeta): return self.run_module("webhook_parser", body=body, form=form, args=args) def search_medias( - self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 @@ -1294,7 +700,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_medias( - self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None + self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) @@ -1307,7 +713,7 @@ class ChainBase(metaclass=ABCMeta): ) def search_persons( - self, name: str, media_source: Optional[MediaSourceSelection] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 @@ -1320,7 +726,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_persons( - self, name: str, media_source: Optional[MediaSourceSelection] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) @@ -1333,7 +739,7 @@ class ChainBase(metaclass=ABCMeta): ) def search_collections( - self, name: str, media_source: Optional[MediaSourceSelection] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索集合信息 @@ -1346,7 +752,7 @@ class ChainBase(metaclass=ABCMeta): ) async def async_search_collections( - self, name: str, media_source: Optional[MediaSourceSelection] = None + self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索集合信息(异步版本) @@ -1785,373 +1191,6 @@ class ChainBase(metaclass=ABCMeta): """ return self.run_module("media_files", mediainfo=mediainfo) - def post_message( - self, - message: Optional[Notification] = None, - meta: Optional[MetaBase] = None, - mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, - torrentinfo: Optional[TorrentInfo] = None, - transferinfo: Optional[TransferInfo] = None, - **kwargs, - ) -> None: - """ - 发送消息 - :param message: Notification实例 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param torrentinfo: 种子信息 - :param transferinfo: 文件整理信息 - :param kwargs: 其他参数(覆盖业务对象属性值) - :return: 成功或失败 - """ - # 添加格式化的时间参数 - kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - # 渲染消息 - message = MessageTemplateHelper.render( - message=message, - meta=meta, - mediainfo=mediainfo, - torrentinfo=torrentinfo, - transferinfo=transferinfo, - **kwargs, - ) - # 检查消息是否有效 - if not message: - logger.warning("消息为空,跳过发送") - return - if message.save_history: - self.messageoper.add(**message.model_dump()) - dispatch_message = self._normalize_notification_for_dispatch(message) - # 发送消息按设置隔离 - if not dispatch_message.userid and dispatch_message.mtype: - # 消息隔离设置 - notify_action = ServiceConfigHelper.get_notification_switch( - dispatch_message.mtype - ) - if notify_action: - # 'admin' 'user,admin' 'user' 'all' - actions = notify_action.split(",") - # 是否已发送管理员标志 - admin_sended = False - send_orignal = False - useroper = UserOper() - for action in actions: - send_message = copy.deepcopy(dispatch_message) - if action == "admin" and not admin_sended: - # 仅发送管理员 - logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") - # 读取管理员消息IDS - send_message.targets = useroper.get_settings(settings.SUPERUSER) - admin_sended = True - elif action == "user" and send_message.username: - # 发送对应用户 - logger.info( - f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}" - ) - # 读取用户消息IDS - send_message.targets = useroper.get_settings( - send_message.username - ) - if send_message.targets is None: - # 没有找到用户 - if not admin_sended: - # 回滚发送管理员 - logger.info( - f"用户 {send_message.username} 不存在,消息将发送给管理员" - ) - # 读取管理员消息IDS - send_message.targets = useroper.get_settings( - settings.SUPERUSER - ) - admin_sended = True - else: - # 管理员发过了,此消息不发了 - logger.info( - f"用户 {send_message.username} 不存在,消息无法发送到对应用户" - ) - continue - elif send_message.username == settings.SUPERUSER: - # 管理员同名已发送 - admin_sended = True - else: - # 按原消息发送全体 - if not admin_sended: - send_orignal = True - break - # 按设定发送 - self.eventmanager.send_event( - etype=EventType.NoticeMessage, - data=self._build_notice_message_data(send_message), - ) - self.messagequeue.send_message( - "post_message", message=send_message, **kwargs - ) - if not send_orignal: - return - # 发送消息事件 - self.eventmanager.send_event( - etype=EventType.NoticeMessage, - data=self._build_notice_message_data(dispatch_message), - ) - # 按原消息发送 - self.messagequeue.send_message( - "post_message", - message=dispatch_message, - immediately=True if dispatch_message.userid else False, - **kwargs, - ) - - async def async_post_message( - self, - message: Optional[Notification] = None, - meta: Optional[MetaBase] = None, - mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, - torrentinfo: Optional[TorrentInfo] = None, - transferinfo: Optional[TransferInfo] = None, - **kwargs, - ) -> None: - """ - 异步发送消息 - :param message: Notification实例 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :param torrentinfo: 种子信息 - :param transferinfo: 文件整理信息 - :param kwargs: 其他参数(覆盖业务对象属性值) - :return: 成功或失败 - """ - # 添加格式化的时间参数 - kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) - # 渲染消息 - message = MessageTemplateHelper.render( - message=message, - meta=meta, - mediainfo=mediainfo, - torrentinfo=torrentinfo, - transferinfo=transferinfo, - **kwargs, - ) - # 检查消息是否有效 - if not message: - logger.warning("消息为空,跳过发送") - return - if message.save_history: - await self.messageoper.async_add(**message.model_dump()) - dispatch_message = self._normalize_notification_for_dispatch(message) - # 发送消息按设置隔离 - if not dispatch_message.userid and dispatch_message.mtype: - # 消息隔离设置 - notify_action = ServiceConfigHelper.get_notification_switch( - dispatch_message.mtype - ) - if notify_action: - # 'admin' 'user,admin' 'user' 'all' - actions = notify_action.split(",") - # 是否已发送管理员标志 - admin_sended = False - send_orignal = False - useroper = UserOper() - for action in actions: - send_message = copy.deepcopy(dispatch_message) - if action == "admin" and not admin_sended: - # 仅发送管理员 - logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") - # 读取管理员消息IDS - send_message.targets = useroper.get_settings(settings.SUPERUSER) - admin_sended = True - elif action == "user" and send_message.username: - # 发送对应用户 - logger.info( - f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}" - ) - # 读取用户消息IDS - send_message.targets = useroper.get_settings( - send_message.username - ) - if send_message.targets is None: - # 没有找到用户 - if not admin_sended: - # 回滚发送管理员 - logger.info( - f"用户 {send_message.username} 不存在,消息将发送给管理员" - ) - # 读取管理员消息IDS - send_message.targets = useroper.get_settings( - settings.SUPERUSER - ) - admin_sended = True - else: - # 管理员发过了,此消息不发了 - logger.info( - f"用户 {send_message.username} 不存在,消息无法发送到对应用户" - ) - continue - elif send_message.username == settings.SUPERUSER: - # 管理员同名已发送 - admin_sended = True - else: - # 按原消息发送全体 - if not admin_sended: - send_orignal = True - break - # 按设定发送 - await self.eventmanager.async_send_event( - etype=EventType.NoticeMessage, - data=self._build_notice_message_data(send_message), - ) - await self.messagequeue.async_send_message( - "post_message", message=send_message, **kwargs - ) - if not send_orignal: - return - # 发送消息事件 - await self.eventmanager.async_send_event( - etype=EventType.NoticeMessage, - data=self._build_notice_message_data(dispatch_message), - ) - # 按原消息发送 - await self.messagequeue.async_send_message( - "post_message", - message=dispatch_message, - immediately=True if dispatch_message.userid else False, - **kwargs, - ) - - def post_medias_message( - self, message: Notification, medias: List[MediaInfo] - ) -> None: - """ - 发送媒体信息选择列表 - :param message: 消息体 - :param medias: 媒体列表 - :return: 成功或失败 - """ - note_list = [media.to_dict() for media in medias] - if message.save_history: - self.messageoper.add(**message.model_dump(), note=note_list) - dispatch_message = self._normalize_notification_for_dispatch(message) - return self.messagequeue.send_message( - "post_medias_message", - message=dispatch_message, - medias=medias, - immediately=True if dispatch_message.userid else False, - ) - - def post_torrents_message( - self, message: Notification, torrents: List[Context] - ) -> None: - """ - 发送种子信息选择列表 - :param message: 消息体 - :param torrents: 种子列表 - :return: 成功或失败 - """ - note_list = [torrent.torrent_info.to_dict() for torrent in torrents] - if message.save_history: - self.messageoper.add(**message.model_dump(), note=note_list) - dispatch_message = self._normalize_notification_for_dispatch(message) - return self.messagequeue.send_message( - "post_torrents_message", - message=dispatch_message, - torrents=torrents, - immediately=True if dispatch_message.userid else False, - ) - - def delete_message( - self, - channel: MessageChannel, - source: str, - message_id: Union[str, int], - chat_id: Optional[Union[str, int]] = None, - ) -> bool: - """ - 删除消息 - :param channel: 消息渠道 - :param source: 消息源(指定特定的消息模块) - :param message_id: 消息ID - :param chat_id: 聊天ID(如群组ID) - :return: 删除是否成功 - """ - return self.run_module( - "delete_message", - channel=channel, - source=source, - message_id=message_id, - chat_id=chat_id, - ) - - def edit_message( - self, - channel: MessageChannel, - source: str, - message_id: Union[str, int], - chat_id: Union[str, int], - text: str, - title: Optional[str] = None, - buttons: Optional[List[List[dict]]] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> bool: - """ - 编辑已发送的消息 - :param channel: 消息渠道 - :param source: 消息源(指定特定的消息模块) - :param message_id: 消息ID - :param chat_id: 聊天ID - :param text: 新的消息内容 - :param title: 消息标题 - :param buttons: 更新后的按钮列表 - :param metadata: 其他消息元数据 - :return: 编辑是否成功 - """ - if channel == MessageChannel.WebAgent: - try: - from app.application.messaging.agent import edit_web_agent_message - - return edit_web_agent_message( - user_id=str((metadata or {}).get("userid") or ""), - message_id=message_id, - title=title, - text=text, - buttons=buttons, - ) - except Exception as err: - logger.debug(f"编辑 WebAgent 消息失败: {err}") - return False - - return self.run_module( - "edit_message", - channel=channel, - source=source, - message_id=message_id, - chat_id=chat_id, - text=text, - title=title, - buttons=buttons, - metadata=metadata, - ) - - def send_direct_message(self, message: Notification) -> Optional[MessageResponse]: - """ - 直接发送消息并返回消息ID等信息(用于后续编辑消息的场景) - 不经过消息队列、不保存消息历史 - :param message: 消息体 - :return: 消息响应(包含message_id, chat_id等) - """ - return self.run_module( - "send_direct_message", - message=self._normalize_notification_for_dispatch(message), - ) - - def finalize_message( - self, - response: MessageResponse, - ) -> bool: - """ - 对已发送消息执行渠道收尾动作。 - 例如关闭流式卡片状态;无特殊收尾的渠道直接返回 False。 - """ - return self.run_module("finalize_message", response=response) - def metadata_img( self, mediainfo: MediaInfo, diff --git a/app/chain/_interaction.py b/app/chain/_interaction.py new file mode 100644 index 000000000..3a55bf738 --- /dev/null +++ b/app/chain/_interaction.py @@ -0,0 +1,86 @@ +from typing import Optional, Tuple, Union + +from app.schemas.types import MessageChannel + + +class InteractionChainMixin: + """ + 斜杠命令交互四件套委托:remote_list / parse_callback / + handle_callback_interaction / handle_text_interaction。 + + subscribe、site 等业务链的交互入口完全同构,唯一差异是各自的 + 交互处理器构造参数。本 mixin 将四件套委托提取为公共实现, + 子类只需注入处理器类并实现 _interaction_handler 构造器。 + + 子类注入约定: + - `_interaction_handler_type`:交互处理器类,提供静态 parse_callback; + - `_interaction_handler()`:按各链业务动作构造处理器实例。 + """ + + # 交互处理器类,子类注入(如 SubscribeInteractionHandler / SiteInteractionHandler) + _interaction_handler_type: type = None + + def _interaction_handler(self): + """ + 构造交互处理器实例,由子类按各自业务动作注入实现。 + """ + raise NotImplementedError + + def remote_list( + self, + arg_str: str = "", + channel: MessageChannel = None, + userid: Union[str, int] = None, + source: Optional[str] = None, + ): + """ + 斜杠命令统一入口,委托交互处理器。 + """ + return self._interaction_handler().remote_list( + arg_str=arg_str, channel=channel, userid=userid, source=source + ) + + @classmethod + def parse_callback(cls, callback_data: str) -> Optional[Tuple[str, str]]: + """ + 解析斜杠命令按钮回调。 + """ + return cls._interaction_handler_type.parse_callback(callback_data) + + def handle_callback_interaction( + self, + callback_data: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + original_message_id: Optional[Union[str, int]] = None, + original_chat_id: Optional[str] = None, + ) -> bool: + """委托交互处理器处理按钮回调。""" + return self._interaction_handler().handle_callback_interaction( + callback_data=callback_data, + channel=channel, + source=source, + userid=userid, + username=username, + original_message_id=original_message_id, + original_chat_id=original_chat_id, + ) + + def handle_text_interaction( + self, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + text: str, + ) -> bool: + """委托交互处理器处理文本输入。""" + return self._interaction_handler().handle_text_interaction( + channel=channel, + source=source, + userid=userid, + username=username, + text=text, + ) diff --git a/app/chain/_messaging.py b/app/chain/_messaging.py new file mode 100644 index 000000000..92ff989c6 --- /dev/null +++ b/app/chain/_messaging.py @@ -0,0 +1,486 @@ +"""消息处理与通知发送 mixin。 + +从 ChainBase 拆出的消息域:渠道输入状态机、通知派发规范化、消息渲染、 +隔离路由与队列发送。方法经 MRO 解析,依赖 ChainBase 实例的 run_module、 +eventmanager、messageoper、messagequeue 等协作对象。 +""" +import copy +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +from app.db.oper.user import UserOper +from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo +from app.domain.meta.metabase import MetaBase +from app.foundation.identity import normalize_internal_user_id +from app.application.messaging.message import MessageTemplateHelper +from app.runtime.config import settings +from app.runtime.extensions.service_registry import ServiceConfigHelper +from app.runtime.log import logger +from app.schemas import MessageResponse, Notification, TransferInfo +from app.schemas.message import ChannelCapability, ChannelCapabilityManager +from app.schemas.types import EventType, MessageChannel + + +class MessageProcessingMixin: + """消息输入/处理状态机与通知派发规范化。""" + + def start_message_processing_status( + self, + channel: MessageChannel, + source: Optional[str], + userid: Optional[Union[str, int]] = None, + message_id: Optional[Union[str, int]] = None, + chat_id: Optional[Union[str, int]] = None, + text: Optional[str] = None, + ) -> Optional[dict]: + """ + 启动渠道侧消息输入/处理状态。 + 具体表现由消息模块实现,例如 typing 保活或消息 reaction。 + """ + if not channel or not ChannelCapabilityManager.supports_capability( + channel, ChannelCapability.PROCESSING_STATUS + ): + return None + try: + status = self.run_module( + "mark_message_processing_started", + channel=channel, + source=source, + userid=userid, + message_id=message_id, + chat_id=chat_id, + text=text, + ) + except Exception as err: + logger.debug(f"启动消息处理状态失败: {err}") + return None + return status if isinstance(status, dict) else None + + def finish_message_processing_status( + self, + status: Optional[dict] = None, + channel: Optional[MessageChannel] = None, + source: Optional[str] = None, + userid: Optional[Union[str, int]] = None, + message_id: Optional[Union[str, int]] = None, + chat_id: Optional[Union[str, int]] = None, + ) -> None: + """ + 结束渠道侧消息输入/处理状态。 + 优先使用 start 返回的 status,缺失时使用显式渠道和消息定位参数。 + """ + target_channel = channel + if status: + try: + target_channel = MessageChannel(status.get("channel")) + except Exception: + target_channel = channel + if not target_channel or not ChannelCapabilityManager.supports_capability( + target_channel, ChannelCapability.PROCESSING_STATUS + ): + return + try: + self.run_module( + "mark_message_processing_finished", + channel=target_channel, + source=(status or {}).get("source") or source, + userid=(status or {}).get("userid") or userid, + message_id=(status or {}).get("message_id") or message_id, + chat_id=(status or {}).get("chat_id") or chat_id, + status=status, + ) + except Exception as err: + logger.debug(f"结束消息处理状态失败: {err}") + + @staticmethod + def _normalize_notification_for_dispatch( + message: Notification + ) -> Notification: + """ + 规范化待发送的通知消息。 + 后台任务会复用内部占位用户ID作为会话身份,这里在真正发送前清空, + 让消息重新走默认通知路由或基于 targets 的目标解析。 + """ + dispatch_message = copy.deepcopy(message) + dispatch_message.userid = normalize_internal_user_id( + dispatch_message.userid + ) + return dispatch_message + + @staticmethod + def _build_notice_message_data(message: Notification) -> dict: + """ + 构造消息通知事件数据。 + """ + return {**message.model_dump(exclude={"save_history"}), "type": message.mtype} + + +class NotificationMixin: + """通知消息发送域:渲染、隔离路由、队列发送与消息编辑。""" + + def post_message( + self, + message: Optional[Notification] = None, + meta: Optional[MetaBase] = None, + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, + torrentinfo: Optional[TorrentInfo] = None, + transferinfo: Optional[TransferInfo] = None, + **kwargs, + ) -> None: + """ + 发送消息 + :param message: Notification实例 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param torrentinfo: 种子信息 + :param transferinfo: 文件整理信息 + :param kwargs: 其他参数(覆盖业务对象属性值) + :return: 成功或失败 + """ + # 添加格式化的时间参数 + kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + # 渲染消息 + message = MessageTemplateHelper.render( + message=message, + meta=meta, + mediainfo=mediainfo, + torrentinfo=torrentinfo, + transferinfo=transferinfo, + **kwargs, + ) + # 检查消息是否有效 + if not message: + logger.warning("消息为空,跳过发送") + return + if message.save_history: + self.messageoper.add(**message.model_dump()) + dispatch_message = self._normalize_notification_for_dispatch(message) + # 发送消息按设置隔离 + if not dispatch_message.userid and dispatch_message.mtype: + # 消息隔离设置 + notify_action = ServiceConfigHelper.get_notification_switch( + dispatch_message.mtype + ) + if notify_action: + # 'admin' 'user,admin' 'user' 'all' + actions = notify_action.split(",") + # 是否已发送管理员标志 + admin_sended = False + send_orignal = False + useroper = UserOper() + for action in actions: + send_message = copy.deepcopy(dispatch_message) + if action == "admin" and not admin_sended: + # 仅发送管理员 + logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") + # 读取管理员消息IDS + send_message.targets = useroper.get_settings(settings.SUPERUSER) + admin_sended = True + elif action == "user" and send_message.username: + # 发送对应用户 + logger.info( + f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}" + ) + # 读取用户消息IDS + send_message.targets = useroper.get_settings( + send_message.username + ) + if send_message.targets is None: + # 没有找到用户 + if not admin_sended: + # 回滚发送管理员 + logger.info( + f"用户 {send_message.username} 不存在,消息将发送给管理员" + ) + # 读取管理员消息IDS + send_message.targets = useroper.get_settings( + settings.SUPERUSER + ) + admin_sended = True + else: + # 管理员发过了,此消息不发了 + logger.info( + f"用户 {send_message.username} 不存在,消息无法发送到对应用户" + ) + continue + elif send_message.username == settings.SUPERUSER: + # 管理员同名已发送 + admin_sended = True + else: + # 按原消息发送全体 + if not admin_sended: + send_orignal = True + break + # 按设定发送 + self.eventmanager.send_event( + etype=EventType.NoticeMessage, + data=self._build_notice_message_data(send_message), + ) + self.messagequeue.send_message( + "post_message", message=send_message, **kwargs + ) + if not send_orignal: + return + # 发送消息事件 + self.eventmanager.send_event( + etype=EventType.NoticeMessage, + data=self._build_notice_message_data(dispatch_message), + ) + # 按原消息发送 + self.messagequeue.send_message( + "post_message", + message=dispatch_message, + immediately=True if dispatch_message.userid else False, + **kwargs, + ) + + async def async_post_message( + self, + message: Optional[Notification] = None, + meta: Optional[MetaBase] = None, + mediainfo: Optional[Union[MediaInfo, MusicInfo]] = None, + torrentinfo: Optional[TorrentInfo] = None, + transferinfo: Optional[TransferInfo] = None, + **kwargs, + ) -> None: + """ + 异步发送消息 + :param message: Notification实例 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :param torrentinfo: 种子信息 + :param transferinfo: 文件整理信息 + :param kwargs: 其他参数(覆盖业务对象属性值) + :return: 成功或失败 + """ + # 添加格式化的时间参数 + kwargs.setdefault("current_time", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + # 渲染消息 + message = MessageTemplateHelper.render( + message=message, + meta=meta, + mediainfo=mediainfo, + torrentinfo=torrentinfo, + transferinfo=transferinfo, + **kwargs, + ) + # 检查消息是否有效 + if not message: + logger.warning("消息为空,跳过发送") + return + if message.save_history: + await self.messageoper.async_add(**message.model_dump()) + dispatch_message = self._normalize_notification_for_dispatch(message) + # 发送消息按设置隔离 + if not dispatch_message.userid and dispatch_message.mtype: + # 消息隔离设置 + notify_action = ServiceConfigHelper.get_notification_switch( + dispatch_message.mtype + ) + if notify_action: + # 'admin' 'user,admin' 'user' 'all' + actions = notify_action.split(",") + # 是否已发送管理员标志 + admin_sended = False + send_orignal = False + useroper = UserOper() + for action in actions: + send_message = copy.deepcopy(dispatch_message) + if action == "admin" and not admin_sended: + # 仅发送管理员 + logger.info(f"{send_message.mtype} 的消息已设置发送给管理员") + # 读取管理员消息IDS + send_message.targets = useroper.get_settings(settings.SUPERUSER) + admin_sended = True + elif action == "user" and send_message.username: + # 发送对应用户 + logger.info( + f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}" + ) + # 读取用户消息IDS + send_message.targets = useroper.get_settings( + send_message.username + ) + if send_message.targets is None: + # 没有找到用户 + if not admin_sended: + # 回滚发送管理员 + logger.info( + f"用户 {send_message.username} 不存在,消息将发送给管理员" + ) + # 读取管理员消息IDS + send_message.targets = useroper.get_settings( + settings.SUPERUSER + ) + admin_sended = True + else: + # 管理员发过了,此消息不发了 + logger.info( + f"用户 {send_message.username} 不存在,消息无法发送到对应用户" + ) + continue + elif send_message.username == settings.SUPERUSER: + # 管理员同名已发送 + admin_sended = True + else: + # 按原消息发送全体 + if not admin_sended: + send_orignal = True + break + # 按设定发送 + await self.eventmanager.async_send_event( + etype=EventType.NoticeMessage, + data=self._build_notice_message_data(send_message), + ) + await self.messagequeue.async_send_message( + "post_message", message=send_message, **kwargs + ) + if not send_orignal: + return + # 发送消息事件 + await self.eventmanager.async_send_event( + etype=EventType.NoticeMessage, + data=self._build_notice_message_data(dispatch_message), + ) + # 按原消息发送 + await self.messagequeue.async_send_message( + "post_message", + message=dispatch_message, + immediately=True if dispatch_message.userid else False, + **kwargs, + ) + + def post_medias_message( + self, message: Notification, medias: List[MediaInfo] + ) -> None: + """ + 发送媒体信息选择列表 + :param message: 消息体 + :param medias: 媒体列表 + :return: 成功或失败 + """ + note_list = [media.to_dict() for media in medias] + if message.save_history: + self.messageoper.add(**message.model_dump(), note=note_list) + dispatch_message = self._normalize_notification_for_dispatch(message) + return self.messagequeue.send_message( + "post_medias_message", + message=dispatch_message, + medias=medias, + immediately=True if dispatch_message.userid else False, + ) + + def post_torrents_message( + self, message: Notification, torrents: List[Context] + ) -> None: + """ + 发送种子信息选择列表 + :param message: 消息体 + :param torrents: 种子列表 + :return: 成功或失败 + """ + note_list = [torrent.torrent_info.to_dict() for torrent in torrents] + if message.save_history: + self.messageoper.add(**message.model_dump(), note=note_list) + dispatch_message = self._normalize_notification_for_dispatch(message) + return self.messagequeue.send_message( + "post_torrents_message", + message=dispatch_message, + torrents=torrents, + immediately=True if dispatch_message.userid else False, + ) + + def delete_message( + self, + channel: MessageChannel, + source: str, + message_id: Union[str, int], + chat_id: Optional[Union[str, int]] = None, + ) -> bool: + """ + 删除消息 + :param channel: 消息渠道 + :param source: 消息源(指定特定的消息模块) + :param message_id: 消息ID + :param chat_id: 聊天ID(如群组ID) + :return: 删除是否成功 + """ + return self.run_module( + "delete_message", + channel=channel, + source=source, + message_id=message_id, + chat_id=chat_id, + ) + + def edit_message( + self, + channel: MessageChannel, + source: str, + message_id: Union[str, int], + chat_id: Union[str, int], + text: str, + title: Optional[str] = None, + buttons: Optional[List[List[dict]]] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> bool: + """ + 编辑已发送的消息 + :param channel: 消息渠道 + :param source: 消息源(指定特定的消息模块) + :param message_id: 消息ID + :param chat_id: 聊天ID + :param text: 新的消息内容 + :param title: 消息标题 + :param buttons: 更新后的按钮列表 + :param metadata: 其他消息元数据 + :return: 编辑是否成功 + """ + if channel == MessageChannel.WebAgent: + try: + from app.application.messaging.agent import edit_web_agent_message + + return edit_web_agent_message( + user_id=str((metadata or {}).get("userid") or ""), + message_id=message_id, + title=title, + text=text, + buttons=buttons, + ) + except Exception as err: + logger.debug(f"编辑 WebAgent 消息失败: {err}") + return False + + return self.run_module( + "edit_message", + channel=channel, + source=source, + message_id=message_id, + chat_id=chat_id, + text=text, + title=title, + buttons=buttons, + metadata=metadata, + ) + + def send_direct_message(self, message: Notification) -> Optional[MessageResponse]: + """ + 直接发送消息并返回消息ID等信息(用于后续编辑消息的场景) + 不经过消息队列、不保存消息历史 + :param message: 消息体 + :return: 消息响应(包含message_id, chat_id等) + """ + return self.run_module( + "send_direct_message", + message=self._normalize_notification_for_dispatch(message), + ) + + def finalize_message( + self, + response: MessageResponse, + ) -> bool: + """ + 对已发送消息执行渠道收尾动作。 + 例如关闭流式卡片状态;无特殊收尾的渠道直接返回 False。 + """ + return self.run_module("finalize_message", response=response) diff --git a/app/chain/_mixins.py b/app/chain/_mixins.py new file mode 100644 index 000000000..d780b4124 --- /dev/null +++ b/app/chain/_mixins.py @@ -0,0 +1,1559 @@ +"""整理链功能域 mixin。 + +TransferChain 从 5000+ 行的单体拆出这些内聚功能域,每个 mixin 只承载一类 +整理辅助逻辑;主流程(do_transfer / manual_transfer / remote_transfer)仍留在 +TransferChain 中。mixin 方法运行时经 MRO 解析,共享 TransferChain 实例状态。 + +注意:这里的方法均已去掉私有名前缀双下划线(__ -> _),因为 Python 的名字 +改编按定义类生效,方法迁到 mixin 后 __ 前缀会改变改编目标,导致跨类调用失败。 +""" +import asyncio +from copy import deepcopy +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from app import schemas +from app.adapters.system.host import SystemUtils +from app.application.agent import build_manual_redo_prompt, get_agent_manager +from app.application.formatting import EpisodeFormatRuleHelper +from app.application.history import clear_transfer_failures, resolve_history +from app.application.transfer import TransferTask, job_lock +from app.chain.media import MediaChain +from app.chain.storage import StorageChain +from app.chain.subscribe import SubscribeChain +from app.db.models.downloadhistory import DownloadFiles, DownloadHistory +from app.db.models.transferhistory import TransferHistory +from app.db.oper.downloadhistory import DownloadHistoryOper +from app.db.oper.systemconfig import SystemConfigOper +from app.db.oper.transferhistory import TransferHistoryOper +from app.domain.context import MediaInfo, MusicInfo +from app.domain.media import normalize_music_type +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.foundation import text as text_tools +from app.runtime.config import global_vars, settings +from app.runtime.log import logger +from app.schemas import ( + FileItem, + Notification, + TmdbEpisode, + TransferInfo, +) +from app.schemas.agent import ReplyMode +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + EventType, + MediaSource, + MediaType, + MessageChannel, + SystemConfigKey, +) + +# 字幕文件常见的语言/默认/强制标记,整理同名字幕时只允许剥离这些字幕专属尾缀。 +SUBTITLE_STEM_TAGS = { + "cc", + "chi", + "chs", + "cht", + "cn", + "default", + "en", + "eng", + "english", + "forced", + "gb", + "gb2312", + "hk", + "ja", + "jap", + "japanese", + "jp", + "jpn", + "sc", + "sdh", + "tc", + "zh", + "zh-cn", + "zh-hans", + "zh-hant", + "zh-tw", + "zh_cn", + "zh_hans", + "zh_hant", + "zh_tw", + "zho", + "中英", + "中字", + "双语", + "简中", + "简体", + "繁中", + "繁体", +} + + +class FileFilterMixin: + @staticmethod + def _requires_automatic_category(task: TransferTask) -> bool: + """ + 判断当前整理任务是否需要根据媒体识别结果自动创建类别目录。 + + :param task: 整理任务 + :return: 是否必须具备自动分类结果 + """ + target_directory = task.target_directory + if target_directory and target_directory.media_category: + return False + if task.library_category_folder is not None: + return bool(task.library_category_folder) + return bool( + target_directory and target_directory.library_category_folder + ) + + def _is_subtitle_file(self, fileitem: FileItem) -> bool: + """ + 判断是否为字幕文件 + """ + if not fileitem.extension: + return False + return ( + True if f".{fileitem.extension.lower()}" in self._subtitle_exts else False + ) + + def _is_audio_file(self, fileitem: FileItem) -> bool: + """ + 判断是否为音频文件 + """ + if not fileitem.extension: + return False + return True if f".{fileitem.extension.lower()}" in self._audio_exts else False + + def _is_media_file( + self, + fileitem: FileItem, + mtype: Optional[MediaType] = None, + ) -> bool: + """ + 判断是否为主要媒体文件 + """ + if mtype == MediaType.MUSIC: + if fileitem.type != "file" or not fileitem.extension: + return False + return f".{fileitem.extension.lower()}" in self._audio_exts + if fileitem.type == "dir": + # 蓝光原盘判断 + return StorageChain().is_bluray_folder(fileitem) + if not fileitem.extension: + return False + extension = f".{fileitem.extension.lower()}" + return extension in self._media_exts + + def _is_primary_media_file( + self, + fileitem: FileItem, + mediainfo: Optional[MediaInfo | MusicInfo], + ) -> bool: + """判断文件在当前媒体上下文中是否属于主要媒体文件。""" + return self._is_media_file( + fileitem, + getattr(mediainfo, "type", None), + ) + + @staticmethod + def _music_info_from_meta(meta: MetaMusic) -> MusicInfo: + """将音频文件标签解析结果转换为可整理的最小音乐信息。""" + return MusicInfo.from_meta(meta) + + @classmethod + def _match_music_album_context( + cls, + file_item: FileItem, + file_path: Path, + file_meta: MetaMusic, + ) -> tuple[MetaMusic, Optional[MusicInfo]]: + """为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。 + + WAV 等无标签文件只能依靠目录结构和曲目特征识别;匹配结果由 MediaChain + 按目录缓存,同一专辑目录内的后续文件不会重复请求远端。 + """ + # 目录级匹配需要读取本地音频时长,远端存储文件无法参与 + if file_meta.media_id or getattr(file_item, "storage", "local") != "local": + return file_meta, None + try: + matched = MediaChain().recognize_music_album_directory(file_path.parent) + except Exception as err: + logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}") + return file_meta, None + info = matched.get(str(file_path.resolve())) + if not info or not info.media_id: + return file_meta, None + logger.info(f"{file_path.name} 通过专辑目录匹配识别为:{info.artist} - {info.title}") + merged_meta = deepcopy(file_meta) + # 保留本地音频的实际技术参数,仅回填身份和名称字段 + if info.title: + merged_meta.title = info.title + if info.artists: + merged_meta.artists = list(info.artists) + if info.album: + merged_meta.album = info.album + if info.album_artist: + merged_meta.album_artist = info.album_artist + if info.year: + merged_meta.year = info.year + if info.disc_number: + merged_meta.disc_number = info.disc_number + if info.track_number: + merged_meta.track_number = info.track_number + if info.total_tracks: + merged_meta.total_tracks = info.total_tracks + merged_meta.media_source = info.media_source + merged_meta.media_id = info.media_id + merged_info = cls._music_info_from_meta(merged_meta) + # 补齐曲目级远端信息,供后续刮削和展示使用 + merged_info.music_type = info.music_type + merged_info.artist_ids = list(info.artist_ids) + merged_info.album_id = info.album_id + merged_info.album_type = info.album_type + merged_info.release_date = info.release_date + merged_info.cover_url = info.cover_url + merged_info.category = info.category + merged_info.genres = list(info.genres) + merged_info.detail_link = info.detail_link + return merged_meta, merged_info + + @staticmethod + def _download_history_music_type( + download_history: Optional[DownloadHistory], + ) -> Optional[str]: + """从下载历史字段或旧版音乐备注中恢复音乐实体类型。""" + music_type = normalize_music_type( + getattr(download_history, "music_type", None), + allow_artist=False, + ) + if music_type: + return music_type + note = getattr(download_history, "note", None) + music_note = note.get("music") if isinstance(note, dict) else None + media_payload = music_note.get("media") if isinstance(music_note, dict) else None + if not isinstance(media_payload, dict): + return None + return normalize_music_type( + media_payload.get("music_type"), + allow_artist=False, + ) + + @classmethod + def _restore_music_download_context( + cls, + download_history: Optional[DownloadHistory], + file_path: Path, + ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]: + """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。""" + note = getattr(download_history, "note", None) + music_note = note.get("music") if isinstance(note, dict) else None + if not isinstance(music_note, dict) or music_note.get("version") != 1: + return None, None + try: + saved_meta = MetaMusic.from_dict(music_note.get("meta") or {}) + saved_info = MusicInfo.from_dict(music_note.get("media") or {}) + except (TypeError, ValueError): + return None, None + + file_tags = MediaChain.read_path_meta(file_path) + file_meta = deepcopy(saved_meta) + file_meta.org_string = file_path.name + # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), + # 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。 + if file_tags.title: + file_meta.title = file_tags.title + is_album_context = saved_info.music_type == MUSIC_ENTITY_ALBUM + for field_name in ( + "artists", + "disc_number", + "track_number", + "total_discs", + "version", + "isrc", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name))) + for field_name in ("album", "album_artist", "year", "total_tracks"): + file_value = getattr(file_tags, field_name, None) + # 整专下载以订阅选中的专辑字段为准,避免单个错误标签把曲目拆到其它专辑目录。 + if file_value and (not is_album_context or not getattr(file_meta, field_name, None)): + setattr(file_meta, field_name, deepcopy(file_value)) + for field_name in ( + "audio_format", + "bit_depth", + "sample_rate", + "bitrate", + "duration", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, getattr(file_tags, field_name)) + file_meta.media_source = saved_info.media_source or saved_meta.media_source + file_meta.media_id = saved_info.media_id or saved_meta.media_id + + file_info = cls._music_info_from_meta(file_meta) + file_info.media_source = saved_info.media_source + file_info.media_id = saved_info.media_id + file_info.music_type = saved_info.music_type + file_info.artist_ids = list(saved_info.artist_ids) + file_info.album_id = saved_info.album_id + file_info.album_type = saved_info.album_type + file_info.release_date = saved_info.release_date + file_info.cover_url = saved_info.cover_url + file_info.lyrics = saved_info.lyrics + file_info.category = saved_info.category + file_info.genres = list(saved_info.genres) + file_info.detail_link = saved_info.detail_link + file_info.listen_count = saved_info.listen_count + return file_meta, file_info + + @staticmethod + def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool: + """ + 判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。 + """ + if history.type == MediaType.MUSIC.value: + return True + return src_path.suffix.lower() in settings.RMT_AUDIOEXT + + def _recognize_music_retry_media( + self, + history: TransferHistory, + src_path: Path, + ) -> Optional[Union[MusicInfo, MediaInfo]]: + """ + 重新整理重试时恢复音乐信息。 + + 优先按历史记录中的 MusicBrainz 身份恢复;单音频文件回退按音频标签与文件名识别; + 音乐专辑目录返回 None,交由整理链按音频后缀逐文件解析识别。 + """ + if history.media_source and history.media_id: + retry_info = MediaChain().recognize_media( + mtype=MediaType.MUSIC, + media_source=history.media_source, + media_id=history.media_id, + music_type=getattr(history, "music_type", None), + ) + if retry_info: + return retry_info + if src_path.is_file(): + # 音频走统一路径识别入口,自动路由到音乐识别链 + recognize_context = MediaChain().recognize_by_path(str(src_path)) + return recognize_context.media_info if recognize_context else None + return None + + def _is_allowed_file(self, fileitem: FileItem) -> bool: + """ + 判断是否允许的扩展名 + """ + if not fileitem.extension: + return False + return True if f".{fileitem.extension.lower()}" in self._allowed_exts else False + + @staticmethod + def _is_allow_filesize(fileitem: FileItem, min_filesize: int) -> bool: + """ + 判断是否满足最小文件大小 + """ + return ( + True + if not min_filesize or (fileitem.size or 0) > min_filesize * 1024 * 1024 + else False + ) + + @staticmethod + def _is_hidden_or_recycle_path(file_path: Optional[str]) -> bool: + """ + 判断是否隐藏或回收站路径 + """ + if not file_path: + return False + normalized_path = file_path.replace("\\", "/") + return ( + "/@Recycle/" in normalized_path + or "/#recycle/" in normalized_path + or "/." in normalized_path + or "/@eaDir" in normalized_path + ) + + @staticmethod + def _should_delete_empty_source_directories( + task: TransferTask, + delete_mounted_local_disk_empty_dirs: bool, + mounted_filesystem_cache: Dict[Path, bool], + ) -> bool: + """ + 判断移动整理后是否应删除源空目录。 + + 仅在关闭挂载盘空目录清理且源存储为本地时检测文件系统, + 避免默认流程产生额外系统调用。 + """ + if delete_mounted_local_disk_empty_dirs: + return True + if task.fileitem.storage != "local": + return True + + source_directory = ( + Path(task.target_directory.download_path) + if task.target_directory and task.target_directory.download_path + else Path(task.fileitem.path).parent + ) + if source_directory not in mounted_filesystem_cache: + mounted_filesystem_cache[source_directory] = ( + SystemUtils.is_network_filesystem( + source_directory, include_local_fuse=True + ) + ) + return not mounted_filesystem_cache[source_directory] + + @staticmethod + def _is_overwrite_declined(task: TransferTask, transferinfo: TransferInfo, + transferhis: TransferHistoryOper) -> bool: + """ + 判断本次未入库是否为「同路径已有成功记录 + 覆盖模式裁定不覆盖」。 + + 只有同路径此前已成功整理过才需要保护:这类文件是查重闸放行的同路径新版本, + 媒体库中的原有版本仍然在位,不应因一次不覆盖裁决把成功记录改写成失败记录。 + 没有成功记录时(如目标同名文件来自其他源路径)保持原有失败语义, + 用户仍能在历史与通知中看到裁决结果。 + :param task: 整理任务 + :param transferinfo: 整理结果 + :param transferhis: 历史操作对象 + :return: True 表示应保留原成功记录 + """ + if not transferinfo.overwrite_skipped or not task.fileitem: + return False + try: + history = resolve_history( + task.fileitem.path, + storage=task.fileitem.storage, + transfer_history_oper=transferhis, + ) + except Exception as err: + logger.error(f"查询整理历史失败: {task.fileitem.path} - {err}") + return False + return bool(history and history.status) + + +class ScrapeBatchMixin: + + def _send_metadata_scrape_event( + self, task: TransferTask, transferinfo: TransferInfo + ): + """ + 发送元数据刮削事件,保持对外事件载荷兼容。 + """ + if ( + not task + or not transferinfo + or not transferinfo.need_scrape + or not self._is_primary_media_file(task.fileitem, task.mediainfo) + ): + return + + target_diritem = transferinfo.target_diritem + if not target_diritem: + return + + self.eventmanager.send_event( + EventType.MetadataScrape, + self._build_metadata_scrape_payload( + task=task, + fileitem=target_diritem, + file_list=transferinfo.file_list_new, + overwrite=False, + ), + ) + + @staticmethod + def _build_metadata_scrape_payload( + task: TransferTask, + fileitem: FileItem, + file_list: Optional[list[str]], + overwrite: bool, + ) -> dict[str, Any]: + """构造刮削事件载荷,并为音乐批次保留逐文件身份上下文。""" + paths = list(dict.fromkeys(file_list or [])) + payload: dict[str, Any] = { + "meta": task.meta, + "mediainfo": task.mediainfo, + "fileitem": fileitem, + "file_list": paths, + "overwrite": overwrite, + } + if isinstance(task.mediainfo, MusicInfo): + payload["file_contexts"] = [ + { + "path": path, + "meta": task.meta, + "mediainfo": task.mediainfo, + } + for path in paths + ] + return payload + + def _register_scrape_batch_task(self, task: TransferTask): + """ + 登记批次任务。刮削事件只在批次关闭且任务全部完成后统一发送。 + """ + if not task or not task.transfer_batch_id: + return + with job_lock: + batch = self._scrape_batches.setdefault( + task.transfer_batch_id, + { + "pending": set(), + "targets": {}, + "closed": False, + }, + ) + batch["pending"].add(task.fileitem.path) + + def _close_scrape_batch(self, batch_id: Optional[str]): + """ + 标记批次不再接收新任务,并尝试发送已聚合的刮削事件。 + """ + if not batch_id: + return + with job_lock: + batch = self._scrape_batches.setdefault( + batch_id, + { + "pending": set(), + "targets": {}, + "closed": False, + }, + ) + batch["closed"] = True + self._flush_scrape_batch_if_ready(batch_id) + + def _record_scrape_target(self, task: TransferTask, transferinfo: TransferInfo): + """ + 记录批次内需要刮削的目标文件,按目标媒体根目录聚合。 + """ + if ( + not task + or not task.transfer_batch_id + or not transferinfo + or not transferinfo.need_scrape + or not self._is_primary_media_file(task.fileitem, task.mediainfo) + ): + return + + target_diritem = transferinfo.target_diritem + if not target_diritem: + return + + target_files = transferinfo.file_list_new or [] + target_key = (target_diritem.storage, target_diritem.path) + with job_lock: + batch = self._scrape_batches.setdefault( + task.transfer_batch_id, + { + "pending": set(), + "targets": {}, + "closed": False, + }, + ) + target = batch["targets"].setdefault( + target_key, + { + "fileitem": target_diritem, + "meta": task.meta, + "mediainfo": task.mediainfo, + "files": [], + "file_contexts": {}, + "overwrite": False, + }, + ) + if not target.get("meta"): + target["meta"] = task.meta + if not target.get("mediainfo"): + target["mediainfo"] = task.mediainfo + for target_file in target_files: + if target_file and target_file not in target["files"]: + target["files"].append(target_file) + if target_file and isinstance(task.mediainfo, MusicInfo): + target["file_contexts"][target_file] = { + "path": target_file, + "meta": task.meta, + "mediainfo": task.mediainfo, + } + + def _finish_scrape_batch_task(self, task: TransferTask): + """ + 标记批次内单个任务已结束。 + """ + if not task or not task.transfer_batch_id: + return + with job_lock: + batch = self._scrape_batches.get(task.transfer_batch_id) + if not batch: + return + batch["pending"].discard(task.fileitem.path) + self._flush_scrape_batch_if_ready(task.transfer_batch_id) + + def _flush_scrape_batch_if_ready(self, batch_id: Optional[str]): + """ + 批次任务全部结束后发送聚合后的刮削事件。 + """ + if not batch_id: + return + + with job_lock: + batch = self._scrape_batches.get(batch_id) + if ( + not batch + or not batch.get("closed") + or batch.get("pending") + ): + return + targets = list(batch.get("targets", {}).values()) + self._scrape_batches.pop(batch_id, None) + + for target in targets: + fileitem = target.get("fileitem") + if not fileitem: + continue + file_list = list(dict.fromkeys(target.get("files") or [])) + file_contexts = target.get("file_contexts") or {} + payload = { + "meta": target.get("meta"), + "mediainfo": target.get("mediainfo"), + "fileitem": fileitem, + "file_list": file_list, + "overwrite": target.get("overwrite", False), + } + if file_contexts: + payload["file_contexts"] = [ + file_contexts[path] + for path in file_list + if path in file_contexts + ] + self.eventmanager.send_event( + EventType.MetadataScrape, + payload, + ) + + +class EpisodeFormatMixin: + + def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: + """ + 获取重命名后的名称 + :param meta: 元数据 + :param mediainfo: 媒体信息 + :return: 重命名后的名称(含目录) + """ + # 获取集信息,供重命名模块使用 + episodes_info: Optional[List[TmdbEpisode]] = None + if mediainfo.type == MediaType.TV: + # 判断注意season为0的情况 + season_num = mediainfo.season + if season_num is None and meta.season_seq: + if meta.season_seq.isdigit(): + season_num = int(meta.season_seq) + # 默认值1 + if season_num is None: + season_num = 1 + episodes_info = self.run_module( + "tmdb_episodes", + tmdbid=mediainfo.tmdb_id, + season=season_num, + episode_group=mediainfo.episode_group, + ) + if episodes_info: + return self.run_module( + "recommend_name", + meta=meta, + mediainfo=mediainfo, + episodes_info=episodes_info, + ) + # 电影或无集信息时保持原有参数集,避免影响旧签名的模块实现 + return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo) + + def recommend_episode_format( + self, + fileitem: FileItem, + fileitems: Optional[List[FileItem]] = None, + ) -> Tuple[bool, str, Optional[dict]]: + """ + 根据目录样本推荐集数定位模板 + """ + if not fileitem and not fileitems: + logger.warn("推荐集数定位模板失败:缺少目录参数") + return False, "缺少目录参数", None + + rules = self._get_episode_format_rules() + if fileitems: + state, errmsg, sample_files = self._get_selected_episode_format_sample_files( + fileitems + ) + if not state: + logger.warn(f"推荐集数定位模板失败:{errmsg}") + return False, errmsg, None + target_path = sample_files[0].path if sample_files else None + else: + if not fileitem or not fileitem.path: + logger.warn("推荐集数定位模板失败:缺少目录参数") + return False, "缺少目录参数", None + directory = self._resolve_episode_format_directory(fileitem) + if not directory or directory.type != "dir": + logger.warn(f"推荐集数定位模板失败:目录不存在 - {fileitem.path}") + return False, "目录不存在", None + sample_files = self._get_episode_format_sample_files(directory) + target_path = directory.path + logger.info( + f"开始匹配集数定位规则:{target_path},规则数 {len(rules)},样本数 {len(sample_files)}" + ) + state, errmsg, data = EpisodeFormatRuleHelper().recommend( + rules=rules, + sample_files=sample_files, + ) + if not state: + logger.warn(f"集数定位模板推荐失败:{target_path} - {errmsg}") + return state, errmsg, data + logger.info( + f"集数定位模板推荐成功:{target_path} - 规则 {data.get('rule_name') if data else None}" + ) + return state, errmsg, data + + @staticmethod + def _get_episode_format_rules() -> List[schemas.EpisodeFormatRule]: + """ + 获取启用的集数定位规则 + """ + rule_items = SystemConfigOper().get(SystemConfigKey.EpisodeFormatRuleTable) or [] + rules: List[schemas.EpisodeFormatRule] = [] + for item in rule_items: + if not isinstance(item, dict): + continue + try: + rule = schemas.EpisodeFormatRule(**item) + except Exception as err: + logger.warn(f"忽略无效的集数定位规则:{err}") + continue + if rule.enabled: + rules.append(rule) + return sorted(rules, key=lambda item: item.order) + + def _resolve_episode_format_directory( + self, fileitem: FileItem + ) -> Optional[FileItem]: + """ + 将文件或目录入参归一化为目录对象 + """ + storage_chain = StorageChain() + if fileitem.type == "dir": + return storage_chain.get_item(fileitem) + source_path = Path(fileitem.path) + parent_item = FileItem( + storage=fileitem.storage, + path=source_path.parent.as_posix(), + type="dir", + name=source_path.parent.name, + ) + return storage_chain.get_item(parent_item) + + def _get_selected_episode_format_sample_files( + self, fileitems: List[FileItem] + ) -> Tuple[bool, str, List[FileItem]]: + """ + 获取当前选择文件中可参与模板推荐的样本文件。 + """ + if not fileitems: + return False, "没有可用于识别的样本文件", [] + + expected_dir_key: Optional[Tuple[str, str]] = None + selected_files: List[FileItem] = [] + seen_files = set() + for item in fileitems: + if not item or not item.path or item.type != "file": + return False, "当前选择不满足智能识别条件", [] + + dir_key = ( + item.storage or "local", + Path(item.path).parent.as_posix(), + ) + if expected_dir_key is None: + expected_dir_key = dir_key + elif dir_key != expected_dir_key: + return False, "当前选择不满足智能识别条件", [] + + file_key = (item.storage or "local", item.path) + if file_key in seen_files: + continue + seen_files.add(file_key) + + if not ( + self._is_media_file(item) + or self._is_subtitle_file(item) + or self._is_audio_file(item) + ): + continue + if self._is_hidden_or_recycle_path(item.path): + continue + selected_files.append(item) + + if not selected_files: + return False, "没有可用于识别的样本文件", [] + return True, "", selected_files + + def _get_episode_format_sample_files( + self, directory: FileItem + ) -> List[FileItem]: + """ + 获取目录下可参与模板推荐的样本文件。 + + 推荐结果最终会在手动整理链路中作为 `episode_format` + 交由 `FormatParser` 过滤主视频、字幕和外挂音频,因此这里需要把 + 同目录下的主视频、字幕和外挂音频一起纳入推荐流程。 + """ + file_items = StorageChain().list_files(directory, recursion=False) or [] + sample_files: List[FileItem] = [] + for item in file_items: + if not item or item.type != "file": + continue + if not ( + self._is_media_file(item) + or self._is_subtitle_file(item) + or self._is_audio_file(item) + ): + continue + if self._is_hidden_or_recycle_path(item.path): + continue + sample_files.append(item) + return sample_files + + +class HistoryMatchMixin: + @staticmethod + def _match_download_file( + download_file: DownloadFiles, + file_path: Path, + save_path: Path, + ) -> bool: + """ + 判断下载文件记录是否明确对应当前文件。 + """ + if download_file.fullpath == file_path.as_posix(): + return True + + filepath = download_file.filepath + if not filepath: + return False + + try: + return (save_path / Path(filepath)).as_posix() == file_path.as_posix() + except (TypeError, ValueError): + return False + + def _resolve_history_from_download_files( + self, + downloadhis: DownloadHistoryOper, + download_files: List[DownloadFiles], + file_path: Optional[Path] = None, + save_path: Optional[Path] = None, + ) -> Optional[DownloadHistory]: + """ + 从下载文件记录中解析唯一的下载历史。 + """ + if file_path and save_path: + download_files = [ + download_file + for download_file in download_files + if self._match_download_file( + download_file=download_file, + file_path=file_path, + save_path=save_path, + ) + ] + + download_hashes = { + download_file.download_hash + for download_file in download_files + if download_file.download_hash + } + if len(download_hashes) == 1: + return downloadhis.get_by_hash(next(iter(download_hashes))) + return None + + def _resolve_download_history( + self, + downloadhis: DownloadHistoryOper, + file_path: Path, + bluray_dir: bool = False, + download_hash: Optional[str] = None, + ) -> Optional[DownloadHistory]: + """ + 根据显式 hash、文件路径或种子根目录回查下载历史。 + """ + if download_hash: + return downloadhis.get_by_hash(download_hash) + + if bluray_dir: + return downloadhis.get_by_path(file_path.as_posix()) + + download_file = downloadhis.get_file_by_fullpath(file_path.as_posix()) + if download_file: + return downloadhis.get_by_hash(download_file.download_hash) + + # 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录, + # 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。 + shared_download_roots = self._get_shared_download_roots(file_path) + + for parent_path in file_path.parents: + parent_posix = parent_path.as_posix() + download_files = downloadhis.get_files_by_savepath(parent_posix) or [] + + if parent_posix in shared_download_roots: + # 共享下载根目录只能接受有明确文件记录的匹配, + # 避免单文件/磁力任务把整个根目录污染成同一媒体。 + history = self._resolve_history_from_download_files( + downloadhis=downloadhis, + download_files=download_files, + file_path=file_path, + save_path=parent_path, + ) + if history: + return history + break + + download_history = downloadhis.get_by_path(parent_posix) + if download_history: + return download_history + + history = self._resolve_history_from_download_files( + downloadhis=downloadhis, + download_files=download_files, + ) + if history: + return history + + return None + + @staticmethod + def _is_movie_year_conflict( + file_meta: MetaBase, + # 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与 + # schemas DTO(TransferTask.download_history)。本函数只按 getattr 取 + # year 与 type,对两者一视同仁 + media: Union[DownloadHistory, schemas.DownloadHistory, MediaInfo, MusicInfo] + ) -> bool: + """ + 判断文件名年份是否与已识别电影年份冲突。 + + 多电影合集只保存一条下载历史,不能把合集首部电影的媒体 ID 套用到其它年份的文件; + 电视剧季包仍应继续复用同一条下载历史。 + """ + file_year = getattr(file_meta, "year", None) + media_year = getattr(media, "year", None) + if not file_meta or not media or not file_year or not media_year: + return False + media_type = getattr(media, "type", None) + if not isinstance(media_type, MediaType): + try: + media_type = MediaType(media_type) + except (TypeError, ValueError): + return False + return ( + media_type == MediaType.MOVIE + and str(file_year) != str(media_year) + ) + + @staticmethod + def _optional_attr_equal( + source: MetaBase, + target: MetaBase, + attr: str, + normalizer: Callable = None, + ) -> bool: + """ + 比较可选识别字段。 + + 字段两边都没有识别到时不参与判断;只要任意一边识别到了,就要求两边值一致, + 避免把同名不同年份或不同季集的附加文件误归到当前主视频。 + """ + source_value = getattr(source, attr, None) + target_value = getattr(target, attr, None) + if source_value is None and target_value is None: + return True + if source_value is None or target_value is None: + return False + if normalizer: + source_value = normalizer(source_value) + target_value = normalizer(target_value) + return source_value == target_value + + def _is_same_media_meta( + self, source_meta: MetaBase, target_meta: MetaBase + ) -> bool: + """ + 判断两个文件识别出的媒体身份是否一致。 + """ + if not source_meta or not target_meta: + return False + if source_meta.type != target_meta.type: + return False + if text_tools.normalize_upper(source_meta.name) != text_tools.normalize_upper( + target_meta.name + ): + return False + if not self._optional_attr_equal(source_meta, target_meta, "year", str): + return False + for attr in ( + "begin_season", + "end_season", + "begin_episode", + "end_episode", + ): + if not self._optional_attr_equal(source_meta, target_meta, attr, int): + return False + return True + + +class FileKeyMixin: + @staticmethod + def _get_file_key(fileitem: FileItem) -> Tuple[str, str]: + """ + 获取文件缓存键。 + """ + normalized_path = Path(str(fileitem.path).replace("\\", "/")).as_posix() + return fileitem.storage or "local", normalized_path + + @staticmethod + def _get_file_stem(fileitem: FileItem) -> str: + """ + 获取文件主干名,用于判断同名附加文件。 + """ + file_name = fileitem.name or Path(fileitem.path).name + return Path(file_name).stem.lower() + + @classmethod + def _get_subtitle_media_stem(cls, subtitle_fileitem: FileItem) -> str: + """ + 获取字幕对应主视频的候选主干名。 + """ + current_stem = cls._get_file_stem(subtitle_fileitem) + while current_stem: + media_stem, separator, suffix = current_stem.rpartition(".") + if not separator or suffix not in SUBTITLE_STEM_TAGS: + return current_stem + current_stem = media_stem + return current_stem + + def _get_extra_media_stem(self, extra_fileitem: FileItem) -> str: + """ + 获取附加文件对应主视频的候选主干名。 + """ + if self._is_subtitle_file(extra_fileitem): + return self._get_subtitle_media_stem(extra_fileitem) + return self._get_file_stem(extra_fileitem) + + def _get_related_main_file_key( + self, + extra_fileitem: FileItem, + main_fileitems: List[FileItem], + ) -> Optional[Tuple[str, str]]: + """ + 获取与附加文件名完全匹配的主视频键。 + """ + if not ( + self._is_subtitle_file(extra_fileitem) + or self._is_audio_file(extra_fileitem) + ): + return None + + extra_media_stem = self._get_extra_media_stem(extra_fileitem) + matched_items: List[FileItem] = [] + for main_fileitem in main_fileitems: + main_stem = self._get_file_stem(main_fileitem) + if main_stem and main_stem == extra_media_stem: + matched_items.append(main_fileitem) + + if len(matched_items) != 1: + return None + return self._get_file_key(matched_items[0]) + + @staticmethod + def _normalize_dir_path(dir_path: Union[str, Path]) -> str: + """ + 归一化目录路径,用于同一父目录候选缓存。 + """ + normalized = Path(dir_path).as_posix().rstrip("/") + return normalized or "/" + + def _get_dir_key(self, dir_item: FileItem) -> Tuple[str, str]: + """ + 获取目录缓存键。 + """ + return dir_item.storage, self._normalize_dir_path(dir_item.path) + + def _get_file_parent_key(self, current_item: FileItem) -> Tuple[str, str]: + """ + 获取文件父目录缓存键。 + """ + return ( + current_item.storage, + self._normalize_dir_path(Path(current_item.path).parent), + ) + + +class ManualHistoryMixin: + @staticmethod + def _get_subscribe_custom_words( + history_record: Optional[DownloadHistory], + ) -> Optional[List[str]]: + """ + 获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。 + + 快照优先可避免整理阶段因订阅季号漂移、来源解析失败或订阅完成被删导致识别词丢失,从而原样入库到偏移前的季集。 + """ + if not history_record: + return None + # 下载时保存的完整订阅识别词快照优先 + if history_record.custom_words: + return history_record.custom_words.split("\n") + # 兜底:历史旧记录无快照时,按下载来源实时反查订阅 + if not isinstance(history_record.note, dict): + return None + subscribe = SubscribeChain().get_subscribe_by_source( + history_record.note.get("source") + ) + return ( + subscribe.custom_words.split("\n") + if subscribe and subscribe.custom_words + else None + ) + + @staticmethod + def _is_successful_move_history(history: Optional[TransferHistory]) -> bool: + """判断历史记录是否为已成功完成的移动类整理。""" + return bool( + history + and history.status + and history.mode + and "move" in history.mode + ) + + def _get_manual_transfer_history( + self, + fileitem: FileItem, + transfer_history_oper: TransferHistoryOper, + include_move_dest: bool = False, + ) -> Optional[TransferHistory]: + """查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。""" + # resolve_history 在命中失败记录时会再确认一次有无成功记录, + # 避免 get_by_src 无排序导致同源多行时返回哪条不确定 + history = resolve_history( + fileitem.path, + storage=fileitem.storage, + transfer_history_oper=transfer_history_oper, + ) + if history or not include_move_dest: + return history + + history = transfer_history_oper.get_by_dest( + fileitem.path, + storage=fileitem.storage, + ) + return history if self._is_successful_move_history(history) else None + + def get_manual_transfer_histories( + self, + fileitems: List[FileItem], + ) -> List[TransferHistory]: + """ + 查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。 + + :param fileitems: 待查询的文件或目录项 + :return: 去重后的成功整理记录 + """ + transfer_history_oper = TransferHistoryOper() + histories: Dict[int, TransferHistory] = {} + for fileitem in fileitems or []: + if not fileitem or not fileitem.path: + continue + storage = fileitem.storage or "local" + if fileitem.type == "dir": + matched_histories = transfer_history_oper.list_success_by_src( + fileitem.path, + storage=storage, + recursive=True, + ) + matched_histories.extend( + transfer_history_oper.list_success_move_by_dest( + fileitem.path, + storage=storage, + recursive=True, + ) + ) + else: + history = self._get_manual_transfer_history( + fileitem=fileitem, + transfer_history_oper=transfer_history_oper, + include_move_dest=True, + ) + matched_histories = [history] if history and history.status else [] + + for history in matched_histories: + histories[history.id] = history + return list(histories.values()) + + @staticmethod + def _delete_manual_transfer_history( + history: TransferHistory, + transfer_history_oper: TransferHistoryOper, + ) -> Tuple[bool, str]: + """删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。""" + if ( + history.dest_fileitem + and not ManualHistoryMixin._is_successful_move_history(history) + ): + dest_fileitem = FileItem(**history.dest_fileitem) + storage_chain = StorageChain() + if ( + storage_chain.exists(dest_fileitem) + and not storage_chain.delete_media_file(dest_fileitem) + ): + return False, f"{dest_fileitem.path} 删除失败" + transfer_history_oper.delete(history.id) + # 删除记录是用户显式要求重来,失败计数一并清零,否则重整仍会受上一轮次数限制 + clear_transfer_failures(history.src, history.src_storage) + return True, "" + + +class FailedRetryMixin: + @staticmethod + def build_failed_transfer_buttons( + history_id: Optional[int], + ) -> Optional[List[List[dict]]]: + """ + 构建整理失败通知的操作按钮。 + """ + if not history_id: + return None + return [ + [ + {"text": "重试", "callback_data": f"transfer_retry_{history_id}"}, + { + "text": "智能助手接管", + "callback_data": f"transfer_ai_retry_{history_id}", + }, + ] + ] + + def redo_transfer_history(self, history_id: int) -> Tuple[bool, str]: + """ + 按历史记录直接重新整理,自动重新识别媒体信息。 + """ + return self._re_transfer(logid=history_id) + + @staticmethod + def parse_failed_transfer_callback( + callback_data: str, + ) -> Optional[tuple[str, int]]: + """ + 解析整理失败通知按钮回调。 + """ + for prefix, action in ( + ("transfer_retry_", "retry"), + ("transfer_ai_retry_", "ai_retry"), + ): + if callback_data.startswith(prefix): + history_id = callback_data.replace(prefix, "", 1) + if history_id.isdigit(): + return action, int(history_id) + return None + + def handle_failed_transfer_callback( + self, + *, + callback_data: str, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> bool: + """ + 处理整理失败通知中的重试类按钮。 + """ + callback = self.parse_failed_transfer_callback(callback_data) + if not callback: + return False + + action, history_id = callback + if action == "retry": + self._retry_transfer_history( + history_id=history_id, + channel=channel, + source=source, + userid=userid, + username=username, + ) + else: + self._take_over_transfer_history_by_ai( + history_id=history_id, + channel=channel, + source=source, + userid=userid, + username=username, + ) + return True + + def _retry_transfer_history( + self, + history_id: int, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 立即重新整理一条失败的整理记录。 + """ + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"开始重新整理记录 #{history_id} ...", + save_history=False, + ) + ) + + state, errmsg = self.redo_transfer_history(history_id) + if state: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"整理记录 #{history_id} 已重新整理", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + return + + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="重新整理失败", + text=errmsg, + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + def _take_over_transfer_history_by_ai( + self, + history_id: int, + channel: MessageChannel, + source: str, + userid: Union[str, int], + username: str, + ) -> None: + """ + 由智能助手接管一条失败的整理记录。 + """ + + if not settings.AI_AGENT_ENABLE: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="MoviePilot智能助手未启用,请在系统设置中启用", + save_history=False, + ) + ) + return + + history = TransferHistoryOper().get(history_id) + if not history: + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="重新整理失败", + text=f"整理记录 #{history_id} 不存在", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + return + + redo_prompt = build_manual_redo_prompt(history) + + self.post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title=f"已将整理记录 #{history_id} 交给智能助手处理", + text="处理完成后会在这里回复结果。", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + async def _run_ai_takeover(): + final_output = "" + + def _capture_output(text_output: str): + nonlocal final_output + final_output = text_output or "" + + try: + await get_agent_manager().run_background_prompt( + message=redo_prompt, + session_prefix=f"__agent_manual_redo_{history_id}", + output_callback=_capture_output, + reply_mode=ReplyMode.CAPTURE_ONLY, + allow_message_tools=False, + ) + await self.async_post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="智能助手整理完成", + text=final_output.strip() + or f"整理记录 #{history_id} 已由智能助手处理完成。", + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + except Exception as e: + await self.async_post_message( + Notification( + channel=channel, + source=source, + userid=userid, + username=username, + title="智能助手整理失败", + text=str(e), + link=settings.MP_DOMAIN("#/history"), + save_history=False, + ) + ) + + asyncio.run_coroutine_threadsafe(_run_ai_takeover(), global_vars.loop) + + def _re_transfer( + self, + logid: int, + mtype: MediaType = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + 根据历史记录,重新识别整理,只支持简单条件 + :param logid: 历史记录ID + :param mtype: 媒体类型 + :param media_source: 媒体数据源 + :param media_id: 数据源原生 ID,必须与 media_source 成对提供 + """ + # 查询历史记录 + history: TransferHistory = TransferHistoryOper().get(logid) + if not history: + logger.error(f"整理记录不存在,ID:{logid}") + return False, "整理记录不存在" + # 按源目录路径重新整理 + src_path = Path(history.src) + if not src_path.exists(): + return False, f"源目录不存在:{src_path}" + # 查询媒体信息 + explicit_identity = media_source is not None or media_id is not None + if explicit_identity and (not media_source or not media_id): + return False, "媒体重新识别需要同时提供 media_source 和 media_id" + if mtype and media_source and media_id: + mediainfo = MediaChain().recognize_media( + mtype=mtype, + media_source=media_source, + media_id=media_id, + music_type=( + getattr(history, "music_type", None) + if mtype == MediaType.MUSIC + else None + ), + episode_group=history.episode_group, + ) + if mediainfo and not isinstance(mediainfo, MusicInfo): + # 更新媒体图片 + self.obtain_images(mediainfo=mediainfo) + elif history.media_source and history.media_id: + try: + history_type = mtype or MediaType(history.type) + except ValueError: + history_type = mtype + mediainfo = MediaChain().recognize_media( + mtype=history_type, + media_source=history.media_source, + media_id=history.media_id, + music_type=( + getattr(history, "music_type", None) + if history_type == MediaType.MUSIC + else None + ), + episode_group=history.episode_group, + ) + mtype = history_type + if mediainfo and not isinstance(mediainfo, MusicInfo): + self.obtain_images(mediainfo=mediainfo) + elif mtype == MediaType.MUSIC or self._is_music_retry_source(history, src_path): + # 音乐重新整理走音乐识别链,避免默认影视识别误入 TMDB + mtype = MediaType.MUSIC + mediainfo = self._recognize_music_retry_media(history, src_path) + else: + recognize_context = MediaChain().recognize_by_path( + str(src_path), + episode_group=history.episode_group, + obtain_images=True, + ) + mediainfo = recognize_context.media_info if recognize_context else None + # 音乐专辑目录允许无预识别信息,由整理链按音频后缀逐文件解析识别 + if not mediainfo and not (mtype == MediaType.MUSIC and src_path.is_dir()): + return False, ( + f"未识别到媒体信息,类型:{mtype.value if mtype else None}," + f"media_source:{media_source},media_id:{media_id}" + ) + # 重新执行整理 + if mediainfo: + logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}") + + # 删除旧的已整理文件 + if history.dest_fileitem: + # 解析目标文件对象 + dest_fileitem = FileItem(**history.dest_fileitem) + StorageChain().delete_file(dest_fileitem) + + # 强制整理 + if history.src_fileitem: + state, errmsg = self.do_transfer( + fileitem=FileItem(**history.src_fileitem), + mediainfo=mediainfo, + mtype=mtype, + download_hash=history.download_hash, + force=True, + background=False, + manual=True, + ) + if not state: + return False, errmsg + + return True, "" diff --git a/app/chain/_music.py b/app/chain/_music.py new file mode 100644 index 000000000..1fd4be24f --- /dev/null +++ b/app/chain/_music.py @@ -0,0 +1,420 @@ +import copy +from typing import Any, List, Optional, Tuple + +from app.application.torrent import TorrentHelper +from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.search import SearchChain +from app.db.models.subscribe import Subscribe +from app.db.oper.subscribe import SubscribeOper +from app.db.oper.systemconfig import SystemConfigOper +from app.domain.context import Context, MediaInfo, MusicInfo +from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES +from app.domain.meta.metamusic import MetaMusic +from app.runtime.log import logger +from app.schemas.types import ( + MUSIC_ENTITY_ALBUM, + MUSIC_ENTITY_RECORDING, + MediaType, + SystemConfigKey, +) + + +def _normalize_music_total_tracks(value: Any) -> Optional[int]: + """将专辑曲目总数归一为正整数,无效或未知值返回 None。""" + try: + total_tracks = int(value or 0) + except (TypeError, ValueError): + return None + return total_tracks if total_tracks > 0 else None + + +class MusicSubscribeMixin: + """ + 音乐订阅功能域 mixin:单曲/专辑目标识别、实体快照同步、候选筛选、 + 择优下载与完成推进。 + + 该域方法通过 self 复用 SubscribeChain 主体的 get_sub_sites / get_params / + filter_torrents / check_and_handle_existing_media / finish_subscribe_or_not / + get_subscribe_source_keyword 等编排能力,因此仅作为 mixin 混入 SubscribeChain, + 不独立成链。build_subscribe_meta / _subscribe_media_key 等订阅通用辅助仍保留在 + subscribe.py,方法内延迟导入以避免 _music ↔ subscribe 的模块级循环。 + """ + + @staticmethod + def _validate_music_subscribe_target( + mediainfo: MediaInfo, + requested_music_type: Optional[str] = None, + ) -> Optional[str]: + """校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。""" + if mediainfo.type != MediaType.MUSIC: + return "识别结果不是音乐" + music_type = getattr(mediainfo, "music_type", None) + if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES: + return "音乐订阅仅支持单曲或专辑" + if music_type not in MUSIC_SUBSCRIBABLE_TYPES: + return "音乐订阅仅支持单曲或专辑" + if requested_music_type and requested_music_type != music_type: + return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}" + if music_type == MUSIC_ENTITY_ALBUM \ + and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None: + return "专辑总曲目数未知,无法校验整张专辑资源" + return None + + @staticmethod + def _ensure_music_subscribe_entity( + subscribe: Subscribe, + mediainfo: Optional[MusicInfo], + ) -> Optional[MusicInfo]: + """保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。""" + if not mediainfo: + return None + expected_type = getattr(subscribe, "music_type", None) + actual_type = getattr(mediainfo, "music_type", None) + if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES: + logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}") + return None + if actual_type not in MUSIC_SUBSCRIBABLE_TYPES: + logger.warning( + f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}" + ) + if expected_type in MUSIC_SUBSCRIBABLE_TYPES: + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + return None + if expected_type and actual_type != expected_type: + logger.warning( + f"音乐订阅 {subscribe.name} 实体不匹配:" + f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照" + ) + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + if actual_type == MUSIC_ENTITY_ALBUM: + remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) + stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None)) + resolved_total = remote_total or stored_total + if resolved_total is not None and mediainfo.total_tracks != resolved_total: + # 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。 + mediainfo = copy.copy(mediainfo) + mediainfo.total_tracks = resolved_total + return mediainfo + + @staticmethod + def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: + """按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" + # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 + from app.chain.subscribe import build_subscribe_meta + if subscribe.media_source and subscribe.media_id: + # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 + mediainfo = MediaChain().recognize_media( + media_source=subscribe.media_source, + media_id=str(subscribe.media_id), + mtype=MediaType.MUSIC, + music_type=getattr(subscribe, "music_type", None), + ) + if mediainfo: + return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo) + if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + # 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。 + return None + if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: + # 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。 + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + # 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。 + mediainfo = MediaChain().recognize_media( + meta=build_subscribe_meta(subscribe), + mtype=MediaType.MUSIC, + media_source=subscribe.media_source, + music_type=MUSIC_ENTITY_RECORDING, + ) + return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo) + + @staticmethod + async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: + """异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" + # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 + from app.chain.subscribe import build_subscribe_meta + if subscribe.media_source and subscribe.media_id: + # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 + mediainfo = await MediaChain().async_recognize_media( + media_source=subscribe.media_source, + media_id=str(subscribe.media_id), + mtype=MediaType.MUSIC, + music_type=getattr(subscribe, "music_type", None), + ) + if mediainfo: + return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo) + if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + return None + if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: + return MusicSubscribeMixin._music_info_from_subscribe(subscribe) + mediainfo = await MediaChain().async_recognize_media( + meta=build_subscribe_meta(subscribe), + mtype=MediaType.MUSIC, + media_source=subscribe.media_source, + music_type=MUSIC_ENTITY_RECORDING, + ) + return MusicSubscribeMixin._ensure_music_subscribe_entity(subscribe, mediainfo) + + @staticmethod + def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo: + """从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。""" + year_text = str(subscribe.year or "")[:4] + music_type = getattr(subscribe, "music_type", None) + # 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。 + artist_text = str(getattr(subscribe, "description", None) or "") \ + .split(" · ", maxsplit=1)[0].strip() + artists = [ + artist.strip() for artist in artist_text.split(" / ") if artist.strip() + ] + return MusicInfo( + media_source=subscribe.media_source, + media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, + music_type=music_type, + title=subscribe.name, + artists=artists, + album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None, + year=int(year_text) if year_text.isdigit() else None, + total_tracks=getattr(subscribe, "total_tracks", None) + if music_type == MUSIC_ENTITY_ALBUM else None, + cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None), + ) + + @staticmethod + def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None: + """把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。""" + update_data = {} + if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type: + update_data["music_type"] = mediainfo.music_type + if mediainfo.music_type == MUSIC_ENTITY_ALBUM: + # 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。 + total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \ + or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None)) + else: + total_tracks = None + if getattr(subscribe, "total_tracks", None) != total_tracks: + update_data["total_tracks"] = total_tracks + if not update_data: + return + SubscribeOper().update(subscribe.id, update_data) + for key, value in update_data.items(): + setattr(subscribe, key, value) + + @staticmethod + def _is_music_download_complete( + subscribe: Subscribe, + mediainfo: MusicInfo, + downloads: Optional[List[Context]], + ) -> bool: + """判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。""" + if not downloads: + return False + music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type + if music_type != MUSIC_ENTITY_ALBUM: + return True + return any(context.confirmed_full_coverage for context in downloads) + + def _prepare_music_subscribe( + self, + subscribe: Subscribe, + ) -> Optional[Tuple[MusicInfo, MetaMusic]]: + """识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。""" + # 延迟导入订阅通用辅助,避免 _music ↔ subscribe 模块级循环 + from app.chain.subscribe import _subscribe_media_key + mediainfo = self._recognize_music_subscribe(subscribe) + if not mediainfo: + logger.warning( + f"未识别到音乐订阅目标:{subscribe.name}," + f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}" + ) + return None + validation_error = self._validate_music_subscribe_target( + mediainfo, + getattr(subscribe, "music_type", None), + ) + if validation_error: + logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}") + return None + self._sync_music_subscribe_target(subscribe, mediainfo) + meta = MetaMusic.from_music_info(mediainfo) + exists, _ = self.check_and_handle_existing_media( + subscribe=subscribe, + meta=meta, + mediainfo=mediainfo, + mediakey=_subscribe_media_key(subscribe), + ) + if exists: + return None + return mediainfo, meta + + def _filter_music_subscribe_contexts( + self, + subscribe: Subscribe, + mediainfo: MusicInfo, + contexts: List[Context], + ) -> List[Context]: + """按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。""" + sites = self.get_sub_sites(subscribe) + default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ + if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups + rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] + torrent_helper = TorrentHelper() + matched: List[Context] = [] + for source_context in contexts or []: + source_torrent = source_context.torrent_info + if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value): + continue + # 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。 + torrent = copy.copy(source_torrent) + if sites and torrent.site not in sites: + continue + if not SearchChain.matches_music_resource( + mediainfo, + torrent.title, + torrent.description, + ): + continue + if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)): + continue + filtered = self.filter_torrents( + rule_groups=rule_groups, + torrent_list=[torrent], + mediainfo=mediainfo, + ) + if filtered is not None: + if not filtered: + continue + torrent = filtered[0] + + context = copy.copy(source_context) + context.torrent_info = torrent + meta = MetaMusic.from_music_info(mediainfo) + meta.org_string = torrent.title + meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) + if subscribe.best_version: + # 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则 + # 优先级时再回退到规范化音质分数,确保零配置也能自动升级。 + music_priority = torrent.pri_order or meta.audio_quality_score + if music_priority <= (subscribe.current_priority or 0): + logger.info( + f"{torrent.title} 音质优先级 {music_priority} " + f"未高于当前版本 {subscribe.current_priority or 0}" + ) + continue + torrent.pri_order = music_priority + context.meta_info = meta + context.media_info = mediainfo + context.match_source = str(mediainfo.media_source or "title") + context.candidate_recognized = False + context.media_info_is_target = True + if subscribe.media_category: + context.media_info.category = subscribe.media_category + matched.append(context) + return matched + + def _download_music_subscribe( + self, + subscribe: Subscribe, + mediainfo: MusicInfo, + contexts: List[Context], + ) -> None: + """批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。""" + if not contexts: + return + downloads, _ = DownloadChain().batch_download( + contexts=contexts, + username=subscribe.username, + save_path=subscribe.save_path, + downloader=subscribe.downloader, + source=self.get_subscribe_source_keyword(subscribe), + custom_words=subscribe.custom_words, + ) + successful = [ + context for context in downloads or [] + if context and context.meta_info and context.torrent_info + ] + quality_downloads = successful + if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: + quality_downloads = [ + context for context in successful + if context.confirmed_full_coverage + ] + if subscribe.best_version and quality_downloads: + best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order) + best_meta = best_context.meta_info + quality_data = { + "current_priority": best_context.torrent_info.pri_order, + "current_audio_format": best_meta.audio_format, + "current_bitrate": best_meta.bitrate, + "current_bit_depth": best_meta.bit_depth, + "current_sample_rate": best_meta.sample_rate, + } + SubscribeOper().update(subscribe.id, quality_data) + for key, value in quality_data.items(): + setattr(subscribe, key, value) + current_subscribe = SubscribeOper().get(subscribe.id) + if current_subscribe: + self.finish_subscribe_or_not( + subscribe=current_subscribe, + meta=MetaMusic.from_music_info(mediainfo), + mediainfo=mediainfo, + downloads=downloads, + ) + + def _search_music_subscribe(self, subscribe: Subscribe) -> None: + """复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。""" + target = self._prepare_music_subscribe(subscribe) + if not target: + return + mediainfo, _ = target + + sites = self.get_sub_sites(subscribe) + default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ + if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups + rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] + keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo) + if not keywords: + keywords = [subscribe.name] + + searchchain = SearchChain() + contexts: List[Context] = [] + for keyword in keywords: + contexts = searchchain.search_by_title( + title=keyword, + sites=sites, + mtype=MediaType.MUSIC, + rule_groups=rule_groups, + ) + contexts = self._filter_music_subscribe_contexts( + subscribe=subscribe, + mediainfo=mediainfo, + contexts=contexts, + ) + if contexts: + break + + if not contexts: + logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源") + return + + self._download_music_subscribe(subscribe, mediainfo, contexts) + + def _match_music_subscribe( + self, + subscribe: Subscribe, + contexts: List[Context], + ) -> None: + """直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。""" + target = self._prepare_music_subscribe(subscribe) + if not target: + return + mediainfo, _ = target + matched = self._filter_music_subscribe_contexts( + subscribe=subscribe, + mediainfo=mediainfo, + contexts=contexts, + ) + if not matched: + logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源") + return + self._download_music_subscribe(subscribe, mediainfo, matched) diff --git a/app/chain/_recognition.py b/app/chain/_recognition.py new file mode 100644 index 000000000..656e9ef71 --- /dev/null +++ b/app/chain/_recognition.py @@ -0,0 +1,518 @@ +"""媒体识别管线 mixin。 + +从 ChainBase 拆出的识别域:原生模块识别路由、识别缓存回填、共享识别、 +插件补充识别。方法经 MRO 解析,依赖 ChainBase 实例的 run_module/eventmanager +等协作对象。 +""" +import copy +from typing import Optional + +from fastapi.concurrency import run_in_threadpool + +from app.adapters.external.server import MoviePilotServerHelper +from app.db.oper.systemconfig import SystemConfigOper +from app.domain.context import MediaInfo, MusicInfo +from app.domain.meta.metabase import MetaBase +from app.domain.meta.metamusic import MetaMusic +from app.runtime.cache import fresh, async_fresh +from app.runtime.config import settings +from app.runtime.events import Event +from app.runtime.log import logger +from app.schemas.media import normalize_media_source, resolve_media_identity +from app.schemas.types import ChainEventType, MediaSource, MediaType, SystemConfigKey + + +class RecognitionMixin: + + @staticmethod + def _can_use_media_recognize_share( + meta: Optional[MetaBase], + media_source: Optional[MediaSource], + media_id: Optional[str], + ) -> bool: + """ + 仅在名称识别场景下使用共享识别,显式ID识别不再重复回查 + """ + return bool( + settings.MEDIA_RECOGNIZE_SHARE + and meta + and not media_source + and not media_id + ) + + @staticmethod + def _snapshot_recognize_cache_meta(meta: Optional[MetaBase]) -> Optional[MetaBase]: + """ + 保存共享识别前的本地缓存关键元数据,用于共享成功后回填正缓存覆盖负缓存。 + """ + if not meta: + return None + return copy.deepcopy(meta) + + def _update_local_recognize_cache( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + ) -> None: + """ + 共享识别成功后回填本地识别缓存,避免名称负缓存导致后续重复回查共享。 + """ + if not meta or not mediainfo: + return + self.run_module( + "update_recognize_cache", + meta=meta, + mediainfo=mediainfo, + ) + + async def _async_update_local_recognize_cache( + self, + meta: Optional[MetaBase], + mediainfo: Optional[MediaInfo], + ) -> None: + """ + 异步回填本地识别缓存。 + """ + if not meta or not mediainfo: + return + await self.async_run_module( + "async_update_recognize_cache", + meta=meta, + mediainfo=mediainfo, + ) + + @staticmethod + def _record_media_recognize_share_hit() -> None: + """记录一次共享媒体识别成功命中,统计失败不影响识别结果。""" + try: + SystemConfigOper().increment(SystemConfigKey.MediaRecognizeShareCount) + except Exception as err: + logger.error(f"记录共享媒体识别命中次数失败:{str(err)}") + + def _run_native_media_recognize( + self, + module_kwargs: dict, + cache: bool, + ) -> Optional[MediaInfo]: + """执行同步原生媒体模块识别,具体媒体领域可覆写该路由钩子。""" + with fresh(not cache): + return self.run_module("recognize_media", **module_kwargs) + + async def _async_run_native_media_recognize( + self, + module_kwargs: dict, + cache: bool, + ) -> Optional[MediaInfo]: + """执行异步原生媒体模块识别,具体媒体领域可覆写该路由钩子。""" + async with async_fresh(not cache): + return await self.async_run_module( + "async_recognize_media", **module_kwargs + ) + + def recognize_media( + self, + meta: MetaBase = None, + mtype: Optional[MediaType] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, + episode_group: Optional[str] = None, + cache: bool = True, + share_meta: MetaBase = None, + music_type: Optional[str] = None, + ) -> Optional[MediaInfo]: + """ + 识别媒体信息,不含Fanart图片 + :param meta: 识别的元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param mtype: 识别的媒体类型 + :param media_source: 请求级识别数据源 + :param media_id: 数据源原生ID,必须与media_source成对提供 + :param episode_group: 剧集组 + :param cache: 是否使用缓存 + :param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑 + :return: 识别的媒体信息,包括剧集信息 + """ + # 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对 + explicit_identity = media_id is not None + requested_source = normalize_media_source(media_source) or media_source + media_source, media_id = resolve_media_identity( + media=meta, + media_source=media_source, + media_id=media_id, + ) + if explicit_identity and (not media_source or not media_id): + logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id") + return None + if not media_id and requested_source is not None: + media_source = requested_source + # meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索 + meta_source, meta_id = resolve_media_identity(media=meta) + if meta_id and meta_source == requested_source: + media_source, media_id = meta_source, meta_id + if not episode_group and hasattr(meta, "episode_group"): + episode_group = meta.episode_group + if not mtype and not (media_source and media_id) and meta and meta.type in [ + MediaType.TV, MediaType.MOVIE, MediaType.MUSIC + ]: + mtype = meta.type + share_query_meta = share_meta or meta + module_kwargs = { + "meta": meta, + "mtype": mtype, + "media_source": media_source, + "media_id": media_id, + "episode_group": episode_group, + "cache": cache, + } + if music_type is not None: + module_kwargs["music_type"] = music_type + mediainfo = self._run_native_media_recognize(module_kwargs, cache) + # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) + mediainfo = self._supplement_media_recognize( + meta=meta, mtype=mtype, media_source=media_source, + media_id=media_id, mediainfo=mediainfo, + music_type=music_type, + ) + fallback_mediainfo = ( + mediainfo + if mediainfo and not self._media_info_has_identity(mediainfo) + else None + ) + if mediainfo and self._media_info_has_identity(mediainfo): + # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID + if not getattr(mediainfo, "recognize_cache_hit", False): + MoviePilotServerHelper.report_recognize_share( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_query_meta, + ) + return mediainfo + + if self._can_use_media_recognize_share( + share_query_meta, media_source, media_id + ): + shared_cache_meta = self._snapshot_recognize_cache_meta(meta) + share_query_kwargs = { + "meta": meta, + "mtype": mtype, + "keyword_meta": share_query_meta, + } + if music_type is not None: + share_query_kwargs["music_type"] = music_type + shared_item = MoviePilotServerHelper.query_recognize_share( + **share_query_kwargs, + ) + shared_params = MoviePilotServerHelper.to_recognize_params(shared_item) + if shared_params: + shared_module_kwargs = { + "meta": meta, + "mtype": shared_params.get("mtype") or mtype, + "media_source": shared_params.get("media_source"), + "media_id": shared_params.get("media_id"), + "episode_group": episode_group, + "cache": cache, + } + shared_music_type = shared_params.get("music_type") or music_type + if shared_music_type is not None: + shared_module_kwargs["music_type"] = shared_music_type + mediainfo = self._run_native_media_recognize( + shared_module_kwargs, + cache, + ) + if mediainfo and self._media_info_has_identity(mediainfo): + self._update_local_recognize_cache(shared_cache_meta, mediainfo) + self._record_media_recognize_share_hit() + return mediainfo + if mediainfo and not fallback_mediainfo: + fallback_mediainfo = mediainfo + return fallback_mediainfo + + async def async_recognize_media( + self, + meta: MetaBase = None, + mtype: Optional[MediaType] = None, + media_source: Optional[MediaSource] = None, + media_id: Optional[str] = None, + episode_group: Optional[str] = None, + cache: bool = True, + share_meta: MetaBase = None, + music_type: Optional[str] = None, + ) -> Optional[MediaInfo]: + """ + 识别媒体信息,不含Fanart图片(异步版本) + :param meta: 识别的元数据 + :param share_meta: 共享识别查询/上报使用的原始元数据 + :param mtype: 识别的媒体类型 + :param media_source: 请求级识别数据源 + :param media_id: 数据源原生ID,必须与media_source成对提供 + :param episode_group: 剧集组 + :param cache: 是否使用缓存 + :param music_type: 音乐实体类型,显式音乐 ID 必须据此区分单曲与专辑 + :return: 识别的媒体信息,包括剧集信息 + """ + # 仅传数据源是请求级识别源约束(按名称识别限定数据源),显式 media_id 才要求来源成对 + explicit_identity = media_id is not None + requested_source = normalize_media_source(media_source) or media_source + media_source, media_id = resolve_media_identity( + media=meta, + media_source=media_source, + media_id=media_id, + ) + if explicit_identity and (not media_source or not media_id): + logger.warning("媒体识别需要同时提供有效的 media_source 和 media_id") + return None + if not media_id and requested_source is not None: + media_source = requested_source + # meta 自带同源身份(如 {tmdbid=} 标题)时直接按身份识别,避免退化为名称搜索 + meta_source, meta_id = resolve_media_identity(media=meta) + if meta_id and meta_source == requested_source: + media_source, media_id = meta_source, meta_id + if not episode_group and hasattr(meta, "episode_group"): + episode_group = meta.episode_group + if not mtype and not (media_source and media_id) and meta and meta.type in [ + MediaType.TV, MediaType.MOVIE, MediaType.MUSIC + ]: + mtype = meta.type + share_query_meta = share_meta or meta + module_kwargs = { + "meta": meta, + "mtype": mtype, + "media_source": media_source, + "media_id": media_id, + "episode_group": episode_group, + "cache": cache, + } + if music_type is not None: + module_kwargs["music_type"] = music_type + mediainfo = await self._async_run_native_media_recognize(module_kwargs, cache) + # 原生识别未取得远端身份时,允许插件按已知要素补充匹配媒体信息(影视与音乐统一) + mediainfo = await self._async_supplement_media_recognize( + meta=meta, mtype=mtype, media_source=media_source, + media_id=media_id, mediainfo=mediainfo, + music_type=music_type, + ) + fallback_mediainfo = ( + mediainfo + if mediainfo and not self._media_info_has_identity(mediainfo) + else None + ) + if mediainfo and self._media_info_has_identity(mediainfo): + # 电影、电视剧、音乐统一上报;音乐的 tmdb 等字段恒为 None,身份取数据源原生 ID + if not getattr(mediainfo, "recognize_cache_hit", False): + await MoviePilotServerHelper.async_report_recognize_share( + meta=meta, + mediainfo=mediainfo, + keyword_meta=share_query_meta, + ) + return mediainfo + + if self._can_use_media_recognize_share( + share_query_meta, media_source, media_id + ): + shared_cache_meta = self._snapshot_recognize_cache_meta(meta) + share_query_kwargs = { + "meta": meta, + "mtype": mtype, + "keyword_meta": share_query_meta, + } + if music_type is not None: + share_query_kwargs["music_type"] = music_type + shared_item = await MoviePilotServerHelper.async_query_recognize_share( + **share_query_kwargs, + ) + shared_params = MoviePilotServerHelper.to_recognize_params(shared_item) + if shared_params: + shared_module_kwargs = { + "meta": meta, + "mtype": shared_params.get("mtype") or mtype, + "media_source": shared_params.get("media_source"), + "media_id": shared_params.get("media_id"), + "episode_group": episode_group, + "cache": cache, + } + shared_music_type = shared_params.get("music_type") or music_type + if shared_music_type is not None: + shared_module_kwargs["music_type"] = shared_music_type + mediainfo = await self._async_run_native_media_recognize( + shared_module_kwargs, + cache, + ) + if mediainfo and self._media_info_has_identity(mediainfo): + await self._async_update_local_recognize_cache(shared_cache_meta, mediainfo) + await run_in_threadpool(self._record_media_recognize_share_hit) + return mediainfo + if mediainfo and not fallback_mediainfo: + fallback_mediainfo = mediainfo + return fallback_mediainfo + + @staticmethod + def _media_recognize_plugin_payload( + meta: Optional[MetaBase], + mtype: Optional[MediaType], + media_source: Optional[MediaSource], + media_id: Optional[str], + is_music: bool, + music_type: Optional[str] = None, + ) -> dict: + """ + 构造媒体识别链式事件的已知要素载荷,供插件匹配媒体信息;影视与音乐统一协议, + 仅要素字段随媒体类型不同 + """ + if is_music: + return { + "title": getattr(meta, "title", None), + "artists": list(getattr(meta, "artists", None) or []), + "album": getattr(meta, "album", None), + "year": getattr(meta, "year", None), + "isrc": getattr(meta, "isrc", None), + "media_source": media_source, + "media_id": media_id, + "music_type": music_type, + } + return { + "title": getattr(meta, "title", None) or getattr(meta, "name", None), + "year": getattr(meta, "year", None), + "season": getattr(meta, "begin_season", None), + "type": mtype.value if isinstance(mtype, MediaType) else None, + "media_source": media_source, + "media_id": media_id, + } + + @classmethod + def _media_info_from_plugin( + cls, + event_data: dict, + is_music: bool, + mtype: Optional[MediaType] = None, + music_type: Optional[str] = None, + ) -> Optional[MediaInfo]: + """ + 解析插件返回的媒体信息,缺少数据源或身份字段的结果不采信; + 音乐构造 MusicInfo,影视构造 MediaInfo + """ + if not isinstance(event_data, dict): + return None + plugin_info = event_data.get("mediainfo") + if not isinstance(plugin_info, dict): + return None + if not plugin_info.get("media_source"): + logger.warn("插件返回的媒体信息缺少数据源,忽略 ...") + return None + try: + if is_music: + if not plugin_info.get("media_id"): + logger.warn("插件返回的音乐媒体信息缺少媒体ID,忽略 ...") + return None + info: MediaInfo = MusicInfo.from_dict(plugin_info) + if not info.media_source or not info.media_id: + return None + if music_type and info.music_type != music_type: + logger.warn( + f"插件返回的音乐实体类型为 {info.music_type}," + f"与请求的 {music_type} 不一致,忽略 ..." + ) + return None + return info + # 影视:插件未提供类型时使用请求推断的类型 + if not plugin_info.get("type") and mtype: + plugin_info = {**plugin_info, "type": mtype} + info = MediaInfo() + info.from_dict(plugin_info) + except Exception as err: + logger.warn(f"插件返回的媒体信息格式错误:{err}") + return None + # 影视与音乐统一要求远端身份,无身份的结果不采信,避免未验证结果进入识别管线 + if not info.media_source or not cls._media_info_has_identity(info): + logger.warn("插件返回的媒体信息缺少远端身份,忽略 ...") + return None + return info + + @staticmethod + def _media_info_has_identity(mediainfo) -> bool: + """判断媒体信息是否具备完整的规范媒体身份。""" + media_source, media_id = resolve_media_identity(media=mediainfo) + return bool(media_source and media_id) + + def _supplement_media_recognize( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType], + media_source: Optional[MediaSource], + media_id: Optional[str], + mediainfo, + music_type: Optional[str] = None, + ): + """ + 媒体识别插件补充(影视与音乐统一):原生模块未给出带远端身份的结果时, + 广播媒体识别链式事件,允许插件(如第三方媒体源)按已知要素匹配并返回标准信息 + """ + is_music = ( + isinstance(meta, MetaMusic) + or mtype == MediaType.MUSIC + or isinstance(mediainfo, MusicInfo) + ) + # 已有远端身份时无需插件介入 + if mediainfo and self._media_info_has_identity(mediainfo): + return mediainfo + etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize + if not self.eventmanager.check(etype): + return mediainfo + result: Event = self.eventmanager.send_event( + etype, + self._media_recognize_plugin_payload( + meta, mtype, media_source, media_id, is_music, music_type + ), + ) + if not result: + return mediainfo + plugin_info = self._media_info_from_plugin( + result.event_data or {}, is_music, mtype, music_type + ) + if not plugin_info: + return mediainfo + logger.info( + f"插件补充媒体识别成功:{plugin_info.title}" + f"({plugin_info.media_source}:{plugin_info.media_id})" + ) + return plugin_info + + async def _async_supplement_media_recognize( + self, + meta: Optional[MetaBase], + mtype: Optional[MediaType], + media_source: Optional[MediaSource], + media_id: Optional[str], + mediainfo, + music_type: Optional[str] = None, + ): + """媒体识别插件补充的异步版本,影视与音乐统一流程""" + is_music = ( + isinstance(meta, MetaMusic) + or mtype == MediaType.MUSIC + or isinstance(mediainfo, MusicInfo) + ) + # 已有远端身份时无需插件介入 + if mediainfo and self._media_info_has_identity(mediainfo): + return mediainfo + etype = ChainEventType.MusicMediaRecognize if is_music else ChainEventType.MediaRecognize + if not self.eventmanager.check(etype): + return mediainfo + result: Event = await self.eventmanager.async_send_event( + etype, + self._media_recognize_plugin_payload( + meta, mtype, media_source, media_id, is_music, music_type + ), + ) + if not result: + return mediainfo + plugin_info = self._media_info_from_plugin( + result.event_data or {}, is_music, mtype, music_type + ) + if not plugin_info: + return mediainfo + logger.info( + f"插件补充媒体识别成功:{plugin_info.title}" + f"({plugin_info.media_source}:{plugin_info.media_id})" + ) + return plugin_info + diff --git a/app/chain/agent.py b/app/chain/agent.py new file mode 100644 index 000000000..11cddaa32 --- /dev/null +++ b/app/chain/agent.py @@ -0,0 +1,14 @@ +"""Agent 业务处理链。 + +AgentChain 是 agent 编排在链层的入口:Agent 运行时会话需要复用 +ChainBase 提供的消息处理状态机(渠道处理状态、直发消息等), +因此继承关系归属链层;具体 Agent 运行时(MoviePilotAgent 等)留在 app.agent。 +""" + +from app.chain import ChainBase + + +class AgentChain(ChainBase): + """Agent 业务处理链。""" + + pass diff --git a/app/chain/message.py b/app/chain/message.py index d665e9f27..15ee57d73 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -11,8 +11,12 @@ from pathlib import Path from typing import Any, Optional, Dict, Union, List, Tuple from urllib.parse import unquote, urlparse -from app.agent.orchestrator import agent_manager -from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.application.agent import ( + get_agent_manager, + is_audio_input_available, + supports_image_input, + transcribe_audio, +) from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain @@ -68,7 +72,7 @@ class MessageChain(ChainBase): return clear_task = None try: - clear_task = agent_manager.clear_session(session_id=session_id, user_id=str(userid)) + clear_task = get_agent_manager().clear_session(session_id=session_id, user_id=str(userid)) asyncio.run_coroutine_threadsafe( clear_task, global_vars.loop, @@ -346,7 +350,7 @@ class MessageChain(ChainBase): if not session_info: return False session_id, _ = session_info - if not agent_manager.matches_secret_confirmation( + if not get_agent_manager().matches_secret_confirmation( session_id, str(userid), channel=channel.value, @@ -966,7 +970,7 @@ class MessageChain(ChainBase): if session_id: clear_task = None try: - clear_task = agent_manager.clear_session( + clear_task = get_agent_manager().clear_session( session_id=session_id, user_id=str(userid) ) asyncio.run_coroutine_threadsafe( @@ -1015,7 +1019,7 @@ class MessageChain(ChainBase): session_id, _ = session_info try: future = asyncio.run_coroutine_threadsafe( - agent_manager.stop_current_task(session_id=session_id), + get_agent_manager().stop_current_task(session_id=session_id), global_vars.loop, ) stopped = future.result(timeout=10) @@ -1180,7 +1184,7 @@ class MessageChain(ChainBase): return session_id, _ = session_info - status = agent_manager.get_session_status(session_id=session_id) + status = get_agent_manager().get_session_status(session_id=session_id) self.post_message( Notification( channel=channel, @@ -1254,7 +1258,7 @@ class MessageChain(ChainBase): # 将可直接输入给 LLM 的附件统一转换为 data URL original_images = images all_files = list(files or []) - if images and LLMHelper.supports_image_input( + if images and supports_image_input( provider=settings.LLM_PROVIDER, model=settings.LLM_MODEL, ): @@ -1333,7 +1337,7 @@ class MessageChain(ChainBase): process_kwargs["has_audio_input"] = True # 在事件循环中处理 asyncio.run_coroutine_threadsafe( - agent_manager.process_message(**process_kwargs), + get_agent_manager().process_message(**process_kwargs), global_vars.loop, ) return True @@ -1353,7 +1357,7 @@ class MessageChain(ChainBase): """ if not audio_refs: return None - if not AgentCapabilityManager.is_audio_input_available(): + if not is_audio_input_available(): logger.warning("音频输入能力未配置或未启用,跳过语音识别") return None @@ -1460,7 +1464,7 @@ class MessageChain(ChainBase): ) continue - transcript = AgentCapabilityManager.transcribe_audio( + transcript = transcribe_audio( content=content, filename=filename ) if transcript: diff --git a/app/chain/search.py b/app/chain/search.py index 53f7c2965..9e87768bd 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -509,10 +509,10 @@ class SearchChain(ChainBase): """ 通过统一后台提示词机制执行资源推荐。 """ - from app.agent.orchestrator import ReplyMode, agent_manager - from app.agent.prompt import prompt_manager + from app.application.agent import get_agent_manager, get_prompt_manager + from app.schemas.agent import ReplyMode - prompt = prompt_manager.render_system_task_message( + prompt = get_prompt_manager().render_system_task_message( "search_recommend", template_context={"search_results": search_results_text}, ) @@ -521,7 +521,7 @@ class SearchChain(ChainBase): def on_output(text: str): full_output[0] = text - await agent_manager.run_background_prompt( + await get_agent_manager().run_background_prompt( message=prompt, session_prefix="__agent_search_recommend", output_callback=on_output, diff --git a/app/chain/site.py b/app/chain/site.py index 5c0246a72..f19b8af9a 100644 --- a/app/chain/site.py +++ b/app/chain/site.py @@ -1,13 +1,14 @@ import base64 import re from datetime import datetime -from typing import Callable, List, Optional, Tuple, Union, Dict +from typing import Callable, Optional, Tuple, Union, Dict from urllib.parse import urljoin from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from lxml import etree from app.chain import ChainBase +from app.chain._interaction import InteractionChainMixin from app.runtime.config import global_vars, settings from app.runtime.events import Event, eventmanager from app.db.models.site import Site @@ -17,10 +18,7 @@ from app.adapters.network.browser import PlaywrightHelper from app.adapters.network.cloudflare import under_challenge from app.application.security.cookie import CookieHelper from app.adapters.external.cookiecloud import CookieCloudHelper -from app.application.messaging.site import ( - SiteInteractionHandler, - site_interaction_manager, -) +from app.application.messaging.site import SiteInteractionHandler from app.application.rss import RssHelper from app.runtime.log import logger from app.schemas import MessageChannel, Notification, SiteUserData @@ -33,12 +31,13 @@ from app.foundation import url as url_tools from app.foundation.dom import DomUtils - -class SiteChain(ChainBase): +class SiteChain(InteractionChainMixin, ChainBase): """ 站点管理处理链 """ + # 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托 + _interaction_handler_type = SiteInteractionHandler def __init__(self): """初始化站点管理处理链及特殊站点测试器""" @@ -752,66 +751,6 @@ class SiteChain(ChainBase): """构造 /sites 交互处理器,Cookie 更新动作由本链提供。""" return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie) - def remote_list( - self, - arg_str: str = "", - channel: MessageChannel = None, - userid: Union[str, int] = None, - source: Optional[str] = None, - ): - """ - /sites 统一入口,委托交互处理器。 - """ - return self._interaction_handler().remote_list( - arg_str=arg_str, channel=channel, userid=userid, source=source - ) - - @staticmethod - def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]: - """ - 解析 /sites 按钮回调。 - """ - return SiteInteractionHandler.parse_callback(callback_data) - - def handle_callback_interaction( - self, - callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> bool: - """委托交互处理器处理按钮回调。""" - return self._interaction_handler().handle_callback_interaction( - callback_data=callback_data, - channel=channel, - source=source, - userid=userid, - username=username, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - - def handle_text_interaction( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - text: str, - ) -> bool: - """委托交互处理器处理文本输入。""" - return self._interaction_handler().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ) - - def remote_disable(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 60cb3f923..578b739b7 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -1,7 +1,6 @@ import copy import json import random -import re import threading import time from datetime import datetime @@ -9,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional, Union, Tuple from app import schemas from app.chain import ChainBase +from app.chain._interaction import InteractionChainMixin +from app.chain._music import MusicSubscribeMixin from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain @@ -19,7 +20,6 @@ from app.runtime.config import settings, global_vars from app.domain.context import ( Context, MediaInfo, - MusicInfo, TorrentInfo, ) from app.runtime.events import eventmanager, Event @@ -32,10 +32,7 @@ from app.db.models.subscribe import Subscribe from app.db.oper.site import SiteOper from app.db.oper.subscribe import SubscribeOper from app.db.oper.systemconfig import SystemConfigOper -from app.application.messaging.subscribe import ( - SubscribeInteractionHandler, - subscribe_interaction_manager, -) +from app.application.messaging.subscribe import SubscribeInteractionHandler from app.application.mediaserver import MediaServerHelper from app.application.subscribe import add_subscribe, async_add_subscribe from app.adapters.external.server import MoviePilotServerHelper @@ -43,22 +40,11 @@ from app.application.torrent import TorrentHelper from app.runtime.log import logger from app.schemas import (SubscribeEpisodesRefreshEventData, SubscribeCompletionCheckEventData) -from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \ +from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \ ContentType -from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity - -def _normalize_music_total_tracks(value: Any) -> Optional[int]: - """将专辑曲目总数归一为正整数,无效或未知值返回 None。""" - try: - total_tracks = int(value or 0) - except (TypeError, ValueError): - return None - return total_tracks if total_tracks > 0 else None - - def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: """ 按订阅对象构造主程序链路共用的媒体元数据。 @@ -116,7 +102,7 @@ def _subscribe_media_keys(subscribe: Subscribe) -> List[Union[str, int]]: return [candidate for candidate in candidates if candidate not in (None, "")] -class SubscribeChain(ChainBase): +class SubscribeChain(MusicSubscribeMixin, InteractionChainMixin, ChainBase): """ 订阅管理处理链。 @@ -133,6 +119,9 @@ class SubscribeChain(ChainBase): 电影下载优先级 writer 单独维护。 """ + # 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托 + _interaction_handler_type = SubscribeInteractionHandler + _rlock = threading.RLock() # 避免莫名原因导致长时间持有锁 _LOCK_TIMOUT = 3600 * 2 @@ -1261,378 +1250,6 @@ class SubscribeChain(ChainBase): return True return False - @staticmethod - def _validate_music_subscribe_target( - mediainfo: MediaInfo, - requested_music_type: Optional[str] = None, - ) -> Optional[str]: - """校验音乐订阅实体一致性,并确保专辑具备可验证的曲目总数。""" - if mediainfo.type != MediaType.MUSIC: - return "识别结果不是音乐" - music_type = getattr(mediainfo, "music_type", None) - if requested_music_type and requested_music_type not in MUSIC_SUBSCRIBABLE_TYPES: - return "音乐订阅仅支持单曲或专辑" - if music_type not in MUSIC_SUBSCRIBABLE_TYPES: - return "音乐订阅仅支持单曲或专辑" - if requested_music_type and requested_music_type != music_type: - return f"音乐订阅类型不匹配:请求 {requested_music_type},识别为 {music_type}" - if music_type == MUSIC_ENTITY_ALBUM \ - and _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) is None: - return "专辑总曲目数未知,无法校验整张专辑资源" - return None - - @staticmethod - def _ensure_music_subscribe_entity( - subscribe: Subscribe, - mediainfo: Optional[MusicInfo], - ) -> Optional[MusicInfo]: - """保持已持久化的单曲/专辑实体边界,拒绝远端详情把订阅类型改写。""" - if not mediainfo: - return None - expected_type = getattr(subscribe, "music_type", None) - actual_type = getattr(mediainfo, "music_type", None) - if expected_type and expected_type not in MUSIC_SUBSCRIBABLE_TYPES: - logger.warning(f"音乐订阅 {subscribe.name} 的实体类型无效:{expected_type}") - return None - if actual_type not in MUSIC_SUBSCRIBABLE_TYPES: - logger.warning( - f"音乐订阅 {subscribe.name} 识别为不可订阅实体:{actual_type}" - ) - if expected_type in MUSIC_SUBSCRIBABLE_TYPES: - return SubscribeChain._music_info_from_subscribe(subscribe) - return None - if expected_type and actual_type != expected_type: - logger.warning( - f"音乐订阅 {subscribe.name} 实体不匹配:" - f"订阅为 {expected_type},远端识别为 {actual_type},使用订阅快照" - ) - return SubscribeChain._music_info_from_subscribe(subscribe) - if actual_type == MUSIC_ENTITY_ALBUM: - remote_total = _normalize_music_total_tracks(getattr(mediainfo, "total_tracks", None)) - stored_total = _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None)) - resolved_total = remote_total or stored_total - if resolved_total is not None and mediainfo.total_tracks != resolved_total: - # 识别模块结果可能来自共享缓存,补齐订阅快照时不得原地修改。 - mediainfo = copy.copy(mediainfo) - mediainfo.total_tracks = resolved_total - return mediainfo - - @staticmethod - def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: - """按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" - if subscribe.media_source and subscribe.media_id: - # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 - mediainfo = MediaChain().recognize_media( - media_source=subscribe.media_source, - media_id=str(subscribe.media_id), - mtype=MediaType.MUSIC, - music_type=getattr(subscribe, "music_type", None), - ) - if mediainfo: - return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) - if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: - return SubscribeChain._music_info_from_subscribe(subscribe) - # 旧订阅没有保存实体类型时不能猜测为单曲,否则可能误把专辑按单曲完成。 - return None - if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: - # 缺少远端 ID 的专辑不能退化为单曲识别,使用已保存专辑快照更可靠。 - return SubscribeChain._music_info_from_subscribe(subscribe) - # 旧订阅没有实体类型时只允许走 Recording 识别,不能从全局混合搜索中猜成专辑或艺术家。 - mediainfo = MediaChain().recognize_media( - meta=build_subscribe_meta(subscribe), - mtype=MediaType.MUSIC, - media_source=subscribe.media_source, - music_type=MUSIC_ENTITY_RECORDING, - ) - return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) - - @staticmethod - async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: - """异步按订阅身份恢复音乐目标,远端暂不可用时使用已持久化的稳定快照。""" - if subscribe.media_source and subscribe.media_id: - # 与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 - mediainfo = await MediaChain().async_recognize_media( - media_source=subscribe.media_source, - media_id=str(subscribe.media_id), - mtype=MediaType.MUSIC, - music_type=getattr(subscribe, "music_type", None), - ) - if mediainfo: - return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) - if getattr(subscribe, "music_type", None) in {MUSIC_ENTITY_RECORDING, MUSIC_ENTITY_ALBUM}: - return SubscribeChain._music_info_from_subscribe(subscribe) - return None - if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: - return SubscribeChain._music_info_from_subscribe(subscribe) - mediainfo = await MediaChain().async_recognize_media( - meta=build_subscribe_meta(subscribe), - mtype=MediaType.MUSIC, - media_source=subscribe.media_source, - music_type=MUSIC_ENTITY_RECORDING, - ) - return SubscribeChain._ensure_music_subscribe_entity(subscribe, mediainfo) - - @staticmethod - def _music_info_from_subscribe(subscribe: Subscribe) -> MusicInfo: - """从订阅行恢复不依赖远端请求的最小音乐目标,保留专辑完成判断所需字段。""" - year_text = str(subscribe.year or "")[:4] - music_type = getattr(subscribe, "music_type", None) - # 音乐订阅的 description 由标准 MusicInfo.overview 生成,首段固定为艺术家。 - artist_text = str(getattr(subscribe, "description", None) or "") \ - .split(" · ", maxsplit=1)[0].strip() - artists = [ - artist.strip() for artist in artist_text.split(" / ") if artist.strip() - ] - return MusicInfo( - media_source=subscribe.media_source, - media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, - music_type=music_type, - title=subscribe.name, - artists=artists, - album=subscribe.name if music_type == MUSIC_ENTITY_ALBUM else None, - year=int(year_text) if year_text.isdigit() else None, - total_tracks=getattr(subscribe, "total_tracks", None) - if music_type == MUSIC_ENTITY_ALBUM else None, - cover_url=getattr(subscribe, "poster", None) or getattr(subscribe, "backdrop", None), - ) - - @staticmethod - def _sync_music_subscribe_target(subscribe: Subscribe, mediainfo: MusicInfo) -> None: - """把远端识别得到的专辑类型和总曲目数同步到订阅,供搜索失败与完成历史复用。""" - update_data = {} - if mediainfo.music_type and getattr(subscribe, "music_type", None) != mediainfo.music_type: - update_data["music_type"] = mediainfo.music_type - if mediainfo.music_type == MUSIC_ENTITY_ALBUM: - # 远端详情可能暂时不返回曲目数;已确认的订阅快照不能因此被清空。 - total_tracks = _normalize_music_total_tracks(mediainfo.total_tracks) \ - or _normalize_music_total_tracks(getattr(subscribe, "total_tracks", None)) - else: - total_tracks = None - if getattr(subscribe, "total_tracks", None) != total_tracks: - update_data["total_tracks"] = total_tracks - if not update_data: - return - SubscribeOper().update(subscribe.id, update_data) - for key, value in update_data.items(): - setattr(subscribe, key, value) - - @staticmethod - def _is_music_download_complete( - subscribe: Subscribe, - mediainfo: MusicInfo, - downloads: Optional[List[Context]], - ) -> bool: - """判断音乐下载是否满足订阅完成条件;专辑必须由下载层确认整专曲目覆盖。""" - if not downloads: - return False - music_type = getattr(subscribe, "music_type", None) or mediainfo.music_type - if music_type != MUSIC_ENTITY_ALBUM: - return True - return any(context.confirmed_full_coverage for context in downloads) - - def _prepare_music_subscribe( - self, - subscribe: Subscribe, - ) -> Optional[Tuple[MusicInfo, MetaMusic]]: - """识别音乐订阅目标、同步实体快照,并在搜索前处理已完整入库的目标。""" - mediainfo = self._recognize_music_subscribe(subscribe) - if not mediainfo: - logger.warning( - f"未识别到音乐订阅目标:{subscribe.name}," - f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}" - ) - return None - validation_error = self._validate_music_subscribe_target( - mediainfo, - getattr(subscribe, "music_type", None), - ) - if validation_error: - logger.warning(f"音乐订阅 {subscribe.name} 无法继续:{validation_error}") - return None - self._sync_music_subscribe_target(subscribe, mediainfo) - meta = MetaMusic.from_music_info(mediainfo) - exists, _ = self.check_and_handle_existing_media( - subscribe=subscribe, - meta=meta, - mediainfo=mediainfo, - mediakey=_subscribe_media_key(subscribe), - ) - if exists: - return None - return mediainfo, meta - - def _filter_music_subscribe_contexts( - self, - subscribe: Subscribe, - mediainfo: MusicInfo, - contexts: List[Context], - ) -> List[Context]: - """按站点、音乐实体、订阅参数和优先级规则筛选并绑定下载上下文。""" - sites = self.get_sub_sites(subscribe) - default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ - if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups - rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] - torrent_helper = TorrentHelper() - matched: List[Context] = [] - for source_context in contexts or []: - source_torrent = source_context.torrent_info - if not source_torrent or source_torrent.category not in (MediaType.MUSIC, MediaType.MUSIC.value): - continue - # 过滤模块会就地写入 pri_order;RSS 缓存会被多个订阅复用,必须隔离候选副本。 - torrent = copy.copy(source_torrent) - if sites and torrent.site not in sites: - continue - if not SearchChain.matches_music_resource( - mediainfo, - torrent.title, - torrent.description, - ): - continue - if not torrent_helper.filter_torrent(torrent, self.get_params(subscribe)): - continue - filtered = self.filter_torrents( - rule_groups=rule_groups, - torrent_list=[torrent], - mediainfo=mediainfo, - ) - if filtered is not None: - if not filtered: - continue - torrent = filtered[0] - - context = copy.copy(source_context) - context.torrent_info = torrent - meta = MetaMusic.from_music_info(mediainfo) - meta.org_string = torrent.title - meta.apply_audio_quality(f"{torrent.title} {torrent.description or ''}", overwrite=True) - if subscribe.best_version: - # 用户规则组可用格式、码率等内置规则定义洗版顺序;未命中规则 - # 优先级时再回退到规范化音质分数,确保零配置也能自动升级。 - music_priority = torrent.pri_order or meta.audio_quality_score - if music_priority <= (subscribe.current_priority or 0): - logger.info( - f"{torrent.title} 音质优先级 {music_priority} " - f"未高于当前版本 {subscribe.current_priority or 0}" - ) - continue - torrent.pri_order = music_priority - context.meta_info = meta - context.media_info = mediainfo - context.match_source = str(mediainfo.media_source or "title") - context.candidate_recognized = False - context.media_info_is_target = True - if subscribe.media_category: - context.media_info.category = subscribe.media_category - matched.append(context) - return matched - - def _download_music_subscribe( - self, - subscribe: Subscribe, - mediainfo: MusicInfo, - contexts: List[Context], - ) -> None: - """批量择优下载音乐候选,并按单曲或整专完成语义推进订阅。""" - if not contexts: - return - downloads, _ = DownloadChain().batch_download( - contexts=contexts, - username=subscribe.username, - save_path=subscribe.save_path, - downloader=subscribe.downloader, - source=self.get_subscribe_source_keyword(subscribe), - custom_words=subscribe.custom_words, - ) - successful = [ - context for context in downloads or [] - if context and context.meta_info and context.torrent_info - ] - quality_downloads = successful - if getattr(subscribe, "music_type", None) == MUSIC_ENTITY_ALBUM: - quality_downloads = [ - context for context in successful - if context.confirmed_full_coverage - ] - if subscribe.best_version and quality_downloads: - best_context = max(quality_downloads, key=lambda item: item.torrent_info.pri_order) - best_meta = best_context.meta_info - quality_data = { - "current_priority": best_context.torrent_info.pri_order, - "current_audio_format": best_meta.audio_format, - "current_bitrate": best_meta.bitrate, - "current_bit_depth": best_meta.bit_depth, - "current_sample_rate": best_meta.sample_rate, - } - SubscribeOper().update(subscribe.id, quality_data) - for key, value in quality_data.items(): - setattr(subscribe, key, value) - current_subscribe = SubscribeOper().get(subscribe.id) - if current_subscribe: - self.finish_subscribe_or_not( - subscribe=current_subscribe, - meta=MetaMusic.from_music_info(mediainfo), - mediainfo=mediainfo, - downloads=downloads, - ) - - def _search_music_subscribe(self, subscribe: Subscribe) -> None: - """复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。""" - target = self._prepare_music_subscribe(subscribe) - if not target: - return - mediainfo, _ = target - - sites = self.get_sub_sites(subscribe) - default_rule_key = SystemConfigKey.BestVersionFilterRuleGroups \ - if subscribe.best_version else SystemConfigKey.SubscribeFilterRuleGroups - rule_groups = subscribe.filter_groups or SystemConfigOper().get(default_rule_key) or [] - keywords = [subscribe.keyword] if subscribe.keyword else SearchChain.music_site_keywords(mediainfo) - if not keywords: - keywords = [subscribe.name] - - searchchain = SearchChain() - contexts: List[Context] = [] - for keyword in keywords: - contexts = searchchain.search_by_title( - title=keyword, - sites=sites, - mtype=MediaType.MUSIC, - rule_groups=rule_groups, - ) - contexts = self._filter_music_subscribe_contexts( - subscribe=subscribe, - mediainfo=mediainfo, - contexts=contexts, - ) - if contexts: - break - - if not contexts: - logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源") - return - - self._download_music_subscribe(subscribe, mediainfo, contexts) - - def _match_music_subscribe( - self, - subscribe: Subscribe, - contexts: List[Context], - ) -> None: - """直接匹配本轮 RSS 缓存中的音乐资源,避免再次调用站点搜索接口。""" - target = self._prepare_music_subscribe(subscribe) - if not target: - return - mediainfo, _ = target - matched = self._filter_music_subscribe_contexts( - subscribe=subscribe, - mediainfo=mediainfo, - contexts=contexts, - ) - if not matched: - logger.info(f"音乐订阅 {subscribe.name} 未匹配到符合条件的 RSS 资源") - return - self._download_music_subscribe(subscribe, mediainfo, matched) - def search( self, sid: Optional[int] = None, @@ -3247,66 +2864,6 @@ class SubscribeChain(ChainBase): """构造 /subscribes 交互处理器,业务动作由本链提供。""" return SubscribeInteractionHandler(messenger=self, actions=self) - def remote_list( - self, - arg_str: str = "", - channel: MessageChannel = None, - userid: Union[str, int] = None, - source: Optional[str] = None, - ): - """ - /subscribes 统一入口,委托交互处理器。 - """ - return self._interaction_handler().remote_list( - arg_str=arg_str, channel=channel, userid=userid, source=source - ) - - @staticmethod - def parse_callback(callback_data: str) -> Optional[Tuple[str, str]]: - """ - 解析 /subscribes 按钮回调。 - """ - return SubscribeInteractionHandler.parse_callback(callback_data) - - def handle_callback_interaction( - self, - callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - original_message_id: Optional[Union[str, int]] = None, - original_chat_id: Optional[str] = None, - ) -> bool: - """委托交互处理器处理按钮回调。""" - return self._interaction_handler().handle_callback_interaction( - callback_data=callback_data, - channel=channel, - source=source, - userid=userid, - username=username, - original_message_id=original_message_id, - original_chat_id=original_chat_id, - ) - - def handle_text_interaction( - self, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - text: str, - ) -> bool: - """委托交互处理器处理文本输入。""" - return self._interaction_handler().handle_text_interaction( - channel=channel, - source=source, - userid=userid, - username=username, - text=text, - ) - - def remote_delete(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None): """ diff --git a/app/chain/transfer.py b/app/chain/transfer.py index 2c4df67e1..2c3c86b76 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -6,16 +6,11 @@ import traceback import uuid from copy import deepcopy from pathlib import Path -from time import monotonic from typing import List, Optional, Tuple, Union, Dict, Callable, Any -from app import schemas -from app.agent.orchestrator import ReplyMode, agent_manager, prompt_manager -from app.agent.prompt.transfer_redo import build_manual_redo_prompt from app.chain import ChainBase from app.chain.media import MediaChain from app.chain.storage import StorageChain -from app.chain.subscribe import SubscribeChain from app.chain.tmdb import TmdbChain from app.runtime.config import settings, global_vars from app.domain.context import MediaInfo, MusicInfo @@ -24,19 +19,17 @@ from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfoPath from app.db.oper.downloadhistory import DownloadHistoryOper -from app.db.models.downloadhistory import DownloadHistory, DownloadFiles -from app.db.models.transferhistory import TransferHistory +from app.db.models.downloadhistory import DownloadHistory from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.transferpending import TransferPendingOper from app.db.oper.transferhistory import TransferHistoryOper from app.application.directory import DirectoryHelper -from app.application.audio import AudioMetadataHelper -from app.application.formatting import EpisodeFormatRuleHelper, FormatParser +from app.application.formatting import FormatParser from app.runtime.progress import ProgressHelper from app.application.history import (add_transfer_fail, add_transfer_success, - clear_transfer_failures, describe_history_gate, - evaluate_history_gate, is_skip_action, - record_transfer_failure, resolve_history) + clear_transfer_failures, describe_history_gate, + evaluate_history_gate, is_skip_action, + record_transfer_failure) from app.runtime.log import logger from app.schemas import StorageOperSelectionEventData from app.schemas import ( @@ -46,7 +39,6 @@ from app.schemas import ( FileItem, TransferDirectoryConf, TransferJob, - TransferJobTask, TmdbEpisode, ) from app.schemas.exception import OperationInterrupted @@ -60,931 +52,27 @@ from app.schemas.types import ( SystemConfigKey, ChainEventType, ContentType, - MUSIC_ENTITY_ALBUM, - MUSIC_ENTITY_RECORDING, MediaSource, ) from app.runtime.reload import ConfigReloadMixin -from app.application.transfer import TransferQueue, TransferTask -from app.domain.media import normalize_music_type -from app.schemas.media import normalize_media_source, resolve_media_identity +from app.application.transfer import (FailedRetryScheduler, JobManager, + TransferQueue, TransferTask, job_lock) +from app.chain._mixins import (EpisodeFormatMixin, FailedRetryMixin, + FileFilterMixin, FileKeyMixin, + HistoryMatchMixin, ManualHistoryMixin, + ScrapeBatchMixin) +from app.schemas.media import resolve_media_identity from app.foundation.singleton import Singleton from app.domain import episode as episode_rules -from app.foundation import text as text_tools -from app.adapters.system.host import SystemUtils # 下载器锁 downloader_lock = threading.Lock() -# 作业锁 -job_lock = threading.Lock() # 任务锁 task_lock = threading.Lock() -# 字幕文件常见的语言/默认/强制标记,整理同名字幕时只允许剥离这些字幕专属尾缀。 -SUBTITLE_STEM_TAGS = { - "cc", - "chi", - "chs", - "cht", - "cn", - "default", - "en", - "eng", - "english", - "forced", - "gb", - "gb2312", - "hk", - "ja", - "jap", - "japanese", - "jp", - "jpn", - "sc", - "sdh", - "tc", - "zh", - "zh-cn", - "zh-hans", - "zh-hant", - "zh-tw", - "zh_cn", - "zh_hans", - "zh_hant", - "zh_tw", - "zho", - "中英", - "中字", - "双语", - "简中", - "简体", - "繁中", - "繁体", -} - -class JobManager: - """ - 作业管理器 - task任务负责一个文件的整理,job作业负责一个媒体的整理 - """ - - # 整理中的作业 - _job_view: Dict[Tuple, TransferJob] = {} - # 汇总季集清单 - _season_episodes: Dict[Tuple, List[int]] = {} - # 记录从 meta 作业迁移到 media 作业的关系,用于清理提前失败后残留的 media 作业 - _meta_to_media_ids: Dict[Tuple, set[Tuple]] = {} - # 记录任务最近一次状态心跳,供外部异步接管任务的失活检测使用 - _task_state_changed_at: Dict[Tuple[str, str], float] = {} - # 记录仍由主程序整理线程直接执行的任务,避免把阻塞中的本地任务误判为失活 - _active_executions: set[Tuple[str, str]] = set() - - def __init__(self): - self._job_view = {} - self._season_episodes = {} - self._meta_to_media_ids = {} - self._task_state_changed_at = {} - self._active_executions = set() - - @staticmethod - def __get_meta_id(meta: MetaBase = None, season: Optional[int] = None) -> Tuple: - """ - 获取元数据ID - """ - return meta.name, season - - @staticmethod - def __get_media_id(media: Optional[Union[MediaInfo, MusicInfo]] = None, - season: Optional[int] = None) -> Tuple: - """ - 获取媒体ID;音乐额外区分实体类型,并为无远端ID的曲目构造稳定身份。 - """ - if not media: - return None, season - source, media_id = resolve_media_identity(media=media) - if getattr(media, "type", None) == MediaType.MUSIC: - music_type = normalize_music_type( - getattr(media, "music_type", None), - ) or MUSIC_ENTITY_RECORDING - if source and media_id: - return "music", source, media_id, music_type - - artists = tuple( - text_tools.normalize_upper(artist) - for artist in (getattr(media, "artists", None) or []) - if text_tools.normalize_upper(artist) - ) - if music_type == MUSIC_ENTITY_ALBUM: - album_artist = text_tools.normalize_upper( - getattr(media, "album_artist", None) - or (artists[0] if artists else "") - ) - album = text_tools.normalize_upper( - getattr(media, "album", None) or getattr(media, "title", None) or "" - ) - return "music", "local", music_type, album_artist, album, getattr(media, "year", None) - - return ( - "music", - "local", - music_type, - artists, - text_tools.normalize_upper(getattr(media, "title", None) or ""), - text_tools.normalize_upper(getattr(media, "album", None) or ""), - getattr(media, "disc_number", None), - getattr(media, "track_number", None), - ) - return (source, media_id), season - - @staticmethod - def __get_file_key(fileitem: FileItem) -> Optional[Tuple[str, str]]: - """ - 获取源文件唯一键,用于跨媒体作业识别同一个整理任务。 - """ - if not fileitem or not fileitem.path: - return None - normalized_path = ( - Path(str(fileitem.path).replace("\\", "/")).as_posix().rstrip("/") or "/" - ) - return fileitem.storage or "local", normalized_path - - def __get_id(self, task: TransferTask = None) -> Tuple: - """ - 获取作业ID - """ - if task.mediainfo: - return self.__get_media_id( - media=task.mediainfo, season=task.meta.begin_season - ) - else: - return self.__get_meta_id(meta=task.meta, season=task.meta.begin_season) - - def get_job_id(self, task: TransferTask) -> Tuple: - """返回任务当前所属的稳定作业身份,供作业级附加状态隔离使用。""" - return self.__get_id(task) - - @staticmethod - def __get_media(task: TransferTask) -> Union[schemas.MediaInfo, schemas.MusicInfo]: - """ - 获取媒体信息 - """ - if task.mediainfo: - # 有媒体信息 - mediainfo = deepcopy(task.mediainfo) - mediainfo.clear() - if isinstance(mediainfo, MusicInfo): - return schemas.MusicInfo(**mediainfo.to_dict()) - return schemas.MediaInfo(**mediainfo.to_dict()) - else: - # 没有媒体信息 - meta: MetaBase = task.meta - if isinstance(meta, MetaMusic): - # 未识别的音乐按已解析元数据兜底展示;音乐年份为 int, - # 不能复用 MediaInfo(year 为 str),否则触发 pydantic 校验异常 - return schemas.MusicInfo( - title=meta.name, - artists=list(meta.artists or []), - artist=meta.artist, - album=meta.album, - album_artist=meta.album_artist, - year=meta.year, - title_year=f"{meta.name} ({meta.year})" if meta.year else meta.name, - media_source=meta.media_source, - media_id=meta.media_id, - ) - return schemas.MediaInfo( - title=meta.name, - year=meta.year, - title_year=f"{meta.name} ({meta.year})", - type=meta.type.value if meta.type else None, - ) - - @staticmethod - def __get_meta(task: TransferTask) -> schemas.MetaInfo: - """ - 获取元数据 - """ - if isinstance(task.meta, MetaMusic): - return schemas.MusicMeta(**task.meta.to_dict()) - return schemas.MetaInfo(**task.meta.to_dict()) - - def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: - """ - 添加整理任务,自动分组到对应的作业中 - :return: True表示任务已添加,False表示任务无效或已存在(重复) - """ - if not all([task, task.meta, task.fileitem]): - return False - file_key = self.__get_file_key(task.fileitem) - if not file_key: - return False - with job_lock: - __mediaid__ = self.__get_id(task) - # 同一个源文件可能在识别前后落入不同作业,必须跨作业去重。 - if any( - self.__get_file_key(t.fileitem) == file_key - for job in self._job_view.values() - for t in job.tasks - ): - logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加") - return False - if __mediaid__ not in self._job_view: - self._job_view[__mediaid__] = TransferJob( - media=self.__get_media(task), - season=task.meta.begin_season, - tasks=[ - TransferJobTask( - fileitem=task.fileitem, - meta=self.__get_meta(task), - downloader=task.downloader, - download_hash=task.download_hash, - state=state, - ) - ], - ) - else: - # 不重复添加任务 - if any( - [ - self.__get_file_key(t.fileitem) == file_key - for t in self._job_view[__mediaid__].tasks - ] - ): - logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加") - return False - self._job_view[__mediaid__].tasks.append( - TransferJobTask( - fileitem=task.fileitem, - meta=self.__get_meta(task), - downloader=task.downloader, - download_hash=task.download_hash, - state=state, - ) - ) - self._task_state_changed_at[file_key] = monotonic() - # 添加季集信息 - if self._season_episodes.get(__mediaid__): - self._season_episodes[__mediaid__].extend(task.meta.episode_list) - self._season_episodes[__mediaid__] = list( - set(self._season_episodes[__mediaid__]) - ) - else: - self._season_episodes[__mediaid__] = task.meta.episode_list - return True - - def migrate_task(self, task: TransferTask) -> bool: - """ - 将任务从 meta 作业迁移到 media 作业 - """ - curr_task, source_job_id = self.__remove_task_with_job_id( - task.fileitem, preserve_execution=True - ) - if not self.add_task(task, state=curr_task.state if curr_task else "waiting"): - return False - if curr_task and task.mediainfo: - metaid = self.__get_meta_id( - meta=task.meta, season=task.meta.begin_season - ) - mediaid = self.__get_id(task) - if source_job_id == metaid and mediaid != metaid: - with job_lock: - self._meta_to_media_ids.setdefault(metaid, set()).add(mediaid) - return True - - def __is_job_done(self, job_id: Tuple) -> bool: - """ - 检查指定作业是否已完成 - """ - if job_id not in self._job_view: - return True - return all( - task.state in ["completed", "failed"] - for task in self._job_view[job_id].tasks - ) - - def __pop_job(self, job_id: Tuple): - """ - 移除指定作业和对应季集缓存 - """ - job = self._job_view.pop(job_id, None) - self._season_episodes.pop(job_id, None) - if not job: - return - for task in job.tasks: - file_key = self.__get_file_key(task.fileitem) - if file_key: - self._task_state_changed_at.pop(file_key, None) - self._active_executions.discard(file_key) - - def __remove_done_job_groups(self, job_ids: set[Tuple]): - """ - 清理已进入终态的独立作业或关联作业组。 - """ - candidates = set(job_ids) - for metaid, mediaids in list(self._meta_to_media_ids.items()): - related_ids = {metaid, *mediaids} - if not related_ids.intersection(candidates): - continue - if all(self.__is_job_done(job_id) for job_id in related_ids): - for job_id in related_ids: - self.__pop_job(job_id) - self._meta_to_media_ids.pop(metaid, None) - candidates.difference_update(related_ids) - - referenced_ids = { - job_id - for metaid, mediaids in self._meta_to_media_ids.items() - for job_id in {metaid, *mediaids} - } - for job_id in candidates - referenced_ids: - if self.__is_job_done(job_id): - self.__pop_job(job_id) - - def start_execution(self, task: TransferTask): - """ - 标记任务仍由主程序整理线程直接执行。 - - :param task: 整理任务 - """ - if not task or not task.fileitem: - return - file_key = self.__get_file_key(task.fileitem) - if not file_key: - return - with job_lock: - self._active_executions.add(file_key) - - def finish_execution(self, task: TransferTask): - """ - 结束主程序整理线程对任务的直接执行标记。 - - :param task: 整理任务 - """ - if not task or not task.fileitem: - return - file_key = self.__get_file_key(task.fileitem) - if not file_key: - return - with job_lock: - self._active_executions.discard(file_key) - - def expire_stale_running_tasks( - self, timeout_seconds: int - ) -> List[Tuple[FileItem, int]]: - """ - 将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。 - - 主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务 - 误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。 - - :param timeout_seconds: 失活超时秒数,小于等于 0 时禁用 - :return: 已失活任务及其无心跳秒数 - """ - if timeout_seconds <= 0: - return [] - - current_time = monotonic() - expired: List[Tuple[FileItem, int]] = [] - affected_job_ids: set[Tuple] = set() - with job_lock: - for mediaid, job in self._job_view.items(): - for task in job.tasks: - file_key = self.__get_file_key(task.fileitem) - if ( - not file_key - or task.state != "running" - or file_key in self._active_executions - ): - continue - updated_at = self._task_state_changed_at.get(file_key, current_time) - inactive_seconds = current_time - updated_at - if inactive_seconds < timeout_seconds: - continue - task.state = "failed" - self._task_state_changed_at[file_key] = current_time - episodes = getattr(task.meta, "episode_list", None) or [] - if mediaid in self._season_episodes: - self._season_episodes[mediaid] = list( - set(self._season_episodes[mediaid]) - set(episodes) - ) - expired.append((task.fileitem, int(inactive_seconds))) - affected_job_ids.add(mediaid) - - self.__remove_done_job_groups(affected_job_ids) - return expired - - def running_task(self, task: TransferTask): - """ - 设置任务为运行中,并刷新外部异步任务的状态心跳。 - """ - with job_lock: - __mediaid__ = self.__get_id(task) - if __mediaid__ not in self._job_view: - return - # 更新状态 - for t in self._job_view[__mediaid__].tasks: - if t.fileitem == task.fileitem: - t.state = "running" - file_key = self.__get_file_key(t.fileitem) - if file_key: - self._task_state_changed_at[file_key] = monotonic() - break - - def finish_task(self, task: TransferTask): - """ - 设置任务为完成/成功 - """ - with job_lock: - __mediaid__ = self.__get_id(task) - if __mediaid__ not in self._job_view: - return - # 更新状态 - for t in self._job_view[__mediaid__].tasks: - if t.fileitem == task.fileitem: - t.state = "completed" - file_key = self.__get_file_key(t.fileitem) - if file_key: - self._task_state_changed_at[file_key] = monotonic() - break - - def fail_task(self, task: TransferTask): - """ - 设置任务为失败 - """ - with job_lock: - __mediaid__ = self.__get_id(task) - if __mediaid__ not in self._job_view: - return - # 更新状态 - for t in self._job_view[__mediaid__].tasks: - if t.fileitem == task.fileitem: - t.state = "failed" - file_key = self.__get_file_key(t.fileitem) - if file_key: - self._task_state_changed_at[file_key] = monotonic() - break - # 移除剧集信息 - if __mediaid__ in self._season_episodes: - self._season_episodes[__mediaid__] = list( - set(self._season_episodes[__mediaid__]) - - set(task.meta.episode_list) - ) - - def fail_unfinished_task(self, task: TransferTask): - """ - 将指定任务视图中的非终态任务标记为失败 - """ - if not task or not task.fileitem: - return - file_key = self.__get_file_key(task.fileitem) - if not file_key: - return - with job_lock: - for mediaid, job in self._job_view.items(): - for job_task in job.tasks: - if self.__get_file_key(job_task.fileitem) != file_key: - continue - if job_task.state not in ["completed", "failed"]: - job_task.state = "failed" - self._task_state_changed_at[file_key] = monotonic() - if mediaid in self._season_episodes: - self._season_episodes[mediaid] = list( - set(self._season_episodes[mediaid]) - - set(task.meta.episode_list) - ) - return - - def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]: - """ - 根据文件项移除任务 - """ - task, _ = self.__remove_task_with_job_id(fileitem) - return task - - def __remove_task_with_job_id( - self, - fileitem: FileItem, - preserve_execution: bool = False, - ) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]: - """ - 根据文件项移除任务,并返回任务所在的作业ID - """ - file_key = self.__get_file_key(fileitem) - if not file_key: - return None, None - with job_lock: - for mediaid in list(self._job_view): - job = self._job_view[mediaid] - for task in job.tasks: - if self.__get_file_key(task.fileitem) == file_key: - job.tasks.remove(task) - self._task_state_changed_at.pop(file_key, None) - if not preserve_execution: - self._active_executions.discard(file_key) - # 如果没有作业了,则移除作业 - if not job.tasks: - self._job_view.pop(mediaid) - # 移除季集信息 - if mediaid in self._season_episodes: - episodes = getattr(task.meta, "episode_list", None) or [] - self._season_episodes[mediaid] = list( - set(self._season_episodes[mediaid]) - - set(episodes) - ) - return task, mediaid - return None, None - - def remove_job(self, task: TransferTask) -> Optional[TransferJob]: - """ - 移除任务对应的作业(强制,线程不安全) - """ - with job_lock: - __mediaid__ = self.__get_id(task) - if __mediaid__ in self._job_view: - job = self._job_view[__mediaid__] - self.__pop_job(__mediaid__) - return job - return None - - def try_remove_job(self, task: TransferTask): - """ - 尝试移除任务对应的作业(严格检查未完成作业,线程安全) - """ - with job_lock: - __metaid__ = self.__get_meta_id( - meta=task.meta, season=task.meta.begin_season - ) - __mediaid__ = self.__get_media_id( - media=task.mediainfo, season=task.meta.begin_season - ) - - related_media_ids = set(self._meta_to_media_ids.get(__metaid__, set())) - if task.mediainfo: - related_media_ids.add(__mediaid__) - - meta_done = self.__is_job_done(__metaid__) - media_done = all( - self.__is_job_done(mediaid) for mediaid in related_media_ids - ) - - if meta_done and media_done: - remove_ids = {__metaid__, self.__get_id(task), *related_media_ids} - for job_id in remove_ids: - self.__pop_job(job_id) - self._meta_to_media_ids.pop(__metaid__, None) - - def is_done(self, task: TransferTask) -> bool: - """ - 检查任务对应的作业是否整理完成(不管成功还是失败) - """ - with job_lock: - __metaid__ = self.__get_meta_id( - meta=task.meta, season=task.meta.begin_season - ) - __mediaid__ = self.__get_media_id( - media=task.mediainfo, season=task.meta.begin_season - ) - if __metaid__ in self._job_view: - meta_done = all( - task.state in ["completed", "failed"] - for task in self._job_view[__metaid__].tasks - ) - else: - meta_done = True - if __mediaid__ in self._job_view: - media_done = all( - task.state in ["completed", "failed"] - for task in self._job_view[__mediaid__].tasks - ) - else: - media_done = True - return meta_done and media_done - - def is_finished(self, task: TransferTask) -> bool: - """ - 检查任务对应的作业是否已完成且有成功的记录 - """ - with job_lock: - __metaid__ = self.__get_meta_id( - meta=task.meta, season=task.meta.begin_season - ) - __mediaid__ = self.__get_media_id( - media=task.mediainfo, season=task.meta.begin_season - ) - if __metaid__ in self._job_view: - meta_finished = all( - task.state in ["completed", "failed"] - for task in self._job_view[__metaid__].tasks - ) - else: - meta_finished = True - if __mediaid__ in self._job_view: - tasks = self._job_view[__mediaid__].tasks - media_finished = all( - task.state in ["completed", "failed"] for task in tasks - ) and any(task.state == "completed" for task in tasks) - else: - media_finished = True - return meta_finished and media_finished - - def is_success(self, task: TransferTask) -> bool: - """ - 检查任务对应的作业是否全部成功 - """ - with job_lock: - __metaid__ = self.__get_meta_id( - meta=task.meta, season=task.meta.begin_season - ) - __mediaid__ = self.__get_media_id( - media=task.mediainfo, season=task.meta.begin_season - ) - if __metaid__ in self._job_view: - meta_success = all( - task.state in ["completed"] - for task in self._job_view[__metaid__].tasks - ) - else: - meta_success = True - if __mediaid__ in self._job_view: - media_success = all( - task.state in ["completed"] - for task in self._job_view[__mediaid__].tasks - ) - else: - media_success = True - return meta_success and media_success - - def get_all_torrent_hashes(self) -> set[str]: - """ - 获取所有种子的哈希值集合 - """ - with job_lock: - return { - task.download_hash - for job in self._job_view.values() - for task in job.tasks - } - - def is_torrent_done(self, download_hash: str) -> bool: - """ - 检查指定种子的所有任务是否都已完成 - """ - with job_lock: - if any( - task.state not in {"completed", "failed"} - for job in self._job_view.values() - for task in job.tasks - if task.download_hash == download_hash - ): - return False - return True - - def is_torrent_success(self, download_hash: str) -> bool: - """ - 检查指定种子的所有任务是否都已成功 - """ - with job_lock: - if any( - task.state != "completed" - for job in self._job_view.values() - for task in job.tasks - if task.download_hash == download_hash - ): - return False - return True - - def has_tasks( - self, - meta: MetaBase, - mediainfo: Optional[MediaInfo] = None, - season: Optional[int] = None, - ) -> bool: - """ - 判断作业是否还有任务正在处理 - """ - with job_lock: - if mediainfo: - __mediaid__ = self.__get_media_id(media=mediainfo, season=season) - if __mediaid__ in self._job_view: - return True - - __metaid__ = self.__get_meta_id(meta=meta, season=season) - return ( - __metaid__ in self._job_view - and len(self._job_view[__metaid__].tasks) > 0 - ) - - def success_tasks( - self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None - ) -> List[TransferJobTask]: - """ - 获取作业中所有成功的任务 - """ - with job_lock: - __mediaid__ = self.__get_media_id(media=media, season=season) - if __mediaid__ not in self._job_view: - return [] - return [ - task - for task in self._job_view[__mediaid__].tasks - if task.state == "completed" - ] - - def all_tasks( - self, media: MediaInfo, season: Optional[int] = None - ) -> List[TransferJobTask]: - """ - 获取作业中全部任务 - """ - with job_lock: - __mediaid__ = self.__get_media_id(media=media, season=season) - if __mediaid__ not in self._job_view: - return [] - return self._job_view[__mediaid__].tasks - - def count(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int: - """ - 获取作业中成功总数 - """ - with job_lock: - __mediaid__ = self.__get_media_id(media=media, season=season) - if __mediaid__ not in self._job_view: - return 0 - return len( - [ - task - for task in self._job_view[__mediaid__].tasks - if task.state == "completed" - ] - ) - - def size(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int: - """ - 获取作业中所有成功文件总大小 - """ - with job_lock: - __mediaid__ = self.__get_media_id(media=media, season=season) - if __mediaid__ not in self._job_view: - return 0 - return sum( - [ - task.fileitem.size - if task.fileitem.size is not None - else ( - SystemUtils.get_directory_size(Path(task.fileitem.path)) - if task.fileitem.storage == "local" - else 0 - ) - for task in self._job_view[__mediaid__].tasks - if task.state == "completed" - ] - ) - - def total(self) -> int: - """ - 获取所有任务总数 - """ - with job_lock: - return sum([len(job.tasks) for job in self._job_view.values()]) - - def pending_total(self) -> int: - """ - 获取未到终态的任务总数。 - - 作业要等关联任务全部终态才整体移除,追更/分批场景下已完成任务会 - 跨批次残留在视图中;批次统计若用全量 total() 会把历史任务计入 - 「当前共 N 个文件」并压低进度百分比,因此只数未终态任务。 - """ - with job_lock: - return sum( - 1 - for job in self._job_view.values() - for task in job.tasks - if task.state not in ("completed", "failed") - ) - - def list_jobs(self) -> List[TransferJob]: - """ - 获取所有作业的任务列表 - """ - with job_lock: - return list(self._job_view.values()) - - def season_episodes( - self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None - ) -> List[int]: - """ - 获取作业的季集清单 - """ - with job_lock: - __mediaid__ = self.__get_media_id(media=media, season=season) - return self._season_episodes.get(__mediaid__) or [] - - -class FailedRetryScheduler: - """ - 负责失败整理记录的 debounce 聚合与 AI 重试调度。 - """ - - RETRY_TRANSFER_DEBOUNCE_SECONDS = 300 - - def __init__(self): - super().__init__() - self._retry_transfer_buffer: dict[str, list[int]] = {} - self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {} - self._retry_transfer_lock = asyncio.Lock() - - async def close(self): - async with self._retry_transfer_lock: - timers = list(self._retry_transfer_timers.values()) - self._retry_transfer_timers.clear() - self._retry_transfer_buffer.clear() - - for timer in timers: - timer.cancel() - - @staticmethod - def _build_retry_transfer_template_context( - history_ids: list[int], - ) -> tuple[str, dict[str, int | str]]: - """仅负责把失败重试任务的动态数据映射成模板变量。""" - is_batch = len(history_ids) > 1 - task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry" - template_context: dict[str, int | str] = { - "history_ids_csv": ", ".join(str(item) for item in history_ids), - "history_count": len(history_ids), - } - if not is_batch: - template_context["history_id"] = history_ids[0] - return task_type, template_context - - def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str: - """根据失败记录数量构建统一的重试整理后台任务提示词。""" - task_type, template_context = self._build_retry_transfer_template_context(history_ids) - return prompt_manager.render_system_task_message( - task_type, - template_context=template_context, - ) - - async def schedule_retry(self, history_id: int, group_key: str = ""): - """ - 同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。 - """ - if not group_key: - group_key = f"_default_{history_id}" - - async with self._retry_transfer_lock: - if group_key not in self._retry_transfer_buffer: - self._retry_transfer_buffer[group_key] = [] - if history_id not in self._retry_transfer_buffer[group_key]: - self._retry_transfer_buffer[group_key].append(history_id) - logger.info( - f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 " - f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)" - ) - - if group_key in self._retry_transfer_timers: - self._retry_transfer_timers[group_key].cancel() - - loop = asyncio.get_running_loop() - self._retry_transfer_timers[group_key] = loop.call_later( - self.RETRY_TRANSFER_DEBOUNCE_SECONDS, - lambda gk=group_key: asyncio.create_task(self._flush_retry_transfer(gk)), - ) - - async def _flush_retry_transfer(self, group_key: str): - """ - 延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。 - """ - async with self._retry_transfer_lock: - history_ids = self._retry_transfer_buffer.pop(group_key, []) - self._retry_transfer_timers.pop(group_key, None) - - if not history_ids: - return - - ids_str = ", ".join(str(item) for item in history_ids) - logger.info( - f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})" - ) - - try: - await agent_manager.run_background_prompt( - message=self._build_retry_transfer_prompt(history_ids), - session_prefix="__agent_retry_transfer_batch", - reply_mode=ReplyMode.DISPATCH, - ) - logger.info( - f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})" - ) - except Exception as err: - logger.error( - f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}" - ) - - -class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): +class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, HistoryMatchMixin, FileKeyMixin, + ManualHistoryMixin, FailedRetryMixin, ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 文件整理处理链 """ @@ -993,23 +81,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "TRANSFER_THREADS", } - @staticmethod - def _requires_automatic_category(task: TransferTask) -> bool: - """ - 判断当前整理任务是否需要根据媒体识别结果自动创建类别目录。 - - :param task: 整理任务 - :return: 是否必须具备自动分类结果 - """ - target_directory = task.target_directory - if target_directory and target_directory.media_category: - return False - if task.library_category_folder is not None: - return bool(task.library_category_folder) - return bool( - target_directory and target_directory.library_category_folder - ) - def __init__(self): """初始化文件整理处理链。""" super().__init__() @@ -1077,333 +148,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self.__stop() self.__init() - def __is_subtitle_file(self, fileitem: FileItem) -> bool: - """ - 判断是否为字幕文件 - """ - if not fileitem.extension: - return False - return ( - True if f".{fileitem.extension.lower()}" in self._subtitle_exts else False - ) - - def __is_audio_file(self, fileitem: FileItem) -> bool: - """ - 判断是否为音频文件 - """ - if not fileitem.extension: - return False - return True if f".{fileitem.extension.lower()}" in self._audio_exts else False - - def __is_media_file( - self, - fileitem: FileItem, - mtype: Optional[MediaType] = None, - ) -> bool: - """ - 判断是否为主要媒体文件 - """ - if mtype == MediaType.MUSIC: - if fileitem.type != "file" or not fileitem.extension: - return False - return f".{fileitem.extension.lower()}" in self._audio_exts - if fileitem.type == "dir": - # 蓝光原盘判断 - return StorageChain().is_bluray_folder(fileitem) - if not fileitem.extension: - return False - extension = f".{fileitem.extension.lower()}" - return extension in self._media_exts - - def _is_primary_media_file( - self, - fileitem: FileItem, - mediainfo: Optional[MediaInfo | MusicInfo], - ) -> bool: - """判断文件在当前媒体上下文中是否属于主要媒体文件。""" - return self.__is_media_file( - fileitem, - getattr(mediainfo, "type", None), - ) - - @staticmethod - def _music_info_from_meta(meta: MetaMusic) -> MusicInfo: - """将音频文件标签解析结果转换为可整理的最小音乐信息。""" - return MusicInfo.from_meta(meta) - - @classmethod - def _match_music_album_context( - cls, - file_item: FileItem, - file_path: Path, - file_meta: MetaMusic, - ) -> tuple[MetaMusic, Optional[MusicInfo]]: - """为缺少远端身份的本地音频尝试目录级专辑匹配,命中后回填文件元数据。 - - WAV 等无标签文件只能依靠目录结构和曲目特征识别;匹配结果由 MediaChain - 按目录缓存,同一专辑目录内的后续文件不会重复请求远端。 - """ - # 目录级匹配需要读取本地音频时长,远端存储文件无法参与 - if file_meta.media_id or getattr(file_item, "storage", "local") != "local": - return file_meta, None - try: - matched = MediaChain().recognize_music_album_directory(file_path.parent) - except Exception as err: - logger.debug(f"音乐专辑目录匹配失败:{file_path} - {err}") - return file_meta, None - info = matched.get(str(file_path.resolve())) - if not info or not info.media_id: - return file_meta, None - logger.info(f"{file_path.name} 通过专辑目录匹配识别为:{info.artist} - {info.title}") - merged_meta = deepcopy(file_meta) - # 保留本地音频的实际技术参数,仅回填身份和名称字段 - if info.title: - merged_meta.title = info.title - if info.artists: - merged_meta.artists = list(info.artists) - if info.album: - merged_meta.album = info.album - if info.album_artist: - merged_meta.album_artist = info.album_artist - if info.year: - merged_meta.year = info.year - if info.disc_number: - merged_meta.disc_number = info.disc_number - if info.track_number: - merged_meta.track_number = info.track_number - if info.total_tracks: - merged_meta.total_tracks = info.total_tracks - merged_meta.media_source = info.media_source - merged_meta.media_id = info.media_id - merged_info = cls._music_info_from_meta(merged_meta) - # 补齐曲目级远端信息,供后续刮削和展示使用 - merged_info.music_type = info.music_type - merged_info.artist_ids = list(info.artist_ids) - merged_info.album_id = info.album_id - merged_info.album_type = info.album_type - merged_info.release_date = info.release_date - merged_info.cover_url = info.cover_url - merged_info.category = info.category - merged_info.genres = list(info.genres) - merged_info.detail_link = info.detail_link - return merged_meta, merged_info - - @staticmethod - def _download_history_music_type( - download_history: Optional[DownloadHistory], - ) -> Optional[str]: - """从下载历史字段或旧版音乐备注中恢复音乐实体类型。""" - music_type = normalize_music_type( - getattr(download_history, "music_type", None), - allow_artist=False, - ) - if music_type: - return music_type - note = getattr(download_history, "note", None) - music_note = note.get("music") if isinstance(note, dict) else None - media_payload = music_note.get("media") if isinstance(music_note, dict) else None - if not isinstance(media_payload, dict): - return None - return normalize_music_type( - media_payload.get("music_type"), - allow_artist=False, - ) - - @classmethod - def _restore_music_download_context( - cls, - download_history: Optional[DownloadHistory], - file_path: Path, - ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]: - """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。""" - note = getattr(download_history, "note", None) - music_note = note.get("music") if isinstance(note, dict) else None - if not isinstance(music_note, dict) or music_note.get("version") != 1: - return None, None - try: - saved_meta = MetaMusic.from_dict(music_note.get("meta") or {}) - saved_info = MusicInfo.from_dict(music_note.get("media") or {}) - except (TypeError, ValueError): - return None, None - - file_tags = MediaChain.read_path_meta(file_path) - file_meta = deepcopy(saved_meta) - file_meta.org_string = file_path.name - # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), - # 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。 - if file_tags.title: - file_meta.title = file_tags.title - is_album_context = saved_info.music_type == MUSIC_ENTITY_ALBUM - for field_name in ( - "artists", - "disc_number", - "track_number", - "total_discs", - "version", - "isrc", - ): - if getattr(file_tags, field_name, None): - setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name))) - for field_name in ("album", "album_artist", "year", "total_tracks"): - file_value = getattr(file_tags, field_name, None) - # 整专下载以订阅选中的专辑字段为准,避免单个错误标签把曲目拆到其它专辑目录。 - if file_value and (not is_album_context or not getattr(file_meta, field_name, None)): - setattr(file_meta, field_name, deepcopy(file_value)) - for field_name in ( - "audio_format", - "bit_depth", - "sample_rate", - "bitrate", - "duration", - ): - if getattr(file_tags, field_name, None): - setattr(file_meta, field_name, getattr(file_tags, field_name)) - file_meta.media_source = saved_info.media_source or saved_meta.media_source - file_meta.media_id = saved_info.media_id or saved_meta.media_id - - file_info = cls._music_info_from_meta(file_meta) - file_info.media_source = saved_info.media_source - file_info.media_id = saved_info.media_id - file_info.music_type = saved_info.music_type - file_info.artist_ids = list(saved_info.artist_ids) - file_info.album_id = saved_info.album_id - file_info.album_type = saved_info.album_type - file_info.release_date = saved_info.release_date - file_info.cover_url = saved_info.cover_url - file_info.lyrics = saved_info.lyrics - file_info.category = saved_info.category - file_info.genres = list(saved_info.genres) - file_info.detail_link = saved_info.detail_link - file_info.listen_count = saved_info.listen_count - return file_meta, file_info - - @staticmethod - def _is_music_retry_source(history: TransferHistory, src_path: Path) -> bool: - """ - 判断重新整理来源是否应走音乐链路:历史类型为音乐,或源路径为音频文件。 - """ - if history.type == MediaType.MUSIC.value: - return True - return src_path.suffix.lower() in settings.RMT_AUDIOEXT - - def _recognize_music_retry_media( - self, - history: TransferHistory, - src_path: Path, - ) -> Optional[Union[MusicInfo, MediaInfo]]: - """ - 重新整理重试时恢复音乐信息。 - - 优先按历史记录中的 MusicBrainz 身份恢复;单音频文件回退按音频标签与文件名识别; - 音乐专辑目录返回 None,交由整理链按音频后缀逐文件解析识别。 - """ - if history.media_source and history.media_id: - retry_info = MediaChain().recognize_media( - mtype=MediaType.MUSIC, - media_source=history.media_source, - media_id=history.media_id, - music_type=getattr(history, "music_type", None), - ) - if retry_info: - return retry_info - if src_path.is_file(): - # 音频走统一路径识别入口,自动路由到音乐识别链 - recognize_context = MediaChain().recognize_by_path(str(src_path)) - return recognize_context.media_info if recognize_context else None - return None - - def __is_allowed_file(self, fileitem: FileItem) -> bool: - """ - 判断是否允许的扩展名 - """ - if not fileitem.extension: - return False - return True if f".{fileitem.extension.lower()}" in self._allowed_exts else False - - @staticmethod - def __is_allow_filesize(fileitem: FileItem, min_filesize: int) -> bool: - """ - 判断是否满足最小文件大小 - """ - return ( - True - if not min_filesize or (fileitem.size or 0) > min_filesize * 1024 * 1024 - else False - ) - - @staticmethod - def __is_hidden_or_recycle_path(file_path: Optional[str]) -> bool: - """ - 判断是否隐藏或回收站路径 - """ - if not file_path: - return False - normalized_path = file_path.replace("\\", "/") - return ( - "/@Recycle/" in normalized_path - or "/#recycle/" in normalized_path - or "/." in normalized_path - or "/@eaDir" in normalized_path - ) - - @staticmethod - def __should_delete_empty_source_directories( - task: TransferTask, - delete_mounted_local_disk_empty_dirs: bool, - mounted_filesystem_cache: Dict[Path, bool], - ) -> bool: - """ - 判断移动整理后是否应删除源空目录。 - - 仅在关闭挂载盘空目录清理且源存储为本地时检测文件系统, - 避免默认流程产生额外系统调用。 - """ - if delete_mounted_local_disk_empty_dirs: - return True - if task.fileitem.storage != "local": - return True - - source_directory = ( - Path(task.target_directory.download_path) - if task.target_directory and task.target_directory.download_path - else Path(task.fileitem.path).parent - ) - if source_directory not in mounted_filesystem_cache: - mounted_filesystem_cache[source_directory] = ( - SystemUtils.is_network_filesystem( - source_directory, include_local_fuse=True - ) - ) - return not mounted_filesystem_cache[source_directory] - - @staticmethod - def __is_overwrite_declined(task: TransferTask, transferinfo: TransferInfo, - transferhis: TransferHistoryOper) -> bool: - """ - 判断本次未入库是否为「同路径已有成功记录 + 覆盖模式裁定不覆盖」。 - - 只有同路径此前已成功整理过才需要保护:这类文件是查重闸放行的同路径新版本, - 媒体库中的原有版本仍然在位,不应因一次不覆盖裁决把成功记录改写成失败记录。 - 没有成功记录时(如目标同名文件来自其他源路径)保持原有失败语义, - 用户仍能在历史与通知中看到裁决结果。 - :param task: 整理任务 - :param transferinfo: 整理结果 - :param transferhis: 历史操作对象 - :return: True 表示应保留原成功记录 - """ - if not transferinfo.overwrite_skipped or not task.fileitem: - return False - try: - history = resolve_history( - task.fileitem.path, - storage=task.fileitem.storage, - transfer_history_oper=transferhis, - ) - except Exception as err: - logger.error(f"查询整理历史失败: {task.fileitem.path} - {err}") - return False - return bool(history and history.status) - def __default_callback( self, task: TransferTask, transferinfo: TransferInfo, / ) -> Tuple[bool, str]: @@ -1459,7 +203,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 媒体库里原有版本仍然在位,写失败记录会用 add_force 顶掉原成功记录,此后该路径 # 永远处于失败态,每个新事件都会重试并重推失败通知。此时保留原记录、不写历史、 # 不发事件与通知、不触发重试,仅把任务置为未入库 - overwrite_declined = self.__is_overwrite_declined( + overwrite_declined = self._is_overwrite_declined( task, transferinfo, transferhis ) history = None @@ -1492,7 +236,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) # 整理失败事件 - if self.__is_media_file(task.fileitem): + if self._is_media_file(task.fileitem): # 主要媒体文件整理失败事件 self.eventmanager.send_event( EventType.TransferFailed, @@ -1506,7 +250,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "transfer_history_id": history.id if history else None, }, ) - elif self.__is_subtitle_file(task.fileitem): + elif self._is_subtitle_file(task.fileitem): # 字幕整理失败事件 self.eventmanager.send_event( EventType.SubtitleTransferFailed, @@ -1520,7 +264,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "transfer_history_id": history.id if history else None, }, ) - elif self.__is_audio_file(task.fileitem): + elif self._is_audio_file(task.fileitem): # 音频文件整理失败事件 self.eventmanager.send_event( EventType.AudioTransferFailed, @@ -1627,7 +371,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "transfer_history_id": history.id if history else None, }, ) - elif self.__is_subtitle_file(task.fileitem): + elif self._is_subtitle_file(task.fileitem): # 字幕整理完成事件 self.eventmanager.send_event( EventType.SubtitleTransferComplete, @@ -1641,7 +385,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): "transfer_history_id": history.id if history else None, }, ) - elif self.__is_audio_file(task.fileitem): + elif self._is_audio_file(task.fileitem): # 音频文件整理完成事件 self.eventmanager.send_event( EventType.AudioTransferComplete, @@ -1669,7 +413,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self.jobview.finish_task(task) # 登记批次级刮削目标 - self.__record_scrape_target(task, transferinfo) + self._record_scrape_target(task, transferinfo) # 全部整理完成且有成功的任务时,发送消息和事件 if self.jobview.is_finished(task): @@ -1682,7 +426,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): )) __notify() if not task.transfer_batch_id: - self.__send_metadata_scrape_event(task, transferinfo) + self._send_metadata_scrape_event(task, transferinfo) # 只要该种子的所有任务都已整理完成,则设置种子状态为已整理 self.__mark_torrent_completed_if_done(task.download_hash, task.downloader) @@ -1724,11 +468,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if ( not t.download_hash and t.fileitem - and self.__should_delete_empty_source_directories( - t, - delete_mounted_local_disk_empty_dirs, - mounted_filesystem_cache, - ) + and self._should_delete_empty_source_directories( + t, + delete_mounted_local_disk_empty_dirs, + mounted_filesystem_cache, + ) ): # 删除剩余空目录 StorageChain().delete_media_file(t.fileitem, delete_self=False) @@ -1762,7 +506,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 维护整理任务视图,如果任务已存在则不添加到队列 if not self.__put_to_jobview(task): return False - self.__register_scrape_batch_task(task) + self._register_scrape_batch_task(task) # 添加到队列 self._queue.put(TransferQueue(task=task, callback=self.__default_callback)) # 落盘登记:队列是纯内存的,进程重启(挂载挂死后的人工重启、升级、OOM) @@ -1938,204 +682,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): logger.error(f"检查种子 {download_hash} 下载进度失败:{e}") return False - def __send_metadata_scrape_event( - self, task: TransferTask, transferinfo: TransferInfo - ): - """ - 发送元数据刮削事件,保持对外事件载荷兼容。 - """ - if ( - not task - or not transferinfo - or not transferinfo.need_scrape - or not self._is_primary_media_file(task.fileitem, task.mediainfo) - ): - return - - target_diritem = transferinfo.target_diritem - if not target_diritem: - return - - self.eventmanager.send_event( - EventType.MetadataScrape, - self.__build_metadata_scrape_payload( - task=task, - fileitem=target_diritem, - file_list=transferinfo.file_list_new, - overwrite=False, - ), - ) - - @staticmethod - def __build_metadata_scrape_payload( - task: TransferTask, - fileitem: FileItem, - file_list: Optional[list[str]], - overwrite: bool, - ) -> dict[str, Any]: - """构造刮削事件载荷,并为音乐批次保留逐文件身份上下文。""" - paths = list(dict.fromkeys(file_list or [])) - payload: dict[str, Any] = { - "meta": task.meta, - "mediainfo": task.mediainfo, - "fileitem": fileitem, - "file_list": paths, - "overwrite": overwrite, - } - if isinstance(task.mediainfo, MusicInfo): - payload["file_contexts"] = [ - { - "path": path, - "meta": task.meta, - "mediainfo": task.mediainfo, - } - for path in paths - ] - return payload - - def __register_scrape_batch_task(self, task: TransferTask): - """ - 登记批次任务。刮削事件只在批次关闭且任务全部完成后统一发送。 - """ - if not task or not task.transfer_batch_id: - return - with job_lock: - batch = self._scrape_batches.setdefault( - task.transfer_batch_id, - { - "pending": set(), - "targets": {}, - "closed": False, - }, - ) - batch["pending"].add(task.fileitem.path) - - def __close_scrape_batch(self, batch_id: Optional[str]): - """ - 标记批次不再接收新任务,并尝试发送已聚合的刮削事件。 - """ - if not batch_id: - return - with job_lock: - batch = self._scrape_batches.setdefault( - batch_id, - { - "pending": set(), - "targets": {}, - "closed": False, - }, - ) - batch["closed"] = True - self.__flush_scrape_batch_if_ready(batch_id) - - def __record_scrape_target(self, task: TransferTask, transferinfo: TransferInfo): - """ - 记录批次内需要刮削的目标文件,按目标媒体根目录聚合。 - """ - if ( - not task - or not task.transfer_batch_id - or not transferinfo - or not transferinfo.need_scrape - or not self._is_primary_media_file(task.fileitem, task.mediainfo) - ): - return - - target_diritem = transferinfo.target_diritem - if not target_diritem: - return - - target_files = transferinfo.file_list_new or [] - target_key = (target_diritem.storage, target_diritem.path) - with job_lock: - batch = self._scrape_batches.setdefault( - task.transfer_batch_id, - { - "pending": set(), - "targets": {}, - "closed": False, - }, - ) - target = batch["targets"].setdefault( - target_key, - { - "fileitem": target_diritem, - "meta": task.meta, - "mediainfo": task.mediainfo, - "files": [], - "file_contexts": {}, - "overwrite": False, - }, - ) - if not target.get("meta"): - target["meta"] = task.meta - if not target.get("mediainfo"): - target["mediainfo"] = task.mediainfo - for target_file in target_files: - if target_file and target_file not in target["files"]: - target["files"].append(target_file) - if target_file and isinstance(task.mediainfo, MusicInfo): - target["file_contexts"][target_file] = { - "path": target_file, - "meta": task.meta, - "mediainfo": task.mediainfo, - } - - def __finish_scrape_batch_task(self, task: TransferTask): - """ - 标记批次内单个任务已结束。 - """ - if not task or not task.transfer_batch_id: - return - with job_lock: - batch = self._scrape_batches.get(task.transfer_batch_id) - if not batch: - return - batch["pending"].discard(task.fileitem.path) - self.__flush_scrape_batch_if_ready(task.transfer_batch_id) - - def __flush_scrape_batch_if_ready(self, batch_id: Optional[str]): - """ - 批次任务全部结束后发送聚合后的刮削事件。 - """ - if not batch_id: - return - - with job_lock: - batch = self._scrape_batches.get(batch_id) - if ( - not batch - or not batch.get("closed") - or batch.get("pending") - ): - return - targets = list(batch.get("targets", {}).values()) - self._scrape_batches.pop(batch_id, None) - - for target in targets: - fileitem = target.get("fileitem") - if not fileitem: - continue - file_list = list(dict.fromkeys(target.get("files") or [])) - file_contexts = target.get("file_contexts") or {} - payload = { - "meta": target.get("meta"), - "mediainfo": target.get("mediainfo"), - "fileitem": fileitem, - "file_list": file_list, - "overwrite": target.get("overwrite", False), - } - if file_contexts: - payload["file_contexts"] = [ - file_contexts[path] - for path in file_list - if path in file_contexts - ] - self.eventmanager.send_event( - EventType.MetadataScrape, - payload, - ) - def remove_from_queue(self, fileitem: FileItem): """ 从待整理队列移除 @@ -2179,7 +725,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ self.jobview.fail_unfinished_task(task) self.jobview.try_remove_job(task) - self.__finish_scrape_batch_task(task) + self._finish_scrape_batch_task(task) def __start_transfer(self): """ @@ -2607,7 +1153,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): finally: # 移除已完成的任务 self.jobview.try_remove_job(task) - self.__finish_scrape_batch_task(task) + self._finish_scrape_batch_task(task) def get_queue_tasks(self) -> List[TransferJob]: """ @@ -2616,193 +1162,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): self.__expire_stale_transfer_tasks() return self.jobview.list_jobs() - def recommend_name(self, meta: MetaBase, mediainfo: MediaInfo) -> Optional[str]: - """ - 获取重命名后的名称 - :param meta: 元数据 - :param mediainfo: 媒体信息 - :return: 重命名后的名称(含目录) - """ - # 获取集信息,供重命名模块使用 - episodes_info: Optional[List[TmdbEpisode]] = None - if mediainfo.type == MediaType.TV: - # 判断注意season为0的情况 - season_num = mediainfo.season - if season_num is None and meta.season_seq: - if meta.season_seq.isdigit(): - season_num = int(meta.season_seq) - # 默认值1 - if season_num is None: - season_num = 1 - episodes_info = self.run_module( - "tmdb_episodes", - tmdbid=mediainfo.tmdb_id, - season=season_num, - episode_group=mediainfo.episode_group, - ) - if episodes_info: - return self.run_module( - "recommend_name", - meta=meta, - mediainfo=mediainfo, - episodes_info=episodes_info, - ) - # 电影或无集信息时保持原有参数集,避免影响旧签名的模块实现 - return self.run_module("recommend_name", meta=meta, mediainfo=mediainfo) - - def recommend_episode_format( - self, - fileitem: FileItem, - fileitems: Optional[List[FileItem]] = None, - ) -> Tuple[bool, str, Optional[dict]]: - """ - 根据目录样本推荐集数定位模板 - """ - if not fileitem and not fileitems: - logger.warn("推荐集数定位模板失败:缺少目录参数") - return False, "缺少目录参数", None - - rules = self.__get_episode_format_rules() - if fileitems: - state, errmsg, sample_files = self.__get_selected_episode_format_sample_files( - fileitems - ) - if not state: - logger.warn(f"推荐集数定位模板失败:{errmsg}") - return False, errmsg, None - target_path = sample_files[0].path if sample_files else None - else: - if not fileitem or not fileitem.path: - logger.warn("推荐集数定位模板失败:缺少目录参数") - return False, "缺少目录参数", None - directory = self.__resolve_episode_format_directory(fileitem) - if not directory or directory.type != "dir": - logger.warn(f"推荐集数定位模板失败:目录不存在 - {fileitem.path}") - return False, "目录不存在", None - sample_files = self.__get_episode_format_sample_files(directory) - target_path = directory.path - logger.info( - f"开始匹配集数定位规则:{target_path},规则数 {len(rules)},样本数 {len(sample_files)}" - ) - state, errmsg, data = EpisodeFormatRuleHelper().recommend( - rules=rules, - sample_files=sample_files, - ) - if not state: - logger.warn(f"集数定位模板推荐失败:{target_path} - {errmsg}") - return state, errmsg, data - logger.info( - f"集数定位模板推荐成功:{target_path} - 规则 {data.get('rule_name') if data else None}" - ) - return state, errmsg, data - - @staticmethod - def __get_episode_format_rules() -> List[schemas.EpisodeFormatRule]: - """ - 获取启用的集数定位规则 - """ - rule_items = SystemConfigOper().get(SystemConfigKey.EpisodeFormatRuleTable) or [] - rules: List[schemas.EpisodeFormatRule] = [] - for item in rule_items: - if not isinstance(item, dict): - continue - try: - rule = schemas.EpisodeFormatRule(**item) - except Exception as err: - logger.warn(f"忽略无效的集数定位规则:{err}") - continue - if rule.enabled: - rules.append(rule) - return sorted(rules, key=lambda item: item.order) - - def __resolve_episode_format_directory( - self, fileitem: FileItem - ) -> Optional[FileItem]: - """ - 将文件或目录入参归一化为目录对象 - """ - storage_chain = StorageChain() - if fileitem.type == "dir": - return storage_chain.get_item(fileitem) - source_path = Path(fileitem.path) - parent_item = FileItem( - storage=fileitem.storage, - path=source_path.parent.as_posix(), - type="dir", - name=source_path.parent.name, - ) - return storage_chain.get_item(parent_item) - - def __get_selected_episode_format_sample_files( - self, fileitems: List[FileItem] - ) -> Tuple[bool, str, List[FileItem]]: - """ - 获取当前选择文件中可参与模板推荐的样本文件。 - """ - if not fileitems: - return False, "没有可用于识别的样本文件", [] - - expected_dir_key: Optional[Tuple[str, str]] = None - selected_files: List[FileItem] = [] - seen_files = set() - for item in fileitems: - if not item or not item.path or item.type != "file": - return False, "当前选择不满足智能识别条件", [] - - dir_key = ( - item.storage or "local", - Path(item.path).parent.as_posix(), - ) - if expected_dir_key is None: - expected_dir_key = dir_key - elif dir_key != expected_dir_key: - return False, "当前选择不满足智能识别条件", [] - - file_key = (item.storage or "local", item.path) - if file_key in seen_files: - continue - seen_files.add(file_key) - - if not ( - self.__is_media_file(item) - or self.__is_subtitle_file(item) - or self.__is_audio_file(item) - ): - continue - if self.__is_hidden_or_recycle_path(item.path): - continue - selected_files.append(item) - - if not selected_files: - return False, "没有可用于识别的样本文件", [] - return True, "", selected_files - - def __get_episode_format_sample_files( - self, directory: FileItem - ) -> List[FileItem]: - """ - 获取目录下可参与模板推荐的样本文件。 - - 推荐结果最终会在手动整理链路中作为 `episode_format` - 交由 `FormatParser` 过滤主视频、字幕和外挂音频,因此这里需要把 - 同目录下的主视频、字幕和外挂音频一起纳入推荐流程。 - """ - file_items = StorageChain().list_files(directory, recursion=False) or [] - sample_files: List[FileItem] = [] - for item in file_items: - if not item or item.type != "file": - continue - if not ( - self.__is_media_file(item) - or self.__is_subtitle_file(item) - or self.__is_audio_file(item) - ): - continue - if self.__is_hidden_or_recycle_path(item.path): - continue - sample_files.append(item) - return sample_files - def process(self, progress_callback: Optional[Callable[..., None]] = None) -> bool: """ 获取下载器中的种子列表,并执行整理 @@ -2862,9 +1221,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): break if progress_callback: torrent_name = ( - getattr(torrent, "title", None) - or getattr(torrent, "name", None) - or torrent.hash + getattr(torrent, "title", None) + or getattr(torrent, "name", None) + or torrent.hash ) progress_callback( value=(index - 1) / total_num * 100, @@ -3116,397 +1475,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return shared_roots - @staticmethod - def _match_download_file( - download_file: DownloadFiles, - file_path: Path, - save_path: Path, - ) -> bool: - """ - 判断下载文件记录是否明确对应当前文件。 - """ - if download_file.fullpath == file_path.as_posix(): - return True - - filepath = download_file.filepath - if not filepath: - return False - - try: - return (save_path / Path(filepath)).as_posix() == file_path.as_posix() - except (TypeError, ValueError): - return False - - def _resolve_history_from_download_files( - self, - downloadhis: DownloadHistoryOper, - download_files: List[DownloadFiles], - file_path: Optional[Path] = None, - save_path: Optional[Path] = None, - ) -> Optional[DownloadHistory]: - """ - 从下载文件记录中解析唯一的下载历史。 - """ - if file_path and save_path: - download_files = [ - download_file - for download_file in download_files - if self._match_download_file( - download_file=download_file, - file_path=file_path, - save_path=save_path, - ) - ] - - download_hashes = { - download_file.download_hash - for download_file in download_files - if download_file.download_hash - } - if len(download_hashes) == 1: - return downloadhis.get_by_hash(next(iter(download_hashes))) - return None - - def _resolve_download_history( - self, - downloadhis: DownloadHistoryOper, - file_path: Path, - bluray_dir: bool = False, - download_hash: Optional[str] = None, - ) -> Optional[DownloadHistory]: - """ - 根据显式 hash、文件路径或种子根目录回查下载历史。 - """ - if download_hash: - return downloadhis.get_by_hash(download_hash) - - if bluray_dir: - return downloadhis.get_by_path(file_path.as_posix()) - - download_file = downloadhis.get_file_by_fullpath(file_path.as_posix()) - if download_file: - return downloadhis.get_by_hash(download_file.download_hash) - - # 多文件种子里的字幕/附加文件可能没有稳定的 fullpath 记录, - # 退回到父目录和 savepath 继续查找,尽量补齐同一种子的关联信息。 - shared_download_roots = self._get_shared_download_roots(file_path) - - for parent_path in file_path.parents: - parent_posix = parent_path.as_posix() - download_files = downloadhis.get_files_by_savepath(parent_posix) or [] - - if parent_posix in shared_download_roots: - # 共享下载根目录只能接受有明确文件记录的匹配, - # 避免单文件/磁力任务把整个根目录污染成同一媒体。 - history = self._resolve_history_from_download_files( - downloadhis=downloadhis, - download_files=download_files, - file_path=file_path, - save_path=parent_path, - ) - if history: - return history - break - - download_history = downloadhis.get_by_path(parent_posix) - if download_history: - return download_history - - history = self._resolve_history_from_download_files( - downloadhis=downloadhis, - download_files=download_files, - ) - if history: - return history - - return None - - @staticmethod - def _is_movie_year_conflict( - file_meta: MetaBase, - # 两种 DownloadHistory 都会进来:库模型(本文件按 ORM 行查历史)与 - # schemas DTO(TransferTask.download_history)。本函数只按 getattr 取 - # year 与 type,对两者一视同仁 - media: Union[DownloadHistory, schemas.DownloadHistory, MediaInfo, MusicInfo] - ) -> bool: - """ - 判断文件名年份是否与已识别电影年份冲突。 - - 多电影合集只保存一条下载历史,不能把合集首部电影的媒体 ID 套用到其它年份的文件; - 电视剧季包仍应继续复用同一条下载历史。 - """ - file_year = getattr(file_meta, "year", None) - media_year = getattr(media, "year", None) - if not file_meta or not media or not file_year or not media_year: - return False - media_type = getattr(media, "type", None) - if not isinstance(media_type, MediaType): - try: - media_type = MediaType(media_type) - except (TypeError, ValueError): - return False - return ( - media_type == MediaType.MOVIE - and str(file_year) != str(media_year) - ) - - @staticmethod - def __optional_attr_equal( - source: MetaBase, - target: MetaBase, - attr: str, - normalizer: Callable = None, - ) -> bool: - """ - 比较可选识别字段。 - - 字段两边都没有识别到时不参与判断;只要任意一边识别到了,就要求两边值一致, - 避免把同名不同年份或不同季集的附加文件误归到当前主视频。 - """ - source_value = getattr(source, attr, None) - target_value = getattr(target, attr, None) - if source_value is None and target_value is None: - return True - if source_value is None or target_value is None: - return False - if normalizer: - source_value = normalizer(source_value) - target_value = normalizer(target_value) - return source_value == target_value - - def __is_same_media_meta( - self, source_meta: MetaBase, target_meta: MetaBase - ) -> bool: - """ - 判断两个文件识别出的媒体身份是否一致。 - """ - if not source_meta or not target_meta: - return False - if source_meta.type != target_meta.type: - return False - if text_tools.normalize_upper(source_meta.name) != text_tools.normalize_upper( - target_meta.name - ): - return False - if not self.__optional_attr_equal(source_meta, target_meta, "year", str): - return False - for attr in ( - "begin_season", - "end_season", - "begin_episode", - "end_episode", - ): - if not self.__optional_attr_equal(source_meta, target_meta, attr, int): - return False - return True - - @staticmethod - def __get_file_key(fileitem: FileItem) -> Tuple[str, str]: - """ - 获取文件缓存键。 - """ - normalized_path = Path(str(fileitem.path).replace("\\", "/")).as_posix() - return fileitem.storage or "local", normalized_path - - @staticmethod - def __get_file_stem(fileitem: FileItem) -> str: - """ - 获取文件主干名,用于判断同名附加文件。 - """ - file_name = fileitem.name or Path(fileitem.path).name - return Path(file_name).stem.lower() - - @classmethod - def __get_subtitle_media_stem(cls, subtitle_fileitem: FileItem) -> str: - """ - 获取字幕对应主视频的候选主干名。 - """ - current_stem = cls.__get_file_stem(subtitle_fileitem) - while current_stem: - media_stem, separator, suffix = current_stem.rpartition(".") - if not separator or suffix not in SUBTITLE_STEM_TAGS: - return current_stem - current_stem = media_stem - return current_stem - - def __get_extra_media_stem(self, extra_fileitem: FileItem) -> str: - """ - 获取附加文件对应主视频的候选主干名。 - """ - if self.__is_subtitle_file(extra_fileitem): - return self.__get_subtitle_media_stem(extra_fileitem) - return self.__get_file_stem(extra_fileitem) - - def __get_related_main_file_key( - self, - extra_fileitem: FileItem, - main_fileitems: List[FileItem], - ) -> Optional[Tuple[str, str]]: - """ - 获取与附加文件名完全匹配的主视频键。 - """ - if not ( - self.__is_subtitle_file(extra_fileitem) - or self.__is_audio_file(extra_fileitem) - ): - return None - - extra_media_stem = self.__get_extra_media_stem(extra_fileitem) - matched_items: List[FileItem] = [] - for main_fileitem in main_fileitems: - main_stem = self.__get_file_stem(main_fileitem) - if main_stem and main_stem == extra_media_stem: - matched_items.append(main_fileitem) - - if len(matched_items) != 1: - return None - return self.__get_file_key(matched_items[0]) - - @staticmethod - def __normalize_dir_path(dir_path: Union[str, Path]) -> str: - """ - 归一化目录路径,用于同一父目录候选缓存。 - """ - normalized = Path(dir_path).as_posix().rstrip("/") - return normalized or "/" - - def __get_dir_key(self, dir_item: FileItem) -> Tuple[str, str]: - """ - 获取目录缓存键。 - """ - return dir_item.storage, self.__normalize_dir_path(dir_item.path) - - def __get_file_parent_key(self, current_item: FileItem) -> Tuple[str, str]: - """ - 获取文件父目录缓存键。 - """ - return ( - current_item.storage, - self.__normalize_dir_path(Path(current_item.path).parent), - ) - - @staticmethod - def _get_subscribe_custom_words( - history_record: Optional[DownloadHistory], - ) -> Optional[List[str]]: - """ - 获取整理用自定义识别词:优先使用下载时保存的快照,无快照(历史旧记录)时再按来源实时反查订阅。 - - 快照优先可避免整理阶段因订阅季号漂移、来源解析失败或订阅完成被删导致识别词丢失,从而原样入库到偏移前的季集。 - """ - if not history_record: - return None - # 下载时保存的完整订阅识别词快照优先 - if history_record.custom_words: - return history_record.custom_words.split("\n") - # 兜底:历史旧记录无快照时,按下载来源实时反查订阅 - if not isinstance(history_record.note, dict): - return None - subscribe = SubscribeChain().get_subscribe_by_source( - history_record.note.get("source") - ) - return ( - subscribe.custom_words.split("\n") - if subscribe and subscribe.custom_words - else None - ) - - @staticmethod - def _is_successful_move_history(history: Optional[TransferHistory]) -> bool: - """判断历史记录是否为已成功完成的移动类整理。""" - return bool( - history - and history.status - and history.mode - and "move" in history.mode - ) - - def _get_manual_transfer_history( - self, - fileitem: FileItem, - transfer_history_oper: TransferHistoryOper, - include_move_dest: bool = False, - ) -> Optional[TransferHistory]: - """查询文件源路径历史,并兼容从成功移动后的目标现址重新整理。""" - # resolve_history 在命中失败记录时会再确认一次有无成功记录, - # 避免 get_by_src 无排序导致同源多行时返回哪条不确定 - history = resolve_history( - fileitem.path, - storage=fileitem.storage, - transfer_history_oper=transfer_history_oper, - ) - if history or not include_move_dest: - return history - - history = transfer_history_oper.get_by_dest( - fileitem.path, - storage=fileitem.storage, - ) - return history if self._is_successful_move_history(history) else None - - def get_manual_transfer_histories( - self, - fileitems: List[FileItem], - ) -> List[TransferHistory]: - """ - 查询文件或目录命中的成功整理记录,供手动整理界面显示重整状态。 - - :param fileitems: 待查询的文件或目录项 - :return: 去重后的成功整理记录 - """ - transfer_history_oper = TransferHistoryOper() - histories: Dict[int, TransferHistory] = {} - for fileitem in fileitems or []: - if not fileitem or not fileitem.path: - continue - storage = fileitem.storage or "local" - if fileitem.type == "dir": - matched_histories = transfer_history_oper.list_success_by_src( - fileitem.path, - storage=storage, - recursive=True, - ) - matched_histories.extend( - transfer_history_oper.list_success_move_by_dest( - fileitem.path, - storage=storage, - recursive=True, - ) - ) - else: - history = self._get_manual_transfer_history( - fileitem=fileitem, - transfer_history_oper=transfer_history_oper, - include_move_dest=True, - ) - matched_histories = [history] if history and history.status else [] - - for history in matched_histories: - histories[history.id] = history - return list(histories.values()) - - @staticmethod - def _delete_manual_transfer_history( - history: TransferHistory, - transfer_history_oper: TransferHistoryOper, - ) -> Tuple[bool, str]: - """删除手动重整历史;非成功移动记录同时清理可能存在的旧目标。""" - if ( - history.dest_fileitem - and not TransferChain._is_successful_move_history(history) - ): - dest_fileitem = FileItem(**history.dest_fileitem) - storage_chain = StorageChain() - if ( - storage_chain.exists(dest_fileitem) - and not storage_chain.delete_media_file(dest_fileitem) - ): - return False, f"{dest_fileitem.path} 删除失败" - transfer_history_oper.delete(history.id) - # 删除记录是用户显式要求重来,失败计数一并清零,否则重整仍会受上一轮次数限制 - clear_transfer_failures(history.src, history.src_storage) - return True, "" - def do_transfer( self, fileitem: FileItem, @@ -3719,19 +1687,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if batch_mtype == MediaType.MUSIC: # 明确的音乐批次只接收音频主文件,避免混合下载目录中的视频或字幕 # 被音乐身份和命名模板整理进音乐库。 - if not self.__is_media_file(item, batch_mtype): + if not self._is_media_file(item, batch_mtype): return False - if not self.__is_allow_filesize(item, min_filesize): + if not self._is_allow_filesize(item, min_filesize): return False # 过滤后缀和大小(蓝光目录、附加文件不过滤) elif ( not is_bluray_dir - and not self.__is_subtitle_file(item) - and not self.__is_audio_file(item) + and not self._is_subtitle_file(item) + and not self._is_audio_file(item) ): - if not self.__is_media_file(item, batch_mtype): + if not self._is_media_file(item, batch_mtype): return False - if not self.__is_allow_filesize(item, min_filesize): + if not self._is_allow_filesize(item, min_filesize): return False # 回收站及隐藏的文件不处理 if ( @@ -3804,7 +1772,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 添加待整理文件项并去重。 """ - file_key = self.__get_file_key(item) + file_key = self._get_file_key(item) if file_key in seen_file_keys: return False planned_items.append((item, is_bluray_dir)) @@ -3825,10 +1793,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): for item, is_bluray_dir in items: if not item or item.type != "file": continue - dir_key = self.__get_file_parent_key(item) - if not is_bluray_dir and self.__is_media_file(item, batch_mtype): + dir_key = self._get_file_parent_key(item) + if not is_bluray_dir and self._is_media_file(item, batch_mtype): main_items_by_dir.setdefault(dir_key, []).append(item) - elif self.__is_subtitle_file(item) or self.__is_audio_file(item): + elif self._is_subtitle_file(item) or self._is_audio_file(item): extra_items_by_dir.setdefault(dir_key, []).append((item, is_bluray_dir)) return main_items_by_dir, extra_items_by_dir @@ -3851,10 +1819,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): for item in storagechain.list_files(parent_item, recursion=False) or []: if not item or item.type != "file": continue - if self.__is_media_file(item, batch_mtype): + if self._is_media_file(item, batch_mtype): main_fileitems.append(item) continue - if not (self.__is_subtitle_file(item) or self.__is_audio_file(item)): + if not (self._is_subtitle_file(item) or self._is_audio_file(item)): continue if not _is_allowed_transfer_item(item, False): continue @@ -3877,13 +1845,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): (item, is_bluray_dir) for item, is_bluray_dir in items if item - and ( - is_bluray_dir - or ( - item.type == "file" - and self.__is_media_file(item, batch_mtype) - ) - ) + and ( + is_bluray_dir + or ( + item.type == "file" + and self._is_media_file(item, batch_mtype) + ) + ) ] single_file_mode = len(items) == 1 and fileitem.type == "file" @@ -3893,15 +1861,15 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): sibling_main_items, sibling_extra_items = _get_single_file_sibling_items( current_item ) - current_dir_key = self.__get_file_parent_key(current_item) - if not current_bluray_dir and self.__is_media_file( + current_dir_key = self._get_file_parent_key(current_item) + if not current_bluray_dir and self._is_media_file( current_item, batch_mtype ): main_items = [(current_item, current_bluray_dir)] main_items_by_dir[current_dir_key] = [current_item] extra_items_by_dir[current_dir_key] = sibling_extra_items - elif self.__is_subtitle_file(current_item) or self.__is_audio_file(current_item): - related_main_file_key = self.__get_related_main_file_key( + elif self._is_subtitle_file(current_item) or self._is_audio_file(current_item): + related_main_file_key = self._get_related_main_file_key( extra_fileitem=current_item, main_fileitems=sibling_main_items, ) @@ -3909,7 +1877,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ( main_item for main_item in sibling_main_items - if self.__get_file_key(main_item) == related_main_file_key + if self._get_file_key(main_item) == related_main_file_key ), None, ) @@ -3920,7 +1888,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_history_oper, ) if main_meta: - inherited_map[self.__get_file_key(current_item)] = deepcopy(main_meta) + inherited_map[self._get_file_key(current_item)] = deepcopy(main_meta) return list(items), inherited_map if not main_items: @@ -3951,7 +1919,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): for main_item, main_bluray_dir in main_items: _append_item(planned_items, seen_file_keys, main_item, main_bluray_dir) - if main_bluray_dir or not self.__is_media_file( + if main_bluray_dir or not self._is_media_file( main_item, batch_mtype ): continue @@ -3973,13 +1941,13 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if not main_meta: continue - dir_key = self.__get_file_parent_key(main_item) + dir_key = self._get_file_parent_key(main_item) main_fileitems = main_items_by_dir.get(dir_key) or [main_item] - main_file_key = self.__get_file_key(main_item) + main_file_key = self._get_file_key(main_item) for extra_item, extra_bluray_dir in extra_items_by_dir.get(dir_key, []): - if self.__get_file_key(extra_item) in seen_file_keys: + if self._get_file_key(extra_item) in seen_file_keys: continue - related_main_file_key = self.__get_related_main_file_key( + related_main_file_key = self._get_related_main_file_key( extra_fileitem=extra_item, main_fileitems=main_fileitems, ) @@ -3991,7 +1959,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): extra_item, extra_bluray_dir, ): - inherited_map[self.__get_file_key(extra_item)] = deepcopy(main_meta) + inherited_map[self._get_file_key(extra_item)] = deepcopy(main_meta) continue if single_file_mode or not sync_extra_files: @@ -4001,7 +1969,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): Path(extra_item.path), subscribe_custom_words, ) - if not self.__is_same_media_meta(main_meta, extra_meta): + if not self._is_same_media_meta(main_meta, extra_meta): continue if _append_item( planned_items, @@ -4009,7 +1977,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): extra_item, extra_bluray_dir, ): - inherited_map[self.__get_file_key(extra_item)] = deepcopy(extra_meta) + inherited_map[self._get_file_key(extra_item)] = deepcopy(extra_meta) for item, is_bluray_dir in items: _append_item(planned_items, seen_file_keys, item, is_bluray_dir) @@ -4075,7 +2043,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) if transferd: should_reorganize = manual and ( - reorganize or not transferd.status + reorganize or not transferd.status ) if should_reorganize: state, message = self._delete_manual_transfer_history( @@ -4153,7 +2121,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if not meta: # 文件元数据(优先使用订阅识别词) inherited_meta = inherited_meta_map.get( - self.__get_file_key(file_item) + self._get_file_key(file_item) ) if history_music_meta: file_meta = history_music_meta @@ -4225,7 +2193,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): else: # 加入列表 if self.__put_to_jobview(transfer_task): - self.__register_scrape_batch_task(transfer_task) + self._register_scrape_batch_task(transfer_task) transfer_tasks.append(transfer_task) else: logger.debug(f"{file_path.name} 已在整理列表中,跳过") @@ -4234,7 +2202,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): finally: file_items.clear() del file_items - self.__close_scrape_batch(transfer_batch_id) + self._close_scrape_batch(transfer_batch_id) # 实时整理 preview_items: List[dict] = [] @@ -4323,7 +2291,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): # 预览模式不走默认回调,这里需要手动收敛任务状态,避免残留 running self.jobview.fail_task(transfer_task) self.jobview.try_remove_job(transfer_task) - if preview and (not preview_items or preview_items[-1].get("source") != transfer_task.fileitem.path): + if preview and ( + not preview_items or preview_items[-1].get("source") != transfer_task.fileitem.path): preview_items.append( { "source": transfer_task.fileitem.path, @@ -4461,7 +2430,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ]: args_error() return - state, errmsg = self.__re_transfer( + state, errmsg = self._re_transfer( logid=int(logid), mtype=MediaType(type_str), media_source=normalized_source, @@ -4481,338 +2450,6 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) return - @staticmethod - def build_failed_transfer_buttons( - history_id: Optional[int], - ) -> Optional[List[List[dict]]]: - """ - 构建整理失败通知的操作按钮。 - """ - if not history_id: - return None - return [ - [ - {"text": "重试", "callback_data": f"transfer_retry_{history_id}"}, - { - "text": "智能助手接管", - "callback_data": f"transfer_ai_retry_{history_id}", - }, - ] - ] - - def redo_transfer_history(self, history_id: int) -> Tuple[bool, str]: - """ - 按历史记录直接重新整理,自动重新识别媒体信息。 - """ - return self.__re_transfer(logid=history_id) - - @staticmethod - def parse_failed_transfer_callback( - callback_data: str, - ) -> Optional[tuple[str, int]]: - """ - 解析整理失败通知按钮回调。 - """ - for prefix, action in ( - ("transfer_retry_", "retry"), - ("transfer_ai_retry_", "ai_retry"), - ): - if callback_data.startswith(prefix): - history_id = callback_data.replace(prefix, "", 1) - if history_id.isdigit(): - return action, int(history_id) - return None - - def handle_failed_transfer_callback( - self, - *, - callback_data: str, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> bool: - """ - 处理整理失败通知中的重试类按钮。 - """ - callback = self.parse_failed_transfer_callback(callback_data) - if not callback: - return False - - action, history_id = callback - if action == "retry": - self._retry_transfer_history( - history_id=history_id, - channel=channel, - source=source, - userid=userid, - username=username, - ) - else: - self._take_over_transfer_history_by_ai( - history_id=history_id, - channel=channel, - source=source, - userid=userid, - username=username, - ) - return True - - def _retry_transfer_history( - self, - history_id: int, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 立即重新整理一条失败的整理记录。 - """ - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"开始重新整理记录 #{history_id} ...", - save_history=False, - ) - ) - - state, errmsg = self.redo_transfer_history(history_id) - if state: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"整理记录 #{history_id} 已重新整理", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - return - - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="重新整理失败", - text=errmsg, - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - def _take_over_transfer_history_by_ai( - self, - history_id: int, - channel: MessageChannel, - source: str, - userid: Union[str, int], - username: str, - ) -> None: - """ - 由智能助手接管一条失败的整理记录。 - """ - - if not settings.AI_AGENT_ENABLE: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="MoviePilot智能助手未启用,请在系统设置中启用", - save_history=False, - ) - ) - return - - history = TransferHistoryOper().get(history_id) - if not history: - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="重新整理失败", - text=f"整理记录 #{history_id} 不存在", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - return - - redo_prompt = build_manual_redo_prompt(history) - - self.post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title=f"已将整理记录 #{history_id} 交给智能助手处理", - text="处理完成后会在这里回复结果。", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - async def _run_ai_takeover(): - final_output = "" - - def _capture_output(text_output: str): - nonlocal final_output - final_output = text_output or "" - - try: - await agent_manager.run_background_prompt( - message=redo_prompt, - session_prefix=f"__agent_manual_redo_{history_id}", - output_callback=_capture_output, - reply_mode=ReplyMode.CAPTURE_ONLY, - allow_message_tools=False, - ) - await self.async_post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="智能助手整理完成", - text=final_output.strip() - or f"整理记录 #{history_id} 已由智能助手处理完成。", - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - except Exception as e: - await self.async_post_message( - Notification( - channel=channel, - source=source, - userid=userid, - username=username, - title="智能助手整理失败", - text=str(e), - link=settings.MP_DOMAIN("#/history"), - save_history=False, - ) - ) - - asyncio.run_coroutine_threadsafe(_run_ai_takeover(), global_vars.loop) - - - def __re_transfer( - self, - logid: int, - mtype: MediaType = None, - media_source: Optional[MediaSource] = None, - media_id: Optional[str] = None, - ) -> Tuple[bool, str]: - """ - 根据历史记录,重新识别整理,只支持简单条件 - :param logid: 历史记录ID - :param mtype: 媒体类型 - :param media_source: 媒体数据源 - :param media_id: 数据源原生 ID,必须与 media_source 成对提供 - """ - # 查询历史记录 - history: TransferHistory = TransferHistoryOper().get(logid) - if not history: - logger.error(f"整理记录不存在,ID:{logid}") - return False, "整理记录不存在" - # 按源目录路径重新整理 - src_path = Path(history.src) - if not src_path.exists(): - return False, f"源目录不存在:{src_path}" - # 查询媒体信息 - explicit_identity = media_source is not None or media_id is not None - if explicit_identity and (not media_source or not media_id): - return False, "媒体重新识别需要同时提供 media_source 和 media_id" - if mtype and media_source and media_id: - mediainfo = MediaChain().recognize_media( - mtype=mtype, - media_source=media_source, - media_id=media_id, - music_type=( - getattr(history, "music_type", None) - if mtype == MediaType.MUSIC - else None - ), - episode_group=history.episode_group, - ) - if mediainfo and not isinstance(mediainfo, MusicInfo): - # 更新媒体图片 - self.obtain_images(mediainfo=mediainfo) - elif history.media_source and history.media_id: - try: - history_type = mtype or MediaType(history.type) - except ValueError: - history_type = mtype - mediainfo = MediaChain().recognize_media( - mtype=history_type, - media_source=history.media_source, - media_id=history.media_id, - music_type=( - getattr(history, "music_type", None) - if history_type == MediaType.MUSIC - else None - ), - episode_group=history.episode_group, - ) - mtype = history_type - if mediainfo and not isinstance(mediainfo, MusicInfo): - self.obtain_images(mediainfo=mediainfo) - elif mtype == MediaType.MUSIC or self._is_music_retry_source(history, src_path): - # 音乐重新整理走音乐识别链,避免默认影视识别误入 TMDB - mtype = MediaType.MUSIC - mediainfo = self._recognize_music_retry_media(history, src_path) - else: - recognize_context = MediaChain().recognize_by_path( - str(src_path), - episode_group=history.episode_group, - obtain_images=True, - ) - mediainfo = recognize_context.media_info if recognize_context else None - # 音乐专辑目录允许无预识别信息,由整理链按音频后缀逐文件解析识别 - if not mediainfo and not (mtype == MediaType.MUSIC and src_path.is_dir()): - return False, ( - f"未识别到媒体信息,类型:{mtype.value if mtype else None}," - f"media_source:{media_source},media_id:{media_id}" - ) - # 重新执行整理 - if mediainfo: - logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}") - - # 删除旧的已整理文件 - if history.dest_fileitem: - # 解析目标文件对象 - dest_fileitem = FileItem(**history.dest_fileitem) - StorageChain().delete_file(dest_fileitem) - - # 强制整理 - if history.src_fileitem: - state, errmsg = self.do_transfer( - fileitem=FileItem(**history.src_fileitem), - mediainfo=mediainfo, - mtype=mtype, - download_hash=history.download_hash, - force=True, - background=False, - manual=True, - ) - if not state: - return False, errmsg - - return True, "" - def manual_transfer( self, fileitem: FileItem, diff --git a/app/domain/context.py b/app/domain/context.py index abec1d13f..d78d87b5d 100644 --- a/app/domain/context.py +++ b/app/domain/context.py @@ -1490,6 +1490,11 @@ class MediaInfo: meta = MetaInfo(self.title) season = meta.begin_season if meta.begin_season is not None else 1 episodes_count = info.get("total_episodes") or info.get("eps") + # bangumi 返回的集数可能为字符串,统一转整型避免拼接/范围构造异常 + try: + episodes_count = int(episodes_count) if episodes_count else 0 + except (TypeError, ValueError): + episodes_count = 0 if episodes_count: self.seasons[season] = list(range(1, episodes_count + 1)) self.number_of_episodes = episodes_count diff --git a/app/factory.py b/app/factory.py index 370e544e9..fb36aa9bf 100644 --- a/app/factory.py +++ b/app/factory.py @@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException from app.api.response import ResponseAPIRoute +from app.application.plugins import register_api_app from app.runtime.config import settings from app.runtime.localization import LocaleHelper from app.runtime.log import logger @@ -326,3 +327,7 @@ def create_app() -> FastAPI: # 创建 FastAPI 应用实例 app = create_app() + +# 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除 +# 统一经服务完成,避免 api.endpoints 反向依赖本模块。 +register_api_app(app) diff --git a/app/modules/_base/__init__.py b/app/modules/_base/__init__.py new file mode 100644 index 000000000..203021c7b --- /dev/null +++ b/app/modules/_base/__init__.py @@ -0,0 +1,15 @@ +"""模块业务样板基类包。 + +沉淀各内置模块逐字复制的业务样板,模块发现规则 +(`ModuleHelper.load`)会跳过 `_` 前缀的包与类,因此本包不会被识别为可实例化模块。 +""" + +from app.modules._base.downloader import _DownloaderModuleBase +from app.modules._base.mediaserver import _MediaServerModuleBase +from app.modules._base.notification import _MessageChannelModuleBase + +__all__ = [ + "_DownloaderModuleBase", + "_MessageChannelModuleBase", + "_MediaServerModuleBase", +] diff --git a/app/modules/_base/downloader.py b/app/modules/_base/downloader.py new file mode 100644 index 000000000..ec60913ab --- /dev/null +++ b/app/modules/_base/downloader.py @@ -0,0 +1,109 @@ +"""下载器模块业务样板基类。 + +沉淀三个内置下载器模块(qbittorrent/transmission/rtorrent)逐字复制的样板: +连接测试、定时重连、种子信息读取与查询状态归一。差异化逻辑 +(任务添加、原始状态映射、任务列表构建)仍留在各模块。 +""" +from pathlib import Path +from typing import Optional, Tuple, Union + +from torrentool.torrent import Torrent + +from app.domain import torrent as torrent_rules +from app.modules import _DownloaderBase, _ModuleBase, TService +from app.runtime.cache import FileCache +from app.runtime.log import logger +from app.schemas.types import TorrentQueryStatus, TorrentStatus + + +class _DownloaderModuleBase(_ModuleBase, _DownloaderBase[TService]): + """ + 下载器模块业务样板基类。 + """ + + def test(self) -> Optional[Tuple[bool, str]]: + """ + 测试模块连接性 + """ + if not self.get_instances(): + return None + for name, server in self.get_instances().items(): + if server.is_inactive(): + server.reconnect() + if not server.transfer_info(): + return False, f"无法连接{self.get_name()}下载器:{name}" + return True, "" + + def scheduler_job(self) -> None: + """ + 定时任务,每10分钟调用一次 + """ + for name, server in self.get_instances().items(): + if server.is_inactive(): + logger.info(f"{self.get_name()}下载器 {name} 连接断开,尝试重连 ...") + server.reconnect() + + def _get_torrent_info(self, content: Union[Path, str, bytes]) \ + -> Tuple[Optional[Torrent], Optional[bytes]]: + """ + 读取种子内容,返回解析后的种子信息与原始内容,磁力链接不解析 + """ + torrent_info, torrent_content = None, None + try: + if isinstance(content, Path): + if content.exists(): + torrent_content = content.read_bytes() + else: + # 读取缓存的种子文件 + torrent_content = FileCache().get( + content.as_posix(), region="torrents" + ) + else: + torrent_content = content + + if torrent_content: + # 检查是否为磁力链接 + if torrent_rules.is_magnet_link(torrent_content): + return None, torrent_content + else: + torrent_info = Torrent.from_string(torrent_content) + + return torrent_info, torrent_content + except Exception as e: + logger.error(f"获取种子名称失败:{e}") + return None, None + + @staticmethod + def _normalize_query_status( + status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]] + ) -> TorrentQueryStatus: + """ + 归一任务查询状态。 + """ + status_value = getattr(status, "value", status) + status_text = str(status_value or "").strip().lower() + if not status_text or status_text in {"all", "全部"}: + return TorrentQueryStatus.ALL + if status_text in { + TorrentStatus.TRANSFER.value, + TorrentQueryStatus.TRANSFER.value, + "transfer", + }: + return TorrentQueryStatus.TRANSFER + if status_text in { + TorrentStatus.DOWNLOADING.value, + TorrentQueryStatus.DOWNLOADING.value, + "downloading", + }: + return TorrentQueryStatus.DOWNLOADING + if status_text in { + TorrentQueryStatus.COMPLETED.value, + "complete", + "seeding", + "完成", + "已完成", + }: + return TorrentQueryStatus.COMPLETED + if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}: + return TorrentQueryStatus.PAUSED + return TorrentQueryStatus.ALL diff --git a/app/modules/_base/mediaserver.py b/app/modules/_base/mediaserver.py new file mode 100644 index 000000000..4b4b3aee9 --- /dev/null +++ b/app/modules/_base/mediaserver.py @@ -0,0 +1,192 @@ +"""媒体服务器模块业务样板基类。 + +沉淀各媒体服务器模块逐字复制的样板:用户辅助认证、媒体存在性检查、 +定时重连与连接测试。服务器差异(认证 API、存在性检查端点、连接探测方式) +通过类属性与钩子方法保留在各模块。 +""" +from typing import Optional, Tuple + +from app import schemas +from app.application.mediaserver import MusicMediaServerHelper +from app.domain.context import MediaInfo +from app.modules import _MediaServerBase, _ModuleBase, TService +from app.runtime.events import eventmanager +from app.runtime.log import logger +from app.schemas.types import ChainEventType, MediaType + + +class _MediaServerModuleBase(_ModuleBase, _MediaServerBase[TService]): + """ + 媒体服务器模块业务样板基类。 + """ + + # 媒体库标识(用于 ExistMediaInfo.server_type,如 "emby"),子类覆写 + _server_type_value: str = "" + + def user_authenticate( + self, + credentials: schemas.AuthCredentials, + service_name: Optional[str] = None, + ) -> Optional[schemas.AuthCredentials]: + """ + 使用媒体服务器用户辅助完成用户认证 + + :param credentials: 认证数据 + :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务器 + :return: 认证数据 + """ + if not credentials or credentials.grant_type != "password": + return None + # 确定要认证的服务器列表 + if service_name: + # 如果指定了服务名,获取该服务实例 + servers = ( + [(service_name, server)] + if (server := self.get_instance(service_name)) + else [] + ) + else: + # 如果没有指定服务名,遍历所有服务 + servers = self.get_instances().items() + # 遍历要认证的服务器 + for name, server in servers: + # 触发认证拦截事件 + intercept_event = eventmanager.send_event( + etype=ChainEventType.AuthIntercept, + data=schemas.AuthInterceptCredentials( + username=credentials.username, + channel=self.get_name(), + service=name, + status="triggered", + ), + ) + if intercept_event and intercept_event.event_data: + intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data + if intercept_data.cancel: + continue + token = server.authenticate(credentials.username, credentials.password) + if token: + credentials.channel = self.get_name() + credentials.service = name + credentials.token = token + return credentials + return None + + def media_exists( + self, + mediainfo: MediaInfo, + itemid: Optional[str] = None, + server: Optional[str] = None, + ) -> Optional[schemas.ExistMediaInfo]: + """ + 判断媒体文件是否存在 + + :param mediainfo: 识别的媒体信息 + :param itemid: 媒体服务器ItemID + :param server: 媒体服务器名称 + :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} + """ + if server: + servers = [(server, self.get_instance(server))] + else: + servers = self.get_instances().items() + for name, s in servers: + if not s: + continue + if mediainfo.type == MediaType.MUSIC: + # 部分服务器未实现音乐查询,退化为空列表 + matches = getattr(s, "get_music", lambda **_: [])( + **MusicMediaServerHelper.search_params(mediainfo) + ) + match = MusicMediaServerHelper.find_match(mediainfo, matches) + if match: + return schemas.ExistMediaInfo( + type=MediaType.MUSIC, + server_type=self._server_type_value, + server=name, + itemid=match.item_id, + ) + continue + if mediainfo.type == MediaType.MOVIE: + if itemid: + movie = s.get_iteminfo(itemid) + if movie: + logger.info(f"媒体库 {name} 中找到了 {movie}") + return schemas.ExistMediaInfo( + type=MediaType.MOVIE, + server_type=self._server_type_value, + server=name, + itemid=movie.item_id + ) + movies = s.get_movies(title=mediainfo.title, + year=mediainfo.year, + media_source=mediainfo.media_source, + media_id=mediainfo.media_id) + if not movies: + logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") + continue + else: + logger.info(f"媒体库 {name} 中找到了 {movies}") + return schemas.ExistMediaInfo( + type=MediaType.MOVIE, + server_type=self._server_type_value, + server=name, + itemid=movies[0].item_id + ) + else: + itemid, tvs = s.get_tv_episodes(title=mediainfo.title, + year=mediainfo.year, + media_source=mediainfo.media_source, + media_id=mediainfo.media_id, + item_id=itemid) + if not tvs: + logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") + continue + else: + logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到 了这些季集:{tvs}") + return schemas.ExistMediaInfo( + type=MediaType.TV, + seasons=tvs, + server_type=self._server_type_value, + server=name, + itemid=itemid + ) + return None + + def scheduler_job(self) -> None: + """ + 定时任务,每10分钟调用一次 + """ + # 定时重连 + for name, server in self.get_instances().items(): + if self._is_inactive(server): + logger.info(f"{self.get_name()}服务器 {name} 连接断开,尝试重连 ...") + server.reconnect() + + def _is_inactive(self, server) -> bool: + """ + 定时重连的失活判断钩子,子类可覆写(如增加配置完整性检查)。 + """ + return server.is_inactive() + + def test(self) -> Optional[Tuple[bool, str]]: + """ + 测试模块连接性 + """ + if not self.get_instances(): + return None + for name, server in self.get_instances().items(): + error = self._test_server(server, name) + if error: + return False, error + return True, "" + + def _test_server(self, server, name: str) -> Optional[str]: + """ + 连接测试钩子,返回失败信息,None 表示就绪,子类可覆写。 + """ + if server.is_inactive(): + server.reconnect() + if not server.get_user(): + return f"无法连接{self.get_name()}服务器:{name}" + return None diff --git a/app/modules/_base/notification.py b/app/modules/_base/notification.py new file mode 100644 index 000000000..83f49557a --- /dev/null +++ b/app/modules/_base/notification.py @@ -0,0 +1,149 @@ +"""消息渠道模块业务样板基类。 + +沉淀各消息渠道模块逐字复制的样板:管理员判断、连接测试、 +斜杠命令注册。渠道差异(客户端类型、菜单 API、前置条件)通过 +类属性与钩子方法保留在各模块。 +""" +import copy +from typing import Dict, List, Optional, Tuple, Union + +from app.application.messaging.agent import ( + matches_channel_admin, + resolve_config_principal_ids, +) +from app.foundation.collections import DictUtils +from app.modules import _MessageBase, _ModuleBase, TService +from app.runtime.events import eventmanager +from app.runtime.log import logger +from app.schemas import CommandRegisterEventData +from app.schemas.types import ChainEventType + + +class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]): + """ + 消息渠道模块业务样板基类。 + """ + + # 管理员配置键,子类覆写(如 "TELEGRAM_ADMINS") + _admin_config_key: str = "" + # 命令注册事件源标识,默认取模块名,子类可覆写 + _command_origin: Optional[str] = None + + @classmethod + def _get_admins(cls, config: Optional[dict]) -> List[str]: + """ + 解析渠道管理员配置,兼容逗号分隔和首尾空白。 + """ + return sorted(resolve_config_principal_ids(config, cls._admin_config_key)) + + def _should_reject_admin_command( + self, + config: Optional[dict], + *user_ids: Optional[Union[str, int]], + ) -> bool: + """ + 判断命令或命令型按钮回调是否应因非管理员身份被拒绝。 + """ + if not self._get_admins(config): + return False + # 模块实例未初始化时 self._channel 为空,退回静态子类型声明 + channel = self._channel or self.get_subtype() + return not matches_channel_admin( + channel, + config, + *user_ids, + ) + + def test(self) -> Optional[Tuple[bool, str]]: + """ + 测试模块连接性 + """ + if not self.get_instances(): + return None + for name, client in self.get_instances().items(): + state, message = self._test_connection(client) + if not state: + suffix = f":{message}" if message else "" + return False, f"{self.get_name()} {name} 未就绪{suffix}" + return True, "" + + def _test_connection(self, client) -> Tuple[bool, str]: + """ + 连接测试钩子,返回 (是否就绪, 失败信息),子类可覆写。 + """ + return bool(client.get_state()), "" + + def register_commands(self, commands: Dict[str, dict]) -> None: + """ + 注册命令,实现这个函数接收系统可用的命令菜单 + + :param commands: 命令字典 + """ + for client_config in self.get_configs().values(): + if not self._commands_enabled(client_config.config): + continue + + client = self.get_instance(client_config.name) + if not client: + continue + + # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 + scoped_commands = copy.deepcopy(commands) + event = eventmanager.send_event( + ChainEventType.CommandRegister, + CommandRegisterEventData( + commands=scoped_commands, + origin=self._command_origin or self.get_name(), + service=client_config.name, + ), + ) + + # 如果事件返回有效的 event_data,使用事件中调整后的命令 + if event and event.event_data: + event_data: CommandRegisterEventData = event.event_data + # 如果事件被取消,跳过命令注册,并清理菜单 + if event_data.cancel: + self._delete_commands(client) + logger.debug( + f"Command registration for {client_config.name} canceled by event: {event_data.source}" + ) + continue + scoped_commands = event_data.commands or {} + if not scoped_commands: + logger.debug("Filtered commands are empty, skipping registration.") + self._delete_commands(client) + + # scoped_commands 必须是 commands 的子集 + filtered_scoped_commands = DictUtils.filter_keys_to_subset( + scoped_commands, + commands, + ) + # 如果 filtered_scoped_commands 为空,则跳过注册 + if not filtered_scoped_commands: + logger.debug("Filtered commands are empty, skipping registration.") + self._delete_commands(client) + continue + # 对比调整后的命令与当前命令 + if filtered_scoped_commands != commands: + logger.debug( + f"Command set has changed, Updating new commands: {filtered_scoped_commands}" + ) + self._apply_commands(client, filtered_scoped_commands) + + def _commands_enabled(self, config: Optional[dict]) -> bool: + """ + 命令注册前置条件钩子,返回 False 时跳过该实例,子类可覆写。 + """ + return True + + def _delete_commands(self, client) -> None: + """ + 清理已注册命令的钩子,子类可覆写(如改用菜单 API)。 + """ + client.delete_commands() + + def _apply_commands(self, client, commands: Dict[str, dict]) -> None: + """ + 应用命令集合的钩子,子类可覆写(如改用菜单 API)。 + """ + client.register_commands(commands) diff --git a/app/modules/discord/__init__.py b/app/modules/discord/__init__.py index f2e6d7ca3..575ba879e 100644 --- a/app/modules/discord/__init__.py +++ b/app/modules/discord/__init__.py @@ -1,27 +1,23 @@ -import copy import json from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import quote, unquote from app.domain.context import MediaInfo, Context -from app.runtime.events import eventmanager from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.schemas import ( - CommandRegisterEventData, CommingMessage, MessageChannel, MessageResponse, Notification, ) -from app.schemas.types import ChainEventType, ModuleType +from app.schemas.types import ModuleType from app.adapters.network.http import RequestUtils -from app.foundation.collections import DictUtils try: from app.modules.discord.discord import Discord @@ -36,7 +32,9 @@ register_channel_admin_resolver( ) -class DiscordModule(_ModuleBase, _MessageBase[Discord]): +class DiscordModule(_MessageChannelModuleBase[Discord]): + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "DISCORD_ADMINS" _IMAGE_SUFFIXES = ( ".png", ".jpg", @@ -107,51 +105,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): except Exception as err: logger.error(f"停止Discord模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"Discord {name} Bot 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 Discord 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("DISCORD_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 Discord 命令或命令型按钮回调是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) - @staticmethod def _send_admin_denied( client: Optional[Discord], @@ -556,54 +512,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]): return True return False - def register_commands(self, commands: Dict[str, dict]) -> None: - """ - 注册命令,实现这个函数接收系统可用的命令菜单。 - - :param commands: 命令字典 - """ - for client_config in self.get_configs().values(): - client = self.get_instance(client_config.name) - if not client: - continue - - scoped_commands = copy.deepcopy(commands) - event = eventmanager.send_event( - ChainEventType.CommandRegister, - CommandRegisterEventData( - commands=scoped_commands, - origin="Discord", - service=client_config.name, - ), - ) - - if event and event.event_data: - event_data: CommandRegisterEventData = event.event_data - if event_data.cancel: - client.delete_commands() - logger.debug( - f"Command registration for {client_config.name} canceled by event: {event_data.source}" - ) - continue - scoped_commands = event_data.commands or {} - if not scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - - filtered_scoped_commands = DictUtils.filter_keys_to_subset( - scoped_commands, - commands, - ) - if not filtered_scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - continue - if filtered_scoped_commands != commands: - logger.debug( - f"Command set has changed, Updating new commands: {filtered_scoped_commands}" - ) - client.register_commands(filtered_scoped_commands) - def mark_message_processing_started( self, channel: MessageChannel, diff --git a/app/modules/emby/__init__.py b/app/modules/emby/__init__.py index 02649d02e..28c6d9fbd 100644 --- a/app/modules/emby/__init__.py +++ b/app/modules/emby/__init__.py @@ -1,16 +1,16 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union from app import schemas -from app.domain.context import MediaInfo -from app.runtime.events import eventmanager -from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _MediaServerBase, _ModuleBase +from app.modules._base import _MediaServerModuleBase from app.modules.emby.emby import Emby -from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType +from app.schemas.types import ModuleType, MediaServerType -class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): +class EmbyModule(_MediaServerModuleBase[Emby]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "emby" def init_module(self) -> None: """ @@ -47,70 +47,9 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.get_user(): - return False, f"无法连接Emby服务器:{name}" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - # 定时重连 - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"Emby服务器 {name} 连接断开,尝试重连 ...") - server.reconnect() - - def user_authenticate(self, credentials: schemas.AuthCredentials, service_name: Optional[str] = None) \ - -> Optional[schemas.AuthCredentials]: - """ - 使用Emby用户辅助完成用户认证 - :param credentials: 认证数据 - :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 - :return: 认证数据 - """ - # Emby认证 - if not credentials or credentials.grant_type != "password": - return None - # 确定要认证的服务器列表 - if service_name: - # 如果指定了服务名,获取该服务实例 - servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] - else: - # 如果没有指定服务名,遍历所有服务 - servers = self.get_instances().items() - # 遍历要认证的服务器 - for name, server in servers: - # 触发认证拦截事件 - intercept_event = eventmanager.send_event( - etype=ChainEventType.AuthIntercept, - data=schemas.AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), - service=name, status="triggered") - ) - if intercept_event and intercept_event.event_data: - intercept_data: schemas.AuthInterceptCredentials = intercept_event.event_data - if intercept_data.cancel: - continue - token = server.authenticate(credentials.username, credentials.password) - if token: - credentials.channel = self.get_name() - credentials.service = name - credentials.token = token - return credentials - return None - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 @@ -136,79 +75,6 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]): return result return None - def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, - server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: - """ - 判断媒体文件是否存在 - :param mediainfo: 识别的媒体信息 - :param itemid: 媒体服务器ItemID - :param server: 媒体服务器名称 - :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} - """ - if server: - servers = [(server, self.get_instance(server))] - else: - servers = self.get_instances().items() - for name, s in servers: - if not s: - continue - if mediainfo.type == MediaType.MUSIC: - matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo)) - match = MusicMediaServerHelper.find_match(mediainfo, matches) - if match: - return schemas.ExistMediaInfo( - type=MediaType.MUSIC, - server_type="emby", - server=name, - itemid=match.item_id, - ) - continue - if mediainfo.type == MediaType.MOVIE: - if itemid: - movie = s.get_iteminfo(itemid) - if movie: - logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="emby", - server=name, - itemid=movie.item_id - ) - movies = s.get_movies(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id) - if not movies: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="emby", - server=name, - itemid=movies[0].item_id - ) - else: - itemid, tvs = s.get_tv_episodes(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - item_id=itemid) - if not tvs: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") - return schemas.ExistMediaInfo( - type=MediaType.TV, - seasons=tvs, - server_type="emby", - server=name, - itemid=itemid - ) - return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 diff --git a/app/modules/feishu/__init__.py b/app/modules/feishu/__init__.py index f9b5c1def..21f54562f 100644 --- a/app/modules/feishu/__init__.py +++ b/app/modules/feishu/__init__.py @@ -3,7 +3,7 @@ from typing import Any, List, Optional, Tuple, Union from app.domain.context import Context, MediaInfo from app.application.messaging.agent import register_channel_admin_resolver, resolve_config_principal_ids from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.feishu.feishu import Feishu from app.schemas import CommingMessage, MessageChannel, MessageResponse, Notification from app.schemas.types import ModuleType @@ -17,7 +17,7 @@ register_channel_admin_resolver( ) -class FeishuModule(_ModuleBase, _MessageBase[Feishu]): +class FeishuModule(_MessageChannelModuleBase[Feishu]): def init_module(self) -> None: super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu) self._channel = MessageChannel.Feishu @@ -46,15 +46,6 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]): except Exception as err: logger.error(f"停止飞书模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"飞书 {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: """通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。""" return None diff --git a/app/modules/jellyfin/__init__.py b/app/modules/jellyfin/__init__.py index d9344668b..517a88edf 100644 --- a/app/modules/jellyfin/__init__.py +++ b/app/modules/jellyfin/__init__.py @@ -1,17 +1,16 @@ from typing import Any, Dict, Generator, List, Optional, Tuple, Union from app import schemas -from app.domain.context import MediaInfo -from app.runtime.events import eventmanager -from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _MediaServerBase, _ModuleBase +from app.modules._base import _MediaServerModuleBase from app.modules.jellyfin.jellyfin import Jellyfin -from app.schemas import AuthCredentials, AuthInterceptCredentials -from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType +from app.schemas.types import ModuleType, MediaServerType -class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): +class JellyfinModule(_MediaServerModuleBase[Jellyfin]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "jellyfin" def init_module(self) -> None: """ @@ -48,70 +47,9 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - # 定时重连 - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"Jellyfin {name} 服务器连接断开,尝试重连 ...") - server.reconnect() - def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.get_user(): - return False, f"无法连接Jellyfin服务器:{name}" - return True, "" - - def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \ - -> Optional[AuthCredentials]: - """ - 使用Jellyfin用户辅助完成用户认证 - :param credentials: 认证数据 - :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 - :return: 认证数据 - """ - # Jellyfin认证 - if not credentials or credentials.grant_type != "password": - return None - # 确定要认证的服务器列表 - if service_name: - # 如果指定了服务名,获取该服务实例 - servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] - else: - # 如果没有指定服务名,遍历所有服务 - servers = self.get_instances().items() - # 遍历要认证的服务器 - for name, server in servers: - # 触发认证拦截事件 - intercept_event = eventmanager.send_event( - etype=ChainEventType.AuthIntercept, - data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), - service=name, status="triggered") - ) - if intercept_event and intercept_event.event_data: - intercept_data: AuthInterceptCredentials = intercept_event.event_data - if intercept_data.cancel: - continue - token = server.authenticate(credentials.username, credentials.password) - if token: - credentials.channel = self.get_name() - credentials.service = name - credentials.token = token - return credentials - return None - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 @@ -137,79 +75,6 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): return result return None - def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, - server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: - """ - 判断媒体文件是否存在 - :param mediainfo: 识别的媒体信息 - :param itemid: 媒体服务器ItemID - :param server: 媒体服务器名称 - :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} - """ - if server: - servers = [(server, self.get_instance(server))] - else: - servers = self.get_instances().items() - for name, s in servers: - if not s: - continue - if mediainfo.type == MediaType.MUSIC: - matches = s.get_music(**MusicMediaServerHelper.search_params(mediainfo)) - match = MusicMediaServerHelper.find_match(mediainfo, matches) - if match: - return schemas.ExistMediaInfo( - type=MediaType.MUSIC, - server_type="jellyfin", - server=name, - itemid=match.item_id, - ) - continue - if mediainfo.type == MediaType.MOVIE: - if itemid: - movie = s.get_iteminfo(itemid) - if movie: - logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="jellyfin", - server=name, - itemid=movie.item_id - ) - movies = s.get_movies(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id) - if not movies: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="jellyfin", - server=name, - itemid=movies[0].item_id - ) - else: - itemid, tvs = s.get_tv_episodes(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - item_id=itemid) - if not tvs: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") - return schemas.ExistMediaInfo( - type=MediaType.TV, - seasons=tvs, - server_type="jellyfin", - server=name, - itemid=itemid - ) - return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 diff --git a/app/modules/plex/__init__.py b/app/modules/plex/__init__.py index c8dd4b0be..531879b69 100644 --- a/app/modules/plex/__init__.py +++ b/app/modules/plex/__init__.py @@ -5,13 +5,16 @@ from app.domain.context import MediaInfo from app.runtime.events import eventmanager from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _ModuleBase, _MediaServerBase +from app.modules._base import _MediaServerModuleBase from app.modules.plex.plex import Plex from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType -class PlexModule(_ModuleBase, _MediaServerBase[Plex]): +class PlexModule(_MediaServerModuleBase[Plex]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "plex" def init_module(self) -> None: """ @@ -54,32 +57,17 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]): except Exception as err: logger.error(f"停止Plex模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.get_librarys(): - return False, f"无法连接Plex服务器:{name}" - return True, "" + def _test_server(self, server, name: str) -> Optional[str]: + """Plex 用媒体库列表探测连接状态。""" + if server.is_inactive(): + server.reconnect() + if not server.get_librarys(): + return f"无法连接Plex服务器:{name}" + return None def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - # 定时重连 - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"Plex {name} 服务器连接断开,尝试重连 ...") - server.reconnect() - def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \ -> Optional[AuthCredentials]: """ diff --git a/app/modules/qbittorrent/__init__.py b/app/modules/qbittorrent/__init__.py index ee5425df4..178586faf 100644 --- a/app/modules/qbittorrent/__init__.py +++ b/app/modules/qbittorrent/__init__.py @@ -2,14 +2,12 @@ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict from qbittorrentapi import TorrentFilesList -from torrentool.torrent import Torrent from app import schemas -from app.runtime.cache import FileCache from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.modules import _ModuleBase, _DownloaderBase +from app.modules._base import _DownloaderModuleBase from app.modules.qbittorrent.qbittorrent import Qbittorrent from app.schemas import DownloaderTorrent from app.schemas.types import ( @@ -19,7 +17,6 @@ from app.schemas.types import ( TorrentQueryStatus, TorrentStatus, ) -from app.domain import torrent as torrent_rules from app.foundation import size as size_tools from app.foundation import temporal as time_tools from app.foundation import text as text_tools @@ -44,7 +41,7 @@ _TORRENT_FILES_RETRY_TIMES = 5 _TORRENT_FILES_RETRY_INTERVAL = 1 -class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): +class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]): """ qBittorrent 下载器模块,负责下载任务添加、文件选择和任务管理。 """ @@ -90,34 +87,12 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): """ pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.transfer_info(): - return False, f"无法连接Qbittorrent下载器:{name}" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: """ 返回控制模块启用状态的配置项 """ pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"Qbittorrent下载器 {name} 连接断开,尝试重连 ...") - server.reconnect() - def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: @@ -132,39 +107,11 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): :param downloader: 下载器 :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ - - def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: - """ - 获取种子名称 - """ - torrent_info, torrent_content = None, None - try: - if isinstance(content, Path): - if content.exists(): - torrent_content = content.read_bytes() - else: - # 读取缓存的种子文件 - torrent_content = FileCache().get(content.as_posix(), region="torrents") - else: - torrent_content = content - - if torrent_content: - # 检查是否为磁力链接 - if torrent_rules.is_magnet_link(torrent_content): - return None, torrent_content - else: - torrent_info = Torrent.from_string(torrent_content) - - return torrent_info, torrent_content - except Exception as e: - logger.error(f"获取种子名称失败:{e}") - return None, None - if not content: return None, None, None, "下载内容为空" # 读取种子的名称 - torrent_from_file, content = __get_torrent_info() + torrent_from_file, content = self._get_torrent_info(content) # 检查是否为磁力链接 is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content, bytes) and content.startswith( @@ -302,7 +249,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): else: servers: Dict[str, Qbittorrent] = self.get_instances() ret_torrents = [] - query_status = self.__normalize_query_status(status) + query_status = self._normalize_query_status(status) query_tags = None if include_all_tags else settings.TORRENT_TAG def __get_torrent_path(torrent_data: dict) -> Path: @@ -408,41 +355,6 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): return None return ret_torrents # noqa - @staticmethod - def __normalize_query_status( - status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]] - ) -> TorrentQueryStatus: - """ - 归一任务查询状态。 - """ - status_value = getattr(status, "value", status) - status_text = str(status_value or "").strip().lower() - if not status_text or status_text in {"all", "全部"}: - return TorrentQueryStatus.ALL - if status_text in { - TorrentStatus.TRANSFER.value, - TorrentQueryStatus.TRANSFER.value, - "transfer", - }: - return TorrentQueryStatus.TRANSFER - if status_text in { - TorrentStatus.DOWNLOADING.value, - TorrentQueryStatus.DOWNLOADING.value, - "downloading", - }: - return TorrentQueryStatus.DOWNLOADING - if status_text in { - TorrentQueryStatus.COMPLETED.value, - "complete", - "seeding", - "完成", - "已完成", - }: - return TorrentQueryStatus.COMPLETED - if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}: - return TorrentQueryStatus.PAUSED - return TorrentQueryStatus.ALL - @staticmethod def __normalize_torrent_state(state: Optional[Union[str, int]]) -> str: """ diff --git a/app/modules/qqbot/__init__.py b/app/modules/qqbot/__init__.py index 47583a1e4..2c16a1392 100644 --- a/app/modules/qqbot/__init__.py +++ b/app/modules/qqbot/__init__.py @@ -15,7 +15,7 @@ from app.application.messaging.agent import ( resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.qqbot.qqbot import QQBot from app.schemas import CommingMessage, MessageChannel, Notification from app.schemas.types import ModuleType @@ -30,9 +30,12 @@ register_channel_admin_resolver( ) -class QQBotModule(_ModuleBase, _MessageBase[QQBot]): +class QQBotModule(_MessageChannelModuleBase[QQBot]): """QQ Bot 通知模块""" + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "QQBOT_ADMINS" + _IMAGE_SUFFIXES = ( ".png", ".jpg", @@ -86,46 +89,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]): except Exception as err: logger.error(f"停止QQ Bot模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - if not client.get_state(): - return False, f"QQ Bot {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 QQ 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("QQBOT_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 QQ 斜杠命令是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - return not matches_channel_admin( - MessageChannel.QQ, - config, - *user_ids, - ) - @staticmethod def _send_admin_denied( client: Optional[QQBot], userid: Optional[Union[str, int]] diff --git a/app/modules/rtorrent/__init__.py b/app/modules/rtorrent/__init__.py index 8dc71388e..9d1a655c4 100644 --- a/app/modules/rtorrent/__init__.py +++ b/app/modules/rtorrent/__init__.py @@ -1,14 +1,11 @@ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict -from torrentool.torrent import Torrent - from app import schemas -from app.runtime.cache import FileCache from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.modules import _ModuleBase, _DownloaderBase +from app.modules._base import _DownloaderModuleBase from app.modules.rtorrent.rtorrent import Rtorrent from app.schemas import DownloaderTorrent from app.schemas.types import ( @@ -18,13 +15,12 @@ from app.schemas.types import ( TorrentQueryStatus, TorrentStatus, ) -from app.domain import torrent as torrent_rules from app.foundation import size as size_tools from app.foundation import temporal as time_tools from app.foundation import text as text_tools -class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): +class RtorrentModule(_DownloaderModuleBase[Rtorrent]): def init_module(self) -> None: """ 初始化模块 @@ -61,31 +57,9 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.transfer_info(): - return False, f"无法连接rTorrent下载器:{name}" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"rTorrent下载器 {name} 连接断开,尝试重连 ...") - server.reconnect() - def download( self, content: Union[Path, str, bytes], @@ -108,38 +82,11 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ - def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: - """ - 获取种子名称 - """ - torrent_info, torrent_content = None, None - try: - if isinstance(content, Path): - if content.exists(): - torrent_content = content.read_bytes() - else: - torrent_content = FileCache().get( - content.as_posix(), region="torrents" - ) - else: - torrent_content = content - - if torrent_content: - if torrent_rules.is_magnet_link(torrent_content): - return None, torrent_content - else: - torrent_info = Torrent.from_string(torrent_content) - - return torrent_info, torrent_content - except Exception as e: - logger.error(f"获取种子名称失败:{e}") - return None, None - if not content: return None, None, None, "下载内容为空" # 读取种子的名称 - torrent_from_file, content = __get_torrent_info() + torrent_from_file, content = self._get_torrent_info(content) # 检查是否为磁力链接 is_magnet = ( isinstance(content, str) @@ -311,7 +258,7 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): else: servers: Dict[str, Rtorrent] = self.get_instances() ret_torrents = [] - query_status = self.__normalize_query_status(status) + query_status = self._normalize_query_status(status) query_tags = None if include_all_tags else settings.TORRENT_TAG def __get_torrent_path(torrent_data: dict) -> Path: @@ -424,41 +371,6 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): return None return ret_torrents # noqa - @staticmethod - def __normalize_query_status( - status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]] - ) -> TorrentQueryStatus: - """ - 归一任务查询状态。 - """ - status_value = getattr(status, "value", status) - status_text = str(status_value or "").strip().lower() - if not status_text or status_text in {"all", "全部"}: - return TorrentQueryStatus.ALL - if status_text in { - TorrentStatus.TRANSFER.value, - TorrentQueryStatus.TRANSFER.value, - "transfer", - }: - return TorrentQueryStatus.TRANSFER - if status_text in { - TorrentStatus.DOWNLOADING.value, - TorrentQueryStatus.DOWNLOADING.value, - "downloading", - }: - return TorrentQueryStatus.DOWNLOADING - if status_text in { - TorrentQueryStatus.COMPLETED.value, - "complete", - "seeding", - "完成", - "已完成", - }: - return TorrentQueryStatus.COMPLETED - if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}: - return TorrentQueryStatus.PAUSED - return TorrentQueryStatus.ALL - @staticmethod def __normalize_torrent_state( state: Optional[Union[int, str]], diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py index 631ae5304..42a6b57c4 100644 --- a/app/modules/slack/__init__.py +++ b/app/modules/slack/__init__.py @@ -1,28 +1,24 @@ -import copy import json import re from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import quote, unquote from app.domain.context import MediaInfo, Context -from app.runtime.events import eventmanager from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.slack.slack import Slack from app.schemas import ( - CommandRegisterEventData, CommingMessage, MessageChannel, MessageResponse, Notification, ) -from app.schemas.types import ChainEventType, ModuleType -from app.foundation.collections import DictUtils +from app.schemas.types import ModuleType register_channel_admin_resolver( @@ -31,7 +27,9 @@ register_channel_admin_resolver( ) -class SlackModule(_ModuleBase, _MessageBase[Slack]): +class SlackModule(_MessageChannelModuleBase[Slack]): + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "SLACK_ADMINS" PROCESSING_REACTION = "eyes" _AUDIO_SUFFIXES = ( ".mp3", @@ -88,51 +86,9 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): except Exception as err: logger.error(f"停止Slack模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"Slack {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 Slack 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("SLACK_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 Slack 命令或命令型按钮回调是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) - @staticmethod def _send_admin_denied(client: Optional[Slack], userid: Optional[Union[str, int]]) -> None: """ @@ -688,54 +644,6 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]): return True return False - def register_commands(self, commands: Dict[str, dict]) -> None: - """ - 注册命令,实现这个函数接收系统可用的命令菜单。 - - :param commands: 命令字典 - """ - for client_config in self.get_configs().values(): - client = self.get_instance(client_config.name) - if not client: - continue - - scoped_commands = copy.deepcopy(commands) - event = eventmanager.send_event( - ChainEventType.CommandRegister, - CommandRegisterEventData( - commands=scoped_commands, - origin="Slack", - service=client_config.name, - ), - ) - - if event and event.event_data: - event_data: CommandRegisterEventData = event.event_data - if event_data.cancel: - client.delete_commands() - logger.debug( - f"Command registration for {client_config.name} canceled by event: {event_data.source}" - ) - continue - scoped_commands = event_data.commands or {} - if not scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - - filtered_scoped_commands = DictUtils.filter_keys_to_subset( - scoped_commands, - commands, - ) - if not filtered_scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - continue - if filtered_scoped_commands != commands: - logger.debug( - f"Command set has changed, Updating new commands: {filtered_scoped_commands}" - ) - client.register_commands(filtered_scoped_commands) - def mark_message_processing_started( self, channel: MessageChannel, diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py index d47d8af02..364206528 100644 --- a/app/modules/synologychat/__init__.py +++ b/app/modules/synologychat/__init__.py @@ -9,7 +9,7 @@ from app.application.messaging.agent import ( resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.synologychat.synologychat import SynologyChat from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType @@ -22,7 +22,9 @@ register_channel_admin_resolver( ) -class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): +class SynologyChatModule(_MessageChannelModuleBase[SynologyChat]): + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "SYNOLOGYCHAT_ADMINS" _IMAGE_SUFFIXES = ( ".png", ".jpg", @@ -84,51 +86,9 @@ class SynologyChatModule(_ModuleBase, _MessageBase[SynologyChat]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"Synology Chat {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 Synology Chat 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("SYNOLOGYCHAT_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 Synology Chat 斜杠命令是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) - @staticmethod def _send_admin_denied( client: Optional[SynologyChat], userid: Optional[Union[str, int]] diff --git a/app/modules/telegram/__init__.py b/app/modules/telegram/__init__.py index fd2841210..c10e1ba85 100644 --- a/app/modules/telegram/__init__.py +++ b/app/modules/telegram/__init__.py @@ -1,28 +1,24 @@ -import copy import json import re from typing import Dict, Optional, Union, List, Tuple, Any from app.domain.context import MediaInfo, Context -from app.runtime.events import eventmanager from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.telegram.telegram import Telegram from app.schemas import ( MessageChannel, CommingMessage, Notification, - CommandRegisterEventData, NotificationConf, MessageResponse, ) -from app.schemas.types import ModuleType, ChainEventType -from app.foundation.collections import DictUtils +from app.schemas.types import ModuleType register_channel_admin_resolver( @@ -33,11 +29,14 @@ register_channel_admin_resolver( ) -class TelegramModule(_ModuleBase, _MessageBase[Telegram]): +class TelegramModule(_MessageChannelModuleBase[Telegram]): """ Telegram 通知模块,负责模块生命周期、消息解析和通知发送。 """ + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "TELEGRAM_ADMINS" + def init_module(self) -> None: """ 初始化模块 @@ -83,53 +82,12 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): except Exception as err: logger.error(f"停止Telegram模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"Telegram {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: """ 获取模块初始化配置项。 """ pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 Telegram 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("TELEGRAM_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 Telegram 命令或命令型按钮回调是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - return not matches_channel_admin( - MessageChannel.Telegram, - config, - *user_ids, - ) - def message_parser( self, source: str, body: Any, form: Any, args: Any ) -> Optional[CommingMessage]: @@ -795,58 +753,6 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]): ) return None - def register_commands(self, commands: Dict[str, dict]): - """ - 注册命令,实现这个函数接收系统可用的命令菜单 - :param commands: 命令字典 - """ - for client_config in self.get_configs().values(): - client = self.get_instance(client_config.name) - if not client: - continue - - # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 - scoped_commands = copy.deepcopy(commands) - event = eventmanager.send_event( - ChainEventType.CommandRegister, - CommandRegisterEventData( - commands=scoped_commands, - origin="Telegram", - service=client_config.name, - ), - ) - - # 如果事件返回有效的 event_data,使用事件中调整后的命令 - if event and event.event_data: - event_data: CommandRegisterEventData = event.event_data - # 如果事件被取消,跳过命令注册,并清理菜单 - if event_data.cancel: - client.delete_commands() - logger.debug( - f"Command registration for {client_config.name} canceled by event: {event_data.source}" - ) - continue - scoped_commands = event_data.commands or {} - if not scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - - # scoped_commands 必须是 commands 的子集 - filtered_scoped_commands = DictUtils.filter_keys_to_subset( - scoped_commands, commands - ) - # 如果 filtered_scoped_commands 为空,则跳过注册 - if not filtered_scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_commands() - continue - # 对比调整后的命令与当前命令 - if filtered_scoped_commands != commands: - logger.debug( - f"Command set has changed, Updating new commands: {filtered_scoped_commands}" - ) - client.register_commands(filtered_scoped_commands) - def download_telegram_file_to_base64(self, file_id: str, source: str) -> Optional[str]: """ 下载Telegram文件并转为base64 diff --git a/app/modules/transmission/__init__.py b/app/modules/transmission/__init__.py index 0b2a72b85..118dfb5b3 100644 --- a/app/modules/transmission/__init__.py +++ b/app/modules/transmission/__init__.py @@ -1,15 +1,13 @@ from pathlib import Path from typing import Set, Tuple, Optional, Union, List, Dict -from torrentool.torrent import Torrent from transmission_rpc import File from app import schemas -from app.runtime.cache import FileCache from app.runtime.config import settings from app.domain.metainfo import MetaInfo from app.runtime.log import logger -from app.modules import _ModuleBase, _DownloaderBase +from app.modules._base import _DownloaderModuleBase from app.modules.transmission.transmission import Transmission from app.schemas import DownloaderTorrent from app.schemas.types import ( @@ -19,7 +17,6 @@ from app.schemas.types import ( TorrentQueryStatus, TorrentStatus, ) -from app.domain import torrent as torrent_rules from app.foundation import size as size_tools from app.foundation import temporal as time_tools @@ -32,7 +29,7 @@ _TRANSMISSION_PAUSED_STATES = { } -class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): +class TransmissionModule(_DownloaderModuleBase[Transmission]): def init_module(self) -> None: """ @@ -69,32 +66,9 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive(): - server.reconnect() - if not server.transfer_info(): - return False, f"无法连接Transmission下载器:{name}" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - # 定时重连 - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"Transmission下载器 {name} 连接断开,尝试重连 ...") - server.reconnect() - def download(self, content: Union[Path, str, bytes], download_dir: Path, cookie: str, episodes: Set[int] = None, category: Optional[str] = None, label: Optional[str] = None, downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]: @@ -110,38 +84,11 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): :return: 下载器名称、种子Hash、种子文件布局、错误原因 """ - def __get_torrent_info() -> Tuple[Optional[Torrent], Optional[bytes]]: - """ - 获取种子名称 - """ - torrent_info, torrent_content = None, None - try: - if isinstance(content, Path): - if content.exists(): - torrent_content = content.read_bytes() - else: - # 读取缓存的种子文件 - torrent_content = FileCache().get(content.as_posix(), region="torrents") - else: - torrent_content = content - - if torrent_content: - # 检查是否为磁力链接 - if torrent_rules.is_magnet_link(torrent_content): - return None, torrent_content - else: - torrent_info = Torrent.from_string(torrent_content) - - return torrent_info, torrent_content - except Exception as e: - logger.error(f"获取种子名称失败:{e}") - return None, None - if not content: return None, None, None, "下载内容为空" # 读取种子的名称 - torrent_from_file, content = __get_torrent_info() + torrent_from_file, content = self._get_torrent_info(content) # 检查是否为磁力链接 is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content, bytes) and content.startswith( @@ -261,7 +208,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): else: servers: Dict[str, Transmission] = self.get_instances() ret_torrents = [] - query_status = self.__normalize_query_status(status) + query_status = self._normalize_query_status(status) query_tags = None if include_all_tags else settings.TORRENT_TAG def __get_torrent_attr(torrent_data, *attr_names): @@ -406,41 +353,6 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]): return None return ret_torrents # noqa - @staticmethod - def __normalize_query_status( - status: Optional[Union[TorrentStatus, TorrentQueryStatus, str]] - ) -> TorrentQueryStatus: - """ - 归一任务查询状态。 - """ - status_value = getattr(status, "value", status) - status_text = str(status_value or "").strip().lower() - if not status_text or status_text in {"all", "全部"}: - return TorrentQueryStatus.ALL - if status_text in { - TorrentStatus.TRANSFER.value, - TorrentQueryStatus.TRANSFER.value, - "transfer", - }: - return TorrentQueryStatus.TRANSFER - if status_text in { - TorrentStatus.DOWNLOADING.value, - TorrentQueryStatus.DOWNLOADING.value, - "downloading", - }: - return TorrentQueryStatus.DOWNLOADING - if status_text in { - TorrentQueryStatus.COMPLETED.value, - "complete", - "seeding", - "完成", - "已完成", - }: - return TorrentQueryStatus.COMPLETED - if status_text in {TorrentQueryStatus.PAUSED.value, "pause", "暂停", "已暂停"}: - return TorrentQueryStatus.PAUSED - return TorrentQueryStatus.ALL - @staticmethod def __normalize_torrent_state(status: Optional[str]) -> str: """ diff --git a/app/modules/trimemedia/__init__.py b/app/modules/trimemedia/__init__.py index 6f04d4614..817e4dadb 100644 --- a/app/modules/trimemedia/__init__.py +++ b/app/modules/trimemedia/__init__.py @@ -1,17 +1,16 @@ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas -from app.domain.context import MediaInfo -from app.runtime.events import eventmanager -from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _MediaServerBase, _ModuleBase +from app.modules._base import _MediaServerModuleBase from app.modules.trimemedia.trimemedia import TrimeMedia -from app.schemas import AuthCredentials, AuthInterceptCredentials -from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType +from app.schemas.types import MediaServerType, ModuleType -class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): +class TrimeMediaModule(_MediaServerModuleBase[TrimeMedia]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "trimemedia" def init_module(self) -> None: """ @@ -52,15 +51,9 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - # 定时重连 - for name, server in self.get_instances().items(): - if server.is_configured() and server.is_inactive(): - logger.info(f"飞牛影视 {name} 连接断开,尝试重连 ...") - server.reconnect() + def _is_inactive(self, server) -> bool: + """未配置的实例不参与定时重连。""" + return server.is_configured() and server.is_inactive() def stop(self) -> None: """停止模块""" @@ -71,65 +64,12 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): except Exception as err: logger.error(f"停止飞牛影视模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if not server.is_configured(): - return False, f"飞牛影视配置不完整:{name}" - if server.is_inactive() and not server.reconnect(): - return False, f"无法连接飞牛影视:{name}" - return True, "" - - def user_authenticate( - self, credentials: AuthCredentials, service_name: Optional[str] = None - ) -> Optional[AuthCredentials]: - """ - 使用飞牛影视用户辅助完成用户认证 - - :param credentials: 认证数据 - :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 - :return: 认证数据 - """ - # 飞牛影视认证 - if not credentials or credentials.grant_type != "password": - return None - # 确定要认证的服务器列表 - if service_name: - # 如果指定了服务名,获取该服务实例 - servers = ( - [(service_name, server)] - if (server := self.get_instance(service_name)) - else [] - ) - else: - # 如果没有指定服务名,遍历所有服务 - servers = self.get_instances().items() - # 遍历要认证的服务器 - for name, server in servers: - # 触发认证拦截事件 - intercept_event = eventmanager.send_event( - etype=ChainEventType.AuthIntercept, - data=AuthInterceptCredentials( - username=credentials.username, - channel=self.get_name(), - service=name, - status="triggered", - ), - ) - if intercept_event and intercept_event.event_data: - intercept_data: AuthInterceptCredentials = intercept_event.event_data - if intercept_data.cancel: - continue - token = server.authenticate(credentials.username, credentials.password) - if token: - credentials.channel = self.get_name() - credentials.service = name - credentials.token = token - return credentials + def _test_server(self, server, name: str) -> Optional[str]: + """飞牛影视用配置完整性与重连结果探测连接状态。""" + if not server.is_configured(): + return f"{self.get_name()}配置不完整:{name}" + if server.is_inactive() and not server.reconnect(): + return f"无法连接{self.get_name()}:{name}" return None def webhook_parser( @@ -160,92 +100,6 @@ class TrimeMediaModule(_ModuleBase, _MediaServerBase[TrimeMedia]): return result return None - def media_exists( - self, - mediainfo: MediaInfo, - itemid: Optional[str] = None, - server: Optional[str] = None, - ) -> Optional[schemas.ExistMediaInfo]: - """ - 判断媒体文件是否存在 - - :param mediainfo: 识别的媒体信息 - :param itemid: 媒体服务器ItemID - :param server: 媒体服务器名称 - :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} - """ - if server: - servers = [(server, self.get_instance(server))] - else: - servers = self.get_instances().items() - for name, s in servers: - if not s: - continue - if mediainfo.type == MediaType.MUSIC: - matches = getattr(s, "get_music", lambda **_: [])( - **MusicMediaServerHelper.search_params(mediainfo) - ) - match = MusicMediaServerHelper.find_match(mediainfo, matches) - if match: - return schemas.ExistMediaInfo( - type=MediaType.MUSIC, - server_type="trimemedia", - server=name, - itemid=match.item_id, - ) - continue - if mediainfo.type == MediaType.MOVIE: - if itemid: - movie = s.get_iteminfo(itemid) - if movie: - logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="trimemedia", - server=name, - itemid=movie.item_id, - ) - movies = s.get_movies( - title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - ) - if not movies: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="trimemedia", - server=name, - itemid=movies[0].item_id, - ) - else: - itemid, tvs = s.get_tv_episodes( - title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - item_id=itemid, - ) - if not tvs: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info( - f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}" - ) - return schemas.ExistMediaInfo( - type=MediaType.TV, - seasons=tvs, - server_type="trimemedia", - server=name, - itemid=itemid, - ) - return None - def media_statistic( self, server: Optional[str] = None ) -> Optional[List[schemas.Statistic]]: diff --git a/app/modules/ugreen/__init__.py b/app/modules/ugreen/__init__.py index 5b1a75db5..5c05440fa 100644 --- a/app/modules/ugreen/__init__.py +++ b/app/modules/ugreen/__init__.py @@ -1,17 +1,16 @@ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas -from app.domain.context import MediaInfo -from app.runtime.events import eventmanager -from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _MediaServerBase, _ModuleBase +from app.modules._base import _MediaServerModuleBase from app.modules.ugreen.ugreen import Ugreen -from app.schemas import AuthCredentials, AuthInterceptCredentials -from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType +from app.schemas.types import MediaServerType, ModuleType -class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): +class UgreenModule(_MediaServerModuleBase[Ugreen]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "ugreen" def init_module(self) -> None: """ @@ -52,14 +51,9 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - for name, server in self.get_instances().items(): - if server.is_configured() and server.is_inactive(): - logger.info(f"绿联影视 {name} 连接断开,尝试重连 ...") - server.reconnect() + def _is_inactive(self, server) -> bool: + """未配置的实例不参与定时重连。""" + return server.is_configured() and server.is_inactive() def stop(self) -> None: """停止模块""" @@ -70,57 +64,12 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): except Exception as err: logger.error(f"停止绿联影视模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if not server.is_configured(): - return False, f"绿联影视配置不完整:{name}" - if server.is_inactive() and not server.reconnect(): - return False, f"无法连接绿联影视:{name}" - return True, "" - - def user_authenticate( - self, credentials: AuthCredentials, service_name: Optional[str] = None - ) -> Optional[AuthCredentials]: - """ - 使用绿联影视用户辅助完成用户认证 - """ - if not credentials or credentials.grant_type != "password": - return None - - if service_name: - servers = ( - [(service_name, server)] - if (server := self.get_instance(service_name)) - else [] - ) - else: - servers = self.get_instances().items() - - for name, server in servers: - intercept_event = eventmanager.send_event( - etype=ChainEventType.AuthIntercept, - data=AuthInterceptCredentials( - username=credentials.username, - channel=self.get_name(), - service=name, - status="triggered", - ), - ) - if intercept_event and intercept_event.event_data: - intercept_data: AuthInterceptCredentials = intercept_event.event_data - if intercept_data.cancel: - continue - token = server.authenticate(credentials.username, credentials.password) - if token: - credentials.channel = self.get_name() - credentials.service = name - credentials.token = token - return credentials + def _test_server(self, server, name: str) -> Optional[str]: + """绿联影视用配置完整性与重连结果探测连接状态。""" + if not server.is_configured(): + return f"{self.get_name()}配置不完整:{name}" + if server.is_inactive() and not server.reconnect(): + return f"无法连接{self.get_name()}:{name}" return None def webhook_parser( @@ -146,84 +95,6 @@ class UgreenModule(_ModuleBase, _MediaServerBase[Ugreen]): return result return None - def media_exists( - self, - mediainfo: MediaInfo, - itemid: Optional[str] = None, - server: Optional[str] = None, - ) -> Optional[schemas.ExistMediaInfo]: - """ - 判断媒体文件是否存在 - """ - if server: - servers = [(server, self.get_instance(server))] - else: - servers = self.get_instances().items() - - for name, s in servers: - if not s: - continue - if mediainfo.type == MediaType.MUSIC: - matches = getattr(s, "get_music", lambda **_: [])( - **MusicMediaServerHelper.search_params(mediainfo) - ) - match = MusicMediaServerHelper.find_match(mediainfo, matches) - if match: - return schemas.ExistMediaInfo( - type=MediaType.MUSIC, - server_type="ugreen", - server=name, - itemid=match.item_id, - ) - continue - if mediainfo.type == MediaType.MOVIE: - if itemid: - movie = s.get_iteminfo(itemid) - if movie: - logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="ugreen", - server=name, - itemid=movie.item_id, - ) - movies = s.get_movies( - title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - ) - if not movies: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="ugreen", - server=name, - itemid=movies[0].item_id, - ) - - itemid, tvs = s.get_tv_episodes( - title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - item_id=itemid, - ) - if not tvs: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") - return schemas.ExistMediaInfo( - type=MediaType.TV, - seasons=tvs, - server_type="ugreen", - server=name, - itemid=itemid, - ) - return None - def media_statistic( self, server: Optional[str] = None ) -> Optional[List[schemas.Statistic]]: diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py index 2f4b8564b..560231471 100644 --- a/app/modules/vocechat/__init__.py +++ b/app/modules/vocechat/__init__.py @@ -9,7 +9,7 @@ from app.application.messaging.agent import ( resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.modules.vocechat.vocechat import VoceChat from app.schemas import MessageChannel, CommingMessage, Notification from app.schemas.types import ModuleType @@ -21,7 +21,9 @@ register_channel_admin_resolver( ) -class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): +class VoceChatModule(_MessageChannelModuleBase[VoceChat]): + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "VOCECHAT_ADMINS" _IMAGE_SUFFIXES = ( ".png", ".jpg", @@ -83,51 +85,9 @@ class VoceChatModule(_ModuleBase, _MessageBase[VoceChat]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"VoceChat {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析 VoceChat 管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("VOCECHAT_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, - config: Optional[dict], - *user_ids: Optional[Union[str, int]], - ) -> bool: - """ - 判断 VoceChat 斜杠命令是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - candidates = [ - str(user_id).strip() - for user_id in user_ids - if user_id is not None and str(user_id).strip() - ] - return not any(candidate in admins for candidate in candidates) - @staticmethod def _send_admin_denied( client: Optional[VoceChat], userid: Optional[Union[str, int]] diff --git a/app/modules/wechat/__init__.py b/app/modules/wechat/__init__.py index 92aece764..c1c841838 100644 --- a/app/modules/wechat/__init__.py +++ b/app/modules/wechat/__init__.py @@ -1,4 +1,3 @@ -import copy import json import re import xml.dom.minidom @@ -6,21 +5,19 @@ from typing import Optional, Union, List, Tuple, Any, Dict from urllib.parse import quote from app.domain.context import Context, MediaInfo -from app.runtime.events import eventmanager from app.application.messaging.agent import ( matches_channel_admin, register_channel_admin_resolver, resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _ModuleBase, _MessageBase +from app.modules._base import _MessageChannelModuleBase from app.adapters.external.wechat_crypt import WXBizMsgCrypt from app.modules.wechat.wechat import WeChat from app.modules.wechat.wechatbot import WeChatBot -from app.schemas import MessageChannel, CommingMessage, Notification, CommandRegisterEventData -from app.schemas.types import ModuleType, ChainEventType +from app.schemas import MessageChannel, CommingMessage, Notification +from app.schemas.types import ModuleType from app.foundation.dom import DomUtils -from app.foundation.collections import DictUtils def _resolve_wechat_admin_ids(config: Optional[dict]) -> set[str]: @@ -34,7 +31,12 @@ def _resolve_wechat_admin_ids(config: Optional[dict]) -> set[str]: register_channel_admin_resolver(MessageChannel.Wechat, _resolve_wechat_admin_ids) -class WechatModule(_ModuleBase, _MessageBase[WeChat]): +class WechatModule(_MessageChannelModuleBase[WeChat]): + + # 管理员配置键,与渠道 resolver 保持一致 + _admin_config_key = "WECHAT_ADMINS" + # 命令注册事件源标识固定为 WeChat(get_name 为“企业微信”) + _command_origin = "WeChat" def init_module(self) -> None: """ @@ -82,51 +84,12 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): def _is_bot_mode(config: dict) -> bool: return (config or {}).get("WECHAT_MODE", "app") == "bot" - @staticmethod - def _get_admins(config: Optional[dict]) -> List[str]: - """ - 解析企业微信管理员配置,兼容逗号分隔和首尾空白。 - """ - return [ - admin.strip() - for admin in str((config or {}).get("WECHAT_ADMINS") or "").split(",") - if admin.strip() - ] - - @classmethod - def _should_reject_admin_command( - cls, config: Optional[dict], user_id: Optional[str] - ) -> bool: - """ - 判断企业微信菜单或斜杠命令是否应因非管理员身份被拒绝。 - """ - admins = cls._get_admins(config) - if not admins: - return False - return not matches_channel_admin( - MessageChannel.Wechat, - config, - user_id, - ) - @classmethod def _create_client(cls, conf): if cls._is_bot_mode(conf.config): return WeChatBot(name=conf.name, **conf.config) return WeChat(name=conf.name, **conf.config) - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state = client.get_state() - if not state: - return False, f"企业微信 {name} 未就绪" - return True, "" - def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @@ -457,54 +420,22 @@ class WechatModule(_ModuleBase, _MessageBase[WeChat]): client.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid, link=message.link) - def register_commands(self, commands: Dict[str, dict]): + def _commands_enabled(self, config: Optional[dict]) -> bool: """ - 注册命令,实现这个函数接收系统可用的命令菜单 - :param commands: 命令字典 + 菜单注册前置条件:智能机器人模式无传统菜单,缺少解密参数时无法调用菜单 API。 """ - for client_config in self.get_configs().values(): - if self._is_bot_mode(client_config.config): - logger.debug(f"{client_config.name} 为智能机器人模式,跳过传统菜单初始化") - continue - # 如果没有配置消息解密相关参数,则也没有必要进行菜单初始化 - if not client_config.config.get("WECHAT_ENCODING_AESKEY") or not client_config.config.get("WECHAT_TOKEN"): - logger.debug(f"{client_config.name} 缺少消息解密参数,跳过后续菜单初始化") - continue + if self._is_bot_mode(config): + logger.debug("智能机器人模式,跳过传统菜单初始化") + return False + if not config.get("WECHAT_ENCODING_AESKEY") or not config.get("WECHAT_TOKEN"): + logger.debug("缺少消息解密参数,跳过菜单初始化") + return False + return True - client = self.get_instance(client_config.name) - if not client: - continue + def _delete_commands(self, client) -> None: + """企业微信使用自定义菜单 API 清理命令。""" + client.delete_menus() - # 触发事件,允许调整命令数据,这里需要进行深复制,避免实例共享 - scoped_commands = copy.deepcopy(commands) - event = eventmanager.send_event( - ChainEventType.CommandRegister, - CommandRegisterEventData(commands=scoped_commands, origin="WeChat", service=client_config.name) - ) - - # 如果事件返回有效的 event_data,使用事件中调整后的命令 - if event and event.event_data: - event_data: CommandRegisterEventData = event.event_data - # 如果事件被取消,跳过命令注册,并清理菜单 - if event_data.cancel: - client.delete_menus() - logger.debug( - f"Command registration for {client_config.name} canceled by event: {event_data.source}" - ) - continue - scoped_commands = event_data.commands or {} - if not scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_menus() - - # scoped_commands 必须是 commands 的子集 - filtered_scoped_commands = DictUtils.filter_keys_to_subset(scoped_commands, commands) - # 如果 filtered_scoped_commands 为空,则跳过注册 - if not filtered_scoped_commands: - logger.debug("Filtered commands are empty, skipping registration.") - client.delete_menus() - continue - # 对比调整后的命令与当前命令 - if filtered_scoped_commands != commands: - logger.debug(f"Command set has changed, Updating new commands: {filtered_scoped_commands}") - client.create_menus(filtered_scoped_commands) + def _apply_commands(self, client, commands: Dict[str, dict]) -> None: + """企业微信使用自定义菜单 API 注册命令。""" + client.create_menus(commands) diff --git a/app/modules/wechatclawbot/__init__.py b/app/modules/wechatclawbot/__init__.py index d6a8f5734..adca12d74 100644 --- a/app/modules/wechatclawbot/__init__.py +++ b/app/modules/wechatclawbot/__init__.py @@ -9,7 +9,7 @@ from app.application.messaging.agent import ( resolve_config_principal_ids, ) from app.runtime.log import logger -from app.modules import _MessageBase, _ModuleBase +from app.modules._base import _MessageChannelModuleBase from app.modules.wechatclawbot.wechatclawbot import WechatClawBot from app.schemas import CommingMessage, Notification from app.schemas.types import MessageChannel, ModuleType, NotificationAction @@ -23,7 +23,7 @@ register_channel_admin_resolver( ) -class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): +class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]): def __init__(self): """初始化模块级去重缓存,拦截 iLink 偶发的重复回放消息。""" super().__init__() @@ -69,15 +69,9 @@ class WechatClawBotModule(_ModuleBase, _MessageBase[WechatClawBot]): except Exception as err: logger.error(f"停止微信 ClawBot 模块实例失败:{err}") - def test(self) -> Optional[Tuple[bool, str]]: - """测试模块连接性。""" - if not self.get_instances(): - return None - for name, client in self.get_instances().items(): - state, message = client.test_connection() - if not state: - return False, f"微信 ClawBot {name} 未就绪:{message}" - return True, "" + def _test_connection(self, client) -> Tuple[bool, str]: + """微信 ClawBot 的连接探测返回 (状态, 信息)。""" + return client.test_connection() def init_setting(self) -> Tuple[str, Union[str, bool]]: """初始化模块设置。""" diff --git a/app/modules/zspace/__init__.py b/app/modules/zspace/__init__.py index 526e3f24d..1d8e606a3 100644 --- a/app/modules/zspace/__init__.py +++ b/app/modules/zspace/__init__.py @@ -1,17 +1,17 @@ from typing import Any, Generator, List, Optional, Tuple, Union from app import schemas -from app.domain.context import MediaInfo -from app.runtime.events import eventmanager -from app.application.mediaserver import MusicMediaServerHelper from app.runtime.log import logger -from app.modules import _MediaServerBase, _ModuleBase +from app.modules._base import _MediaServerModuleBase from app.modules.zspace.zspace import ZSpace from app.schemas import AuthCredentials, AuthInterceptCredentials -from app.schemas.types import ChainEventType, MediaServerType, MediaType, ModuleType +from app.schemas.types import ChainEventType, MediaServerType, ModuleType -class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): +class ZSpaceModule(_MediaServerModuleBase[ZSpace]): + + # 媒体库标识(ExistMediaInfo.server_type) + _server_type_value = "zspace" def init_module(self) -> None: """ @@ -48,63 +48,17 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): def stop(self): pass - def test(self) -> Optional[Tuple[bool, str]]: - """ - 测试模块连接性 - """ - if not self.get_instances(): - return None - for name, server in self.get_instances().items(): - if server.is_inactive() and not server.reconnect(): - return False, f"无法连接极影视服务器:{name}" - if not server.user: - return False, f"无法连接极影视服务器:{name}" - return True, "" + def _test_server(self, server, name: str) -> Optional[str]: + """极影视用重连结果与用户信息探测连接状态。""" + if server.is_inactive() and not server.reconnect(): + return f"无法连接{self.get_name()}服务器:{name}" + if not server.user: + return f"无法连接{self.get_name()}服务器:{name}" + return None def init_setting(self) -> Tuple[str, Union[str, bool]]: pass - def scheduler_job(self) -> None: - """ - 定时任务,每10分钟调用一次 - """ - for name, server in self.get_instances().items(): - if server.is_inactive(): - logger.info(f"极影视服务器 {name} 连接断开,尝试重连 ...") - server.reconnect() - - def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \ - -> Optional[AuthCredentials]: - """ - 使用极影视用户辅助完成用户认证 - :param credentials: 认证数据 - :param service_name: 指定要认证的媒体服务器名称,若为 None 则认证所有服务 - :return: 认证数据 - """ - if not credentials or credentials.grant_type != "password": - return None - if service_name: - servers = [(service_name, server)] if (server := self.get_instance(service_name)) else [] - else: - servers = self.get_instances().items() - for name, server in servers: - intercept_event = eventmanager.send_event( - etype=ChainEventType.AuthIntercept, - data=AuthInterceptCredentials(username=credentials.username, channel=self.get_name(), - service=name, status="triggered") - ) - if intercept_event and intercept_event.event_data: - intercept_data: AuthInterceptCredentials = intercept_event.event_data - if intercept_data.cancel: - continue - token = server.authenticate(credentials.username, credentials.password) - if token: - credentials.channel = self.get_name() - credentials.service = name - credentials.token = token - return credentials - return None - def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]: """ 解析Webhook报文体 @@ -130,81 +84,6 @@ class ZSpaceModule(_ModuleBase, _MediaServerBase[ZSpace]): return result return None - def media_exists(self, mediainfo: MediaInfo, itemid: Optional[str] = None, - server: Optional[str] = None) -> Optional[schemas.ExistMediaInfo]: - """ - 判断媒体文件是否存在 - :param mediainfo: 识别的媒体信息 - :param itemid: 媒体服务器ItemID - :param server: 媒体服务器名称 - :return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}} - """ - if server: - servers = [(server, self.get_instance(server))] - else: - servers = self.get_instances().items() - for name, s in servers: - if not s: - continue - if mediainfo.type == MediaType.MUSIC: - matches = getattr(s, "get_music", lambda **_: [])( - **MusicMediaServerHelper.search_params(mediainfo) - ) - match = MusicMediaServerHelper.find_match(mediainfo, matches) - if match: - return schemas.ExistMediaInfo( - type=MediaType.MUSIC, - server_type="zspace", - server=name, - itemid=match.item_id, - ) - continue - if mediainfo.type == MediaType.MOVIE: - if itemid: - movie = s.get_iteminfo(itemid) - if movie: - logger.info(f"媒体库 {name} 中找到了 {movie}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="zspace", - server=name, - itemid=movie.item_id - ) - movies = s.get_movies(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id) - if not movies: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"媒体库 {name} 中找到了 {movies}") - return schemas.ExistMediaInfo( - type=MediaType.MOVIE, - server_type="zspace", - server=name, - itemid=movies[0].item_id - ) - else: - itemid, tvs = s.get_tv_episodes(title=mediainfo.title, - year=mediainfo.year, - media_source=mediainfo.media_source, - media_id=mediainfo.media_id, - item_id=itemid) - if not tvs: - logger.info(f"{mediainfo.title_year} 没有在媒体库 {name} 中") - continue - else: - logger.info(f"{mediainfo.title_year} 在媒体库 {name} 中找到了这些季集:{tvs}") - return schemas.ExistMediaInfo( - type=MediaType.TV, - seasons=tvs, - server_type="zspace", - server=name, - itemid=itemid - ) - return None - def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]: """ 媒体数量统计 diff --git a/app/runtime/compat/imports.py b/app/runtime/compat/imports.py index 32f34305d..c23226510 100644 --- a/app/runtime/compat/imports.py +++ b/app/runtime/compat/imports.py @@ -188,11 +188,14 @@ class LegacySymbolOverlayLoader(importlib.abc.Loader): module.__getattr__ = resolve_export module.__dir__ = list_exports + # 兼容符号不并入 __all__:避免 `from import *` 在包初始化期 + # 急切解析旧符号、反向拉起应用层模块形成循环导入;显式导入与属性 + # 访问仍由上方 __getattr__ 惰性解析兜底 public_names = { name for name in module.__dict__ if not name.startswith("_") } declared_exports = set(previous_all or ()) if had_all else public_names - module.__all__ = sorted(declared_exports | set(exports)) + module.__all__ = sorted(declared_exports) module.__dict__[self._STATE_KEY] = { "__getattr__": previous_getattr, "__dir__": previous_dir, diff --git a/app/runtime/compat/manifest.py b/app/runtime/compat/manifest.py index b16d833b1..a40f2076a 100644 --- a/app/runtime/compat/manifest.py +++ b/app/runtime/compat/manifest.py @@ -680,6 +680,18 @@ PACKAGE_EXPORTS: Dict[str, Dict[str, SymbolAlias]] = { # 物理模块仍存在、仅部分公开符号迁走时,由导入器在标准 Loader 执行后叠加惰性符号路由。 # canonical 源码不反向依赖兼容层,目标符号也只在旧调用方真正取用时加载。 SYMBOL_ALIASES: Dict[str, Dict[str, SymbolAlias]] = { + "app.agent.orchestrator": { + "AgentChain": SymbolAlias( + target_module="app.chain.agent", + target_name="AgentChain", + replacement="app.chain.agent.AgentChain", + ), + "ReplyMode": SymbolAlias( + target_module="app.schemas.agent", + target_name="ReplyMode", + replacement="app.schemas.agent.ReplyMode", + ), + }, "app.chain.message": { "MediaInteractionChain": SymbolAlias( target_module="app.chain.interaction", diff --git a/app/scheduler.py b/app/scheduler.py index e009910e5..a0b2811ba 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -51,7 +51,8 @@ from app.runtime.scheduling import TimerUtils lock = threading.Lock() SCHEDULER_PROGRESS_PREFIX = "scheduler" -AGENT_TASK_JOB_PREFIX = "agent-task" +# Agent 自主定时任务前缀下沉到 application 门面,此处保留兼容导出。 +from app.application.scheduling import AGENT_TASK_JOB_PREFIX # noqa: E402 class SchedulerChain(ChainBase): diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 9c229d3fa..447791bf6 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -1,6 +1,7 @@ """AI智能体相关数据模型""" from datetime import datetime +from enum import Enum from typing import Any, List, Literal, Optional, Union from langchain_core.messages import BaseMessage @@ -9,6 +10,13 @@ from pydantic import BaseModel, Field, ConfigDict, field_serializer from app.schemas.common import JsonData +class ReplyMode(str, Enum): + """Agent 最终回复处理模式(chain 与 agent 层共享的值域)。""" + + DISPATCH = "dispatch" + CAPTURE_ONLY = "capture_only" + + class ConversationMemory(BaseModel): """对话记忆模型""" diff --git a/app/startup/agent_initializer.py b/app/startup/agent_initializer.py index 13bbcedd1..f56f4c39d 100644 --- a/app/startup/agent_initializer.py +++ b/app/startup/agent_initializer.py @@ -1,7 +1,21 @@ +from app.agent.llm import AgentCapabilityManager, LLMHelper from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services from app.runtime.config import settings from app.runtime.log import logger +# 导入期即向 application 门面注册实现,保证任何先于 initialize 的 +# 链层调用都能通过门面取到 Agent 服务对象。 +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + class AgentInitializer: """ diff --git a/app/startup/command_initializer.py b/app/startup/command_initializer.py index 013660e1d..51102d3ab 100644 --- a/app/startup/command_initializer.py +++ b/app/startup/command_initializer.py @@ -1,5 +1,9 @@ +from app.application.commands import register_command_class from app.command import Command +# 导入期即向 application 门面注册命令类,保证工具调用时不依赖静态边。 +register_command_class(Command) + def init_command(): """ diff --git a/app/startup/scheduler_initializer.py b/app/startup/scheduler_initializer.py index 49f771457..28d958792 100644 --- a/app/startup/scheduler_initializer.py +++ b/app/startup/scheduler_initializer.py @@ -1,5 +1,9 @@ +from app.application.scheduling import register_scheduler_class from app.scheduler import Scheduler +# 导入期即向 application 门面注册调度器类,保证工具调用时不依赖静态边。 +register_scheduler_class(Scheduler) + def init_scheduler(): """ diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 9c263ab3a..9cf6b55f3 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -201,6 +201,18 @@ do not belong here. Chains interact with modules exclusively through internals (classes, exceptions, constants) are forbidden, so every module stays pluggable and a chain never names a concrete module implementation. +Underscore-prefixed files in `app/chain/` are feature-domain mixins for +`ChainBase` and concrete chains, not chains themselves: `_recognition.py` +(`RecognitionMixin`), `_messaging.py` (`MessageProcessingMixin` / +`NotificationMixin`), `_interaction.py` (`InteractionChainMixin`, the shared +slash-command delegation for `remote_list` / `parse_callback` / +`handle_callback_interaction` / `handle_text_interaction`), `_music.py` +(`MusicSubscribeMixin`, the music single/album subscribe domain mixed into +`SubscribeChain`) and `_mixins.py` (TransferChain feature mixins). A concrete chain that exposes slash-command +interaction inherits `InteractionChainMixin`, injects its handler class via +`_interaction_handler_type` and implements only `_interaction_handler`; it must +not re-export application-layer interaction managers. + ### Module layer `app/modules/` contains pluggable downloaders, media servers, metadata sources, @@ -212,6 +224,20 @@ exceptions and value domains used by both modules and upper layers live in method names. The directory remains unchanged because discovery and plugin code depend on this established runtime root. +`app/modules/_base/` hosts the shared template base classes for module families +(`downloader.py`, `mediaserver.py`, `notification.py`), each combining the +family mixin with `_ModuleBase` and typed by `TService` (usage: +`class QbittorrentModule(_DownloaderModuleBase[Qbittorrent])`). The base classes +carry only verbatim-duplicated boilerplate — connection test, scheduled +reconnect, torrent-info reading, query-status normalization for downloaders; +authentication, media-exists check, inactive-server handling for media servers; +admin resolution and command registration for message channels — while +subclasses keep the differentiated API calls and override small hooks such as +`_test_connection`, `_test_server` and `_is_inactive`. Discovery already skips +the package (module discovery only enumerates first-level submodules and skips +underscore-prefixed names), so no new exclusion rules are needed; do not grow +this package with per-module business logic. + Channels and storages that need login management or temporary-parameter initialization follow one generic contract instead of per-target APIs: modules implement `channel_manage(channel, action, **params)` or @@ -303,6 +329,9 @@ policy. `app/db` therefore has no dependency on `app/domain`. |---|---| | `entrypoint -> chain / application / Oper` | Allowed according to workflow complexity | | `chain -> module (only via run_module dispatch) / application / Oper / canonical capability` | Allowed; direct `chain -> module` imports forbidden | +| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`, whose implementations are registered by `app/startup/agent_initializer.py` at import time | +| `agent.tools -> api / scheduler / command` | Forbidden; tools use `app/application/plugins.py`, `scheduling.py` and `commands.py` facades | +| `api -> factory` | Forbidden; the FastAPI instance is injected into `app/application/plugins.py` by the composition root after creation | | `application -> domain / runtime / adapter / Oper` | Allowed | | `module -> canonical capability / Oper` | Allowed | | `module -> module / chain` | Forbidden for new code | @@ -317,6 +346,11 @@ policy. `app/db` therefore has no dependency on `app/domain`. | Path | Purpose | |---|---| +| `app/application/agent.py` | Agent orchestration facade (`get_agent_manager` / `get_prompt_manager` / capability queries / prompt builders); Agent implementations register through `app/startup/agent_initializer.py`, no static `application -> agent` edge | +| `app/application/plugins.py` | Plugin API dynamic route registration/removal; the FastAPI instance is injected by `app/factory.py` after creation | +| `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/scheduler_initializer.py` | +| `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/command_initializer.py` | +| `app/chain/agent.py` | `AgentChain(ChainBase)`: the chain-layer entry for Agent sessions; Agent runtime stays in `app/agent/` | | `app/runtime/config.py` | `ConfigModel`, `Settings` and deployment configuration | | `app/runtime/events.py` | `EventManager`, `Event` and event resolver registration | | `app/runtime/extensions/module_manager.py` | Module discovery and lifecycle | diff --git a/tests/test_agent_image_capability.py b/tests/test_agent_image_capability.py index 4760e78fb..b749576b7 100644 --- a/tests/test_agent_image_capability.py +++ b/tests/test_agent_image_capability.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + from unittest.mock import AsyncMock, patch from app.agent import MoviePilotAgent @@ -69,7 +84,7 @@ def test_handle_ai_message_routes_text_only_model_images_to_files(monkeypatch): } ], ) as prepare_files, patch( - "app.chain.message.agent_manager.process_message", new_callable=AsyncMock + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), diff --git a/tests/test_agent_image_support.py b/tests/test_agent_image_support.py index b2802af5e..913b6db0d 100644 --- a/tests/test_agent_image_support.py +++ b/tests/test_agent_image_support.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import asyncio import base64 import json @@ -451,7 +466,7 @@ class AgentImageSupportTest(unittest.TestCase): } ], ) as prepare_files, patch( - "app.chain.message.agent_manager.process_message", new_callable=AsyncMock + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), @@ -484,7 +499,7 @@ class AgentImageSupportTest(unittest.TestCase): with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( chain, "_get_or_create_session_id", return_value="session-1" ), patch( - "app.chain.message.agent_manager.process_message", new_callable=AsyncMock + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", side_effect=lambda coro, _loop: coro.close(), diff --git a/tests/test_agent_interaction.py b/tests/test_agent_interaction.py index 6f6e7ea84..40c123c43 100644 --- a/tests/test_agent_interaction.py +++ b/tests/test_agent_interaction.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import asyncio import unittest from datetime import datetime @@ -195,7 +210,7 @@ class TestAgentInteraction(unittest.TestCase): ) as message_add, patch.object( chain, "edit_message", return_value=True ) as edit_message, patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", @@ -268,7 +283,7 @@ class TestAgentInteraction(unittest.TestCase): try: for channel in (MessageChannel.Telegram, MessageChannel.Feishu): with patch( - "app.chain.message.agent_manager.matches_secret_confirmation", + "app.application.agent._agent_manager.matches_secret_confirmation", return_value=True, ), patch.object( chain, diff --git a/tests/test_agent_message_routing.py b/tests/test_agent_message_routing.py index 4eb753f89..b75d43c0c 100644 --- a/tests/test_agent_message_routing.py +++ b/tests/test_agent_message_routing.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import asyncio from unittest.mock import AsyncMock, Mock, patch @@ -67,7 +82,7 @@ def test_explicit_ai_message_is_not_recorded_to_message_history(): with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object( chain, "_record_user_message" ) as record_user_message, patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", @@ -90,7 +105,7 @@ def test_message_chain_passes_stable_channel_admin_principal_to_agent(): chain = MessageChain() with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", @@ -113,7 +128,7 @@ def test_message_chain_does_not_trust_channel_display_username(): chain = MessageChain() with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", @@ -136,7 +151,7 @@ def test_message_chain_uses_same_admin_contract_for_slack(): chain = MessageChain() with patch.object(settings, "AI_AGENT_ENABLE", True), patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", @@ -259,7 +274,7 @@ def test_agent_choice_callback_is_not_recorded_to_message_history(): ) as record_user_message, patch.object( chain, "edit_message", return_value=True ), patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", diff --git a/tests/test_agent_scheduled_tasks.py b/tests/test_agent_scheduled_tasks.py index d5c7476ee..88fdd3b87 100644 --- a/tests/test_agent_scheduled_tasks.py +++ b/tests/test_agent_scheduled_tasks.py @@ -431,6 +431,10 @@ async def test_interrupted_date_task_enable_toggle_stays_manual_only( scheduler = _build_agent_task_scheduler() scheduler.init_agent_task_jobs() monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: scheduler, + ) tool = _build_tool(UpdateAgentTaskTool, task.user_id) paused = json.loads(await tool.run(task_id=task.id, enabled=False)) @@ -458,6 +462,10 @@ async def test_interrupted_date_task_new_trigger_rearms_schedule(monkeypatch) -> scheduler = _build_agent_task_scheduler() scheduler.init_agent_task_jobs() monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: scheduler, + ) updated = json.loads( await _build_tool(UpdateAgentTaskTool, task.user_id).run( task_id=task.id, @@ -487,6 +495,10 @@ async def test_interrupted_date_task_rejects_past_trigger_while_pausing( scheduler = _build_agent_task_scheduler() scheduler.init_agent_task_jobs() monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: scheduler, + ) tool = _build_tool(UpdateAgentTaskTool, task.user_id) with pytest.raises(ValueError, match="必须晚于当前时间"): @@ -521,6 +533,10 @@ async def test_expired_date_task_rejects_enable_without_reschedule( scheduler = _build_agent_task_scheduler() scheduler.init_agent_task_jobs() monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: scheduler, + ) with pytest.raises(ValueError, match="必须晚于当前时间"): await _build_tool(UpdateAgentTaskTool, task.user_id).run( @@ -734,6 +750,10 @@ async def test_scheduler_tools_exclude_agent_tasks(monkeypatch) -> None: ] ) monkeypatch.setattr("app.scheduler.Scheduler", lambda: scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: scheduler, + ) tool = _build_tool(QuerySchedulersTool, "admin-user") result = json.loads(await tool.run()) @@ -763,6 +783,10 @@ async def test_agent_task_tools_manage_persistent_schedule(monkeypatch) -> None: user_id = f"user-{uuid4().hex}" fake_scheduler = _FakeAgentTaskScheduler() monkeypatch.setattr("app.scheduler.Scheduler", lambda: fake_scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: fake_scheduler, + ) create_tool = _build_tool(CreateAgentTaskTool, user_id) created = json.loads(await create_tool.ainvoke({ @@ -843,6 +867,10 @@ async def test_run_agent_task_enforces_owner_and_enabled_state(monkeypatch) -> N ) fake_scheduler = _FakeAgentTaskScheduler() monkeypatch.setattr("app.scheduler.Scheduler", lambda: fake_scheduler) + monkeypatch.setattr( + "app.application.scheduling._scheduler_class", + lambda: fake_scheduler, + ) other_user_result = await _build_tool( RunAgentTaskTool, diff --git a/tests/test_agent_session_status.py b/tests/test_agent_session_status.py index f4883500c..1d189740f 100644 --- a/tests/test_agent_session_status.py +++ b/tests/test_agent_session_status.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import asyncio import unittest from datetime import datetime, timedelta @@ -94,7 +109,7 @@ class TestAgentSessionStatus(unittest.TestCase): with ( patch( - "app.chain.message.agent_manager.get_session_status", + "app.application.agent._agent_manager.get_session_status", return_value=status, ), patch.object(chain, "post_message") as post_message, diff --git a/tests/test_api_response.py b/tests/test_api_response.py index b12f39313..194c5ba65 100644 --- a/tests/test_api_response.py +++ b/tests/test_api_response.py @@ -684,7 +684,7 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas(): def test_plugin_routes_only_register_v1(monkeypatch): """插件动态路由只应注册 v1 地址并由应用统一路由类处理。""" - from app.api.endpoints import plugin as plugin_endpoint + from app.application import plugins class FakeApp: """记录动态注册路径的应用桩。""" @@ -715,15 +715,15 @@ def test_plugin_routes_only_register_v1(monkeypatch): ] fake_app = FakeApp() - monkeypatch.setattr(plugin_endpoint, "app", fake_app) - monkeypatch.setattr(plugin_endpoint, "PluginManager", FakePluginManager) + monkeypatch.setattr(plugins, "_api_app", fake_app) + monkeypatch.setattr(plugins, "PluginManager", FakePluginManager) - plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="add") + plugins._update_plugin_api_routes("DemoPlugin", action="add") assert [route.path for route in fake_app.routes] == [ "/api/v1/plugin/DemoPlugin/health" ] - plugin_endpoint._update_plugin_api_routes("DemoPlugin", action="remove") + plugins._update_plugin_api_routes("DemoPlugin", action="remove") assert fake_app.routes == [] diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index ce59f29c8..aab554eda 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -1,6 +1,9 @@ import ast +from functools import lru_cache from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).parents[1] APP_ROOT = PROJECT_ROOT / "app" @@ -97,11 +100,17 @@ FORBIDDEN_IMPORT_PREFIXES = { def _discover_modules() -> dict[str, Path]: - """建立实际 Python 模块名到源码路径的映射。""" + """建立实际 Python 模块名到源码路径的映射。 + + `app/plugins/` 由插件仓自治(包含独立第三方实现与未完成文件), + 不参与宿主架构图分析。 + """ 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[0] == "app" and parts[1] == "plugins": + continue if parts[-1] == "__init__": parts.pop() modules[".".join(parts)] = path @@ -431,7 +440,10 @@ def test_resource_adapter_does_not_restart_process(): def test_modules_do_not_import_other_modules_or_chain(): - """模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。""" + """模块之间以及模块对链层的直接依赖被禁止,跨模块编排归链层。 + + `app.modules._base` 是模块共享样板基类包(模块发现会跳过),不视为业务模块。 + """ modules = _discover_modules() known_modules = set(modules) violations: dict[str, set[str]] = {} @@ -446,7 +458,7 @@ def test_modules_do_not_import_other_modules_or_chain(): if dependency.startswith("app.chain") or ( dependency.startswith("app.modules.") - and dependency.split(".")[2] != own_package + and dependency.split(".")[2] not in (own_package, "_base") ) } if forbidden: @@ -513,3 +525,103 @@ def test_chain_does_not_import_module_internals(): if forbidden: violations[module_name] = forbidden assert violations == {} + + +@lru_cache(maxsize=1) +def _build_module_graph() -> dict[str, set[str]]: + """构建非插件模块的完整静态依赖图,供既有包治理断言复用。 + + 纯静态 AST 分析无副作用,结果可安全缓存;多断言共享一次解析。 + """ + modules = _discover_modules() + known_modules = set(modules) + return { + name: _resolve_imports(name, path, known_modules) + for name, path in modules.items() + } + + +def test_chain_does_not_import_agent_implementation(): + """编排层不得反向依赖 Agent 实现,跨域编排经 application 门面。""" + violations: dict[str, set[str]] = {} + for module_name, dependencies in _build_module_graph().items(): + if not module_name.startswith("app.chain"): + continue + forbidden = { + dependency + for dependency in dependencies + if dependency.startswith("app.agent") + } + if forbidden: + violations[module_name] = forbidden + assert violations == {} + + +def test_agent_tools_do_not_import_entrypoint_internals(): + """Agent 工具不得穿透导入 HTTP 端点、调度器与命令注册表内部实现。 + + 工具对进程级状态的读写必须收敛到 application 门面, + 否则 agent 层与入口层互相穿透会形成不可测试的循环。 + """ + violations: dict[str, set[str]] = {} + for module_name, dependencies in _build_module_graph().items(): + if not module_name.startswith("app.agent.tools"): + continue + forbidden = { + dependency + for dependency in dependencies + if dependency.startswith(("app.api", "app.scheduler", "app.command")) + } + if forbidden: + violations[module_name] = forbidden + assert violations == {} + + +def test_api_does_not_import_factory(): + """装配器(factory)只允许 app.main 使用,HTTP 端点不得回引。""" + violations: dict[str, set[str]] = {} + for module_name, dependencies in _build_module_graph().items(): + if not module_name.startswith("app.api"): + continue + forbidden = { + dependency + for dependency in dependencies + if dependency.startswith("app.factory") + } + if forbidden: + violations[module_name] = forbidden + assert violations == {} + + +PROCESS_LEVEL_ROOTS = ( + "app.api", + "app.chain", + "app.agent", + "app.scheduler", + "app.command", + "app.monitor", + "app.startup", + "app.factory", +) + + +def test_process_level_packages_are_not_mutually_cyclic(): + """进程级根包之间不得形成跨包强连通分量。 + + 允许的环只存在于:单一包内部(modules 模块内、db 内、schemas 包内、 + agent 子域内、doctor 内)。跨根包的环意味着入口层、编排层与 Agent 层 + 互相穿透,破坏可插拔性与可测试性。 + """ + graph = _build_module_graph() + components = _strongly_connected_components(graph) + violations: list[list[str]] = [] + for component in components: + roots = { + name.split(".")[1] + for name in component + if name.startswith("app.") and name.count(".") >= 1 + } + involved = {root for root in roots if f"app.{root}" in PROCESS_LEVEL_ROOTS} + if len(involved) > 1: + violations.append(sorted(component)) + assert violations == [] diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py index 82adc2586..79033d887 100644 --- a/tests/test_capability_registry.py +++ b/tests/test_capability_registry.py @@ -187,6 +187,8 @@ def test_current_host_module_manifests_follow_the_strict_nested_schema() -> None path for path in modules_root.iterdir() if path.is_dir() and (path / "__init__.py").is_file() + # 下划线前缀目录是内部基础包(如 _base),不是 host module,不参与清单校验 + and not path.name.startswith("_") } entrypoint_modules = {spec.entrypoint.split(":", maxsplit=1)[0] for spec in specs} diff --git a/tests/test_chain_layering.py b/tests/test_chain_layering.py index 9ec365a57..a3b3ef027 100644 --- a/tests/test_chain_layering.py +++ b/tests/test_chain_layering.py @@ -53,9 +53,13 @@ def test_chain_base_does_not_import_concrete_chains() -> None: """基础链不得反向导入任何具体处理链。""" imports = _imported_modules(CHAIN_ROOT / "__init__.py") + # 下划线前缀的内部模块(_messaging/_recognition 等)是 ChainBase 的 + # 功能域 mixin,不是具体处理链,允许导入 assert not { - module for module in imports + module + for module in imports if module.startswith("app.chain.") + and not module.removeprefix("app.chain.").startswith("_") } @@ -68,6 +72,7 @@ def test_legacy_music_chain_is_removed() -> None: ) for root in LEGACY_MUSIC_SCAN_ROOTS for path in root.rglob("*.py") + if "plugins" not in path.parts # 插件目录由插件仓自治,跳过 if "app.chain.music" in _imported_modules(path) } assert not violations diff --git a/tests/test_discord_command_registration.py b/tests/test_discord_command_registration.py index 3d0d810ce..0f7f59985 100644 --- a/tests/test_discord_command_registration.py +++ b/tests/test_discord_command_registration.py @@ -30,7 +30,7 @@ def test_discord_module_register_commands_filters_event_subset(): return_value={"discord-main": SimpleNamespace(name="discord-main", config={})}, ), patch.object(module, "get_instance", return_value=client), - patch("app.modules.discord.eventmanager.send_event", return_value=event), + patch("app.modules._base.notification.eventmanager.send_event", return_value=event), ): module.register_commands(original_commands) @@ -60,7 +60,7 @@ def test_discord_module_register_commands_deletes_when_event_canceled(): return_value={"discord-main": SimpleNamespace(name="discord-main", config={})}, ), patch.object(module, "get_instance", return_value=client), - patch("app.modules.discord.eventmanager.send_event", return_value=event), + patch("app.modules._base.notification.eventmanager.send_event", return_value=event), ): module.register_commands({"/sites": {"description": "管理站点"}}) diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index e275f5333..26dd586e4 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -109,6 +109,7 @@ def _load_transmission_module(): size_tools_module = types.ModuleType("app.foundation.size") temporal_tools_module = types.ModuleType("app.foundation.temporal") cache_module = types.ModuleType("app.runtime.cache") + base_module = types.ModuleType("app.modules._base") modules_module = types.ModuleType("app.modules") modules_module.__path__ = [] transmission_package_module = types.ModuleType("app.modules.transmission") @@ -131,6 +132,38 @@ def _load_transmission_module(): def __class_getitem__(cls, _item): return cls + class _DownloaderModuleBase(_ModuleBase, _DownloaderBase): + """隔离测试用的下载器模块基类桩,与 app.modules._base 行为对齐。""" + + def _get_torrent_info(self, content): + """与真实基类一致的种子信息读取,磁力链接不解析。""" + torrent_content = content + if isinstance(content, Path): + torrent_content = content.read_bytes() if content.exists() else None + torrent_info = None + if torrent_content and not torrent_rules_module.is_magnet_link(torrent_content): + torrent_info = torrentool_torrent_module.Torrent.from_string( + torrent_content + ) + return torrent_info, torrent_content + + @staticmethod + def _normalize_query_status(status): + """与真实基类一致的查询状态归一,返回隔离测试枚举。""" + status_value = getattr(status, "value", status) + status_text = str(status_value or "").strip().lower() + if not status_text or status_text in {"all", "全部"}: + return TorrentQueryStatus.ALL + if status_text in {"transfer", "transferring"}: + return TorrentQueryStatus.TRANSFER + if status_text in {"downloading"}: + return TorrentQueryStatus.DOWNLOADING + if status_text in {"complete", "completed", "seeding", "完成", "已完成"}: + return TorrentQueryStatus.COMPLETED + if status_text in {"pause", "paused", "暂停", "已暂停"}: + return TorrentQueryStatus.PAUSED + return TorrentQueryStatus.ALL + class _TransferTorrent: def __init__(self, **kwargs): self.__dict__.update(kwargs) @@ -210,6 +243,8 @@ def _load_transmission_module(): log_module.logger = _Logger() modules_module._ModuleBase = _ModuleBase modules_module._DownloaderBase = _DownloaderBase + modules_module._base = base_module + base_module._DownloaderModuleBase = _DownloaderModuleBase torrent_rules_module.is_magnet_link = _is_magnet_link size_tools_module.format_compact_size = _format_size temporal_tools_module.format_duration = _format_duration @@ -247,6 +282,7 @@ def _load_transmission_module(): "app.domain.metainfo": metainfo_module, "app.runtime.log": log_module, "app.modules": modules_module, + "app.modules._base": base_module, "app.modules.transmission": transmission_package_module, "app.modules.transmission.transmission": transmission_client_module, "app.schemas": schemas_module, diff --git a/tests/test_duplicate_code.py b/tests/test_duplicate_code.py new file mode 100644 index 000000000..b78d2f31c --- /dev/null +++ b/tests/test_duplicate_code.py @@ -0,0 +1,143 @@ +"""函数级重复代码门禁。 + +对非插件模块做归一化 AST 指纹比对:两个及以上不同模块中出现同构函数 +(变量名、字面量已归一,仅保留结构与属性名),且指纹规模超过阈值时告警。 + +存量复制粘贴以白名单标注,随各 Phase 清理后同步收紧;新增重复不得越过阈值。 +""" +import ast +from collections import defaultdict +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[1] +APP_ROOT = PROJECT_ROOT / "app" + +# 指纹长度阈值:低于该值视为偶然相似,不告警。 +MIN_FINGERPRINT_SIZE = 1000 +# 参与告警的最小函数体节点数:过滤 setter/getter 等小函数。 +MIN_FUNCTION_SIZE = 40 +# 存量白名单:(模块名, 函数名) 集合,各 Phase 清理后同步移除。 +KNOWN_DUPLICATES = { + # 服务实现类(非模块类)的条目信息格式化样板(待后续 Phase 清理)。 + ("app.modules.jellyfin.jellyfin", "__format_item_info"), + ("app.modules.zspace.zspace", "__format_item_info"), +} + + +def _normalize(node: ast.AST) -> str: + """把函数 AST 归一化为指纹字符串。 + + 只保留控制流骨架与属性访问名,统一变量名与字面量, + 使改名、改常量的复制粘贴仍能被识别为同构。 + """ + parts: list[str] = [] + for child in ast.walk(node): + if isinstance(child, ast.FunctionDef): + parts.append(f"F:{child.name}") + elif isinstance(child, ast.Name): + parts.append(f"V:{child.id}") + elif isinstance(child, ast.Attribute): + parts.append(f"A:{child.attr}") + elif isinstance(child, ast.Constant): + parts.append("C:lit") + elif isinstance(child, ast.Call): + parts.append("call") + elif isinstance(child, ast.BinOp): + parts.append(f"op:{type(child.op).__name__}") + elif isinstance(child, ast.Compare): + parts.append("cmp") + elif isinstance(child, ast.UnaryOp): + parts.append("uop") + elif isinstance(child, ast.BoolOp): + parts.append("boolop") + elif isinstance(child, ast.If): + parts.append("if") + elif isinstance(child, ast.For): + parts.append("for") + elif isinstance(child, ast.While): + parts.append("while") + elif isinstance(child, ast.Try): + parts.append("try") + elif isinstance(child, ast.Return): + parts.append("return") + elif isinstance(child, ast.Assign): + parts.append("assign") + elif isinstance(child, ast.AnnAssign): + parts.append("annassign") + elif isinstance(child, ast.AugAssign): + parts.append("augassign") + elif isinstance(child, ast.Dict): + parts.append("dict") + elif isinstance(child, ast.List): + parts.append("list") + elif isinstance(child, ast.Subscript): + parts.append("sub") + elif isinstance(child, ast.Lambda): + parts.append("lambda") + elif isinstance(child, ast.Expr): + parts.append("expr") + elif isinstance(child, ast.With): + parts.append("with") + elif isinstance(child, ast.Yield): + parts.append("yield") + elif isinstance(child, ast.Import): + parts.append("import") + elif isinstance(child, ast.ImportFrom): + parts.append("importfrom") + elif isinstance(child, ast.Pass): + parts.append("pass") + elif isinstance(child, ast.arguments): + parts.append("args") + return "|".join(parts) + + +def _collect_duplicates() -> dict[int, list[tuple[str, str, int, int]]]: + """扫描非插件模块,按归一化指纹分组收集跨模块同构函数。""" + fingerprints: dict[int, list[tuple[str, str, int, int]]] = defaultdict(list) + for path in APP_ROOT.rglob("*.py"): + if path.relative_to(APP_ROOT).parts[0] == "plugins": + continue + try: + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + except SyntaxError: + continue + relative = path.relative_to(PROJECT_ROOT).with_suffix("") + parts = list(relative.parts) + if parts[-1] == "__init__": + parts.pop() + module_name = ".".join(parts) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if sum(1 for _ in ast.walk(node)) < MIN_FUNCTION_SIZE: + continue + fingerprint = _normalize(node) + if len(fingerprint) < MIN_FINGERPRINT_SIZE: + continue + fingerprints[hash(fingerprint)].append( + (module_name, node.name, node.lineno, len(fingerprint)) + ) + return fingerprints + + +def test_no_new_large_duplicate_functions(): + """跨模块同构大函数(指纹 >= 1000)不得出现,存量白名单除外。""" + violations: list[list[tuple[str, str, int, int]]] = [] + for items in _collect_duplicates().values(): + modules = {module_name for module_name, _, _, _ in items} + if len(modules) < 2: + continue + leftovers = [ + item for item in items if (item[0], item[1]) not in KNOWN_DUPLICATES + ] + if leftovers: + violations.append(leftovers) + assert violations == [], ( + "检测到跨模块同构大函数(疑似复制粘贴),请提取公共基类/工具或" + "先加入 KNOWN_DUPLICATES 白名单并随对应 Phase 清理:\n" + + "\n".join( + f" {module_name}.{func_name} (line {line})" + for group in violations + for module_name, func_name, line, _ in group + ) + ) diff --git a/tests/test_episode_format_helper.py b/tests/test_episode_format_helper.py index a82413537..3ad3d6764 100644 --- a/tests/test_episode_format_helper.py +++ b/tests/test_episode_format_helper.py @@ -708,21 +708,21 @@ def test_transfer_chain_recommend_episode_format_passes_helper_data(monkeypatch) monkeypatch.setattr( chain, - "_TransferChain__resolve_episode_format_directory", + "_resolve_episode_format_directory", lambda item: directory, ) monkeypatch.setattr( chain, - "_TransferChain__get_episode_format_rules", + "_get_episode_format_rules", lambda: [], ) monkeypatch.setattr( chain, - "_TransferChain__get_episode_format_sample_files", + "_get_episode_format_sample_files", lambda item: [sample], ) monkeypatch.setattr( - "app.chain.transfer.EpisodeFormatRuleHelper.recommend", + "app.chain._mixins.EpisodeFormatRuleHelper.recommend", lambda self, rules, sample_files: (True, "", helper_data), ) @@ -773,11 +773,11 @@ def test_transfer_chain_recommend_episode_format_uses_selected_fileitems(monkeyp monkeypatch.setattr( chain, - "_TransferChain__get_episode_format_rules", + "_get_episode_format_rules", lambda: [], ) monkeypatch.setattr( - "app.chain.transfer.EpisodeFormatRuleHelper.recommend", + "app.chain._mixins.EpisodeFormatRuleHelper.recommend", lambda self, rules, sample_files: (True, "", { **helper_data, "received_samples": [item.name for item in sample_files], @@ -854,7 +854,7 @@ def test_transfer_chain_episode_format_samples_include_extra_files(monkeypatch): monkeypatch.setattr(chain, "_subtitle_exts", [".ass", ".ssa"], raising=False) monkeypatch.setattr(chain, "_audio_exts", [".mka", ".aac"], raising=False) - sample_files = TransferChain._TransferChain__get_episode_format_sample_files( + sample_files = TransferChain._get_episode_format_sample_files( chain, directory, ) diff --git a/tests/test_episode_group_recognition.py b/tests/test_episode_group_recognition.py index 0544bf724..c3aed1181 100644 --- a/tests/test_episode_group_recognition.py +++ b/tests/test_episode_group_recognition.py @@ -34,9 +34,9 @@ def test_recognize_media_uses_meta_episode_group(): ) with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=True, - ), patch("app.chain.MoviePilotServerHelper.query_recognize_share") as query_mock: + ), patch("app.chain._recognition.MoviePilotServerHelper.query_recognize_share") as query_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is mediainfo diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index 487997acc..2190497a6 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -36,12 +36,12 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -62,6 +62,7 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del "app.chain.transfer.TransferHistoryOper", lambda: history_oper, ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: history_oper) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -71,10 +72,17 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -85,6 +93,13 @@ def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, del or True, ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + exists=lambda current_fileitem: True, + delete_media_file=lambda current_fileitem: deleted.append( + ("target", current_fileitem.path) + ) + or True, + )) monkeypatch.setattr( "app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1), diff --git a/tests/test_media_recognize_share.py b/tests/test_media_recognize_share.py index b0d07534e..60ab49e97 100644 --- a/tests/test_media_recognize_share.py +++ b/tests/test_media_recognize_share.py @@ -48,10 +48,10 @@ def test_report_shared_result_after_local_recognize_success(): mediainfo = _tmdb_media("测试电影", 100, MediaType.MOVIE, year="2024") with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share" + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta, cache=False) @@ -72,7 +72,7 @@ def test_query_shared_result_when_local_recognize_failed(): "run_module", side_effect=[None, shared_media], ) as run_module, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "tv", "media_source": "themoviedb", @@ -80,7 +80,7 @@ def test_query_shared_result_when_local_recognize_failed(): "season": 1, }, ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -88,7 +88,7 @@ def test_query_shared_result_when_local_recognize_failed(): "season": 1, }, ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -119,7 +119,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "async_run_module", async_run_module, ), patch( - "app.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_query_recognize_share", AsyncMock(return_value={ "type": "tv", "media_source": "themoviedb", @@ -127,7 +127,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "season": 2, }), ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -135,7 +135,7 @@ def test_async_query_shared_result_when_local_recognize_failed(): "season": 2, }, ), patch( - "app.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_report_recognize_share", AsyncMock(return_value=False), ), patch.object( chain, @@ -173,14 +173,14 @@ def test_backfill_local_cache_after_shared_recognize_success(): "run_module", side_effect=[None, shared_media, None], ) as run_module_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "movie", "media_source": "themoviedb", "media_id": "700", }, ), patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MOVIE, "media_source": MediaSource.TMDB, @@ -188,7 +188,7 @@ def test_backfill_local_cache_after_shared_recognize_success(): "season": None, }, ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media(meta=meta, cache=False) @@ -288,7 +288,7 @@ def test_report_shared_result_with_distinct_keyword_meta(): mediainfo = _tmdb_media("测试剧集", 402, MediaType.TV, year="2024") with patch.object(chain, "run_module", return_value=mediainfo), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock: result = chain.recognize_media(meta=meta, share_meta=share_meta, cache=False) @@ -315,7 +315,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "run_module", side_effect=[None, shared_media], ), patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "tv", "media_source": "themoviedb", @@ -323,7 +323,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "season": 1, }, ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.TV, "media_source": MediaSource.TMDB, @@ -331,7 +331,7 @@ def test_query_shared_result_with_distinct_keyword_meta(): "season": 1, }, ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -359,10 +359,10 @@ def test_skip_report_when_local_recognize_hits_cache(): mediainfo.recognize_cache_hit = True with patch.object(chain, "run_module", return_value=mediainfo) as run_module, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share" + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta) @@ -385,10 +385,10 @@ def test_async_skip_report_when_local_recognize_hits_cache(): "async_run_module", AsyncMock(return_value=mediainfo), ) as async_run_module, patch( - "app.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_report_recognize_share", AsyncMock(return_value=True), ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_query_recognize_share", AsyncMock(), ) as query_mock: result = await chain.async_recognize_media(meta=meta) @@ -579,10 +579,10 @@ def test_chain_recognize_media_reports_music_share_result(): music = _music_info() with patch.object(chain, "recognize_music_from_source", return_value=music), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=True, ) as report_mock, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share" + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share" ) as query_mock: result = chain.recognize_media(meta=meta, cache=False) @@ -602,7 +602,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "recognize_music_from_source", side_effect=[None, music], ) as recognize_source, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "music", "media_source": "musicbrainz", @@ -610,7 +610,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "music_type": "recording", }, ), patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -619,7 +619,7 @@ def test_chain_recognize_media_queries_music_share_when_local_failed(): "season": None, }, ), patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ), patch.object( chain, @@ -650,7 +650,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): "recognize_music_from_source", side_effect=[fallback, music], ) as recognize_source, patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value={ "type": "music", "media_source": "musicbrainz", @@ -658,7 +658,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): "music_type": "recording", }, ) as query_share, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -670,7 +670,7 @@ def test_chain_recognize_media_queries_music_share_after_local_fallback(): chain, "_update_local_recognize_cache", ), patch( - "app.chain.settings.MEDIA_RECOGNIZE_SHARE", + "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True, ): result = chain.recognize_media(meta=meta, cache=False) @@ -697,7 +697,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "async_recognize_music_from_source", new=AsyncMock(side_effect=[fallback, music]), ) as recognize_source, patch( - "app.chain.MoviePilotServerHelper.async_query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_query_recognize_share", new=AsyncMock(return_value={ "type": "music", "media_source": "musicbrainz", @@ -705,7 +705,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "music_type": "recording", }), ) as query_share, patch( - "app.chain.MoviePilotServerHelper.to_recognize_params", + "app.chain._recognition.MoviePilotServerHelper.to_recognize_params", return_value={ "mtype": MediaType.MUSIC, "media_source": MediaSource.MusicBrainz, @@ -718,7 +718,7 @@ def test_chain_async_recognize_media_queries_music_share_after_local_fallback(): "_async_update_local_recognize_cache", new=AsyncMock(), ), patch( - "app.chain.settings.MEDIA_RECOGNIZE_SHARE", + "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True, ): result = await chain.async_recognize_media(meta=meta, cache=False) @@ -749,12 +749,12 @@ def test_chain_recognize_media_skips_music_report_for_fallback_result(): fallback = MusicInfo(title="未知曲目", artists=["未知艺术家"]) with patch.object(chain, "recognize_music_from_source", return_value=fallback), patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value=None, ) as query_mock, patch( - "app.chain.MoviePilotServerHelper.report_recognize_share" + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share" ) as report_mock, patch( - "app.chain.settings.MEDIA_RECOGNIZE_SHARE", True + "app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True ): result = chain.recognize_media(meta=meta, cache=False) diff --git a/tests/test_media_recognize_share_statistics.py b/tests/test_media_recognize_share_statistics.py index 60f879c9e..23a159a65 100644 --- a/tests/test_media_recognize_share_statistics.py +++ b/tests/test_media_recognize_share_statistics.py @@ -29,8 +29,9 @@ def _shared_params(tmdb_id: int) -> dict: def _mock_counter(monkeypatch) -> Mock: """替换系统配置持久化入口并返回递增调用桩。""" increment = Mock() + # 计数逻辑在识别 mixin 中,按 _recognition 模块命名空间解析 SystemConfigOper monkeypatch.setattr( - "app.chain.SystemConfigOper", + "app.chain._recognition.SystemConfigOper", lambda: SimpleNamespace(increment=increment), ) return increment @@ -56,7 +57,7 @@ def test_sync_shared_recognize_success_increments_persisted_count(monkeypatch): type=MediaType.MOVIE, ) increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, media])) monkeypatch.setattr(chain, "_update_local_recognize_cache", Mock()) monkeypatch.setattr( @@ -85,7 +86,7 @@ def test_sync_shared_result_without_local_match_does_not_increment(monkeypatch): chain = _bare_chain() meta = _build_meta("共享识别失败电影") increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr(chain, "run_module", Mock(side_effect=[None, None])) monkeypatch.setattr( MoviePilotServerHelper, @@ -121,7 +122,7 @@ def test_async_shared_recognize_success_increments_persisted_count(monkeypatch): type=MediaType.MOVIE, ) increment = _mock_counter(monkeypatch) - monkeypatch.setattr("app.chain.settings.MEDIA_RECOGNIZE_SHARE", True) + monkeypatch.setattr("app.chain._recognition.settings.MEDIA_RECOGNIZE_SHARE", True) monkeypatch.setattr( chain, "async_run_module", diff --git a/tests/test_media_source_routing.py b/tests/test_media_source_routing.py index 81593f46b..02268bc32 100644 --- a/tests/test_media_source_routing.py +++ b/tests/test_media_source_routing.py @@ -71,7 +71,7 @@ def test_explicit_source_recognition_reaches_modules_with_unified_identity() -> chain.run_module = Mock(return_value=media) with patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media( @@ -105,7 +105,7 @@ def test_default_recognition_passes_empty_generic_identity() -> None: meta.type = MediaType.MOVIE with patch( - "app.chain.MoviePilotServerHelper.report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.report_recognize_share", return_value=False, ): result = chain.recognize_media(meta=meta) diff --git a/tests/test_message_notifications.py b/tests/test_message_notifications.py index 90c8b42b5..708e2a99a 100644 --- a/tests/test_message_notifications.py +++ b/tests/test_message_notifications.py @@ -170,7 +170,7 @@ def test_user_helper_message_does_not_enter_sse_queue() -> None: assert helper.get() is None -def test_notification_post_message_is_persisted_without_sse_queue() -> None: +def test_notification_post_message_is_persisted_without_sse_queue(monkeypatch) -> None: """ 业务通知通过消息链发送时只登记数据库,不进入前端 SSE 队列。 """ @@ -179,8 +179,10 @@ def test_notification_post_message_is_persisted_without_sse_queue() -> None: _reset_message_helper(helper) chain = ChainBase() - chain.messagequeue.send_message = Mock() - chain.eventmanager.send_event = Mock() + # messagequeue 是全局单例,用 monkeypatch 避免用例间污染 + send_message = Mock() + monkeypatch.setattr(chain.messagequeue, "send_message", send_message) + monkeypatch.setattr(chain.eventmanager, "send_event", Mock()) chain.post_message( Notification( @@ -195,10 +197,10 @@ def test_notification_post_message_is_persisted_without_sse_queue() -> None: assert messages[0].title == "下载完成" assert messages[0].mtype == NotificationType.Download.value assert helper.get() is None - chain.messagequeue.send_message.assert_called_once() + send_message.assert_called_once() -def test_agent_notification_post_message_is_persisted_without_sse_queue() -> None: +def test_agent_notification_post_message_is_persisted_without_sse_queue(monkeypatch) -> None: """ 智能体消息通过消息链发送时登记数据库,但不进入前端 SSE 队列。 """ @@ -207,8 +209,10 @@ def test_agent_notification_post_message_is_persisted_without_sse_queue() -> Non _reset_message_helper(helper) chain = ChainBase() - chain.messagequeue.send_message = Mock() - chain.eventmanager.send_event = Mock() + # messagequeue 是全局单例,用 monkeypatch 避免用例间污染 + send_message = Mock() + monkeypatch.setattr(chain.messagequeue, "send_message", send_message) + monkeypatch.setattr(chain.eventmanager, "send_event", Mock()) chain.post_message( Notification( @@ -223,18 +227,21 @@ def test_agent_notification_post_message_is_persisted_without_sse_queue() -> Non assert messages[0].title == "MoviePilot助手" assert messages[0].mtype == NotificationType.Agent.value assert helper.get() is None - chain.messagequeue.send_message.assert_called_once() + send_message.assert_called_once() -def test_transient_notification_post_message_skips_history_but_dispatches() -> None: +def test_transient_notification_post_message_skips_history_but_dispatches(monkeypatch) -> None: """ 标记为不保存历史的过程消息应跳过数据库登记,但仍正常派发。 """ _clear_messages() chain = ChainBase() - chain.messagequeue.send_message = Mock() - chain.eventmanager.send_event = Mock() + # messagequeue 是全局单例,用 monkeypatch 避免用例间污染 + send_message = Mock() + monkeypatch.setattr(chain.messagequeue, "send_message", send_message) + send_event = Mock() + monkeypatch.setattr(chain.eventmanager, "send_event", send_event) chain.post_message( Notification( @@ -245,12 +252,12 @@ def test_transient_notification_post_message_skips_history_but_dispatches() -> N ) assert MessageOper().list_by_page(page=1, count=10) == [] - assert "save_history" not in chain.eventmanager.send_event.call_args.kwargs["data"] - chain.eventmanager.send_event.assert_called_once() - chain.messagequeue.send_message.assert_called_once() + assert "save_history" not in send_event.call_args.kwargs["data"] + send_event.assert_called_once() + send_message.assert_called_once() -def test_transient_media_and_torrent_lists_skip_history_but_dispatch() -> None: +def test_transient_media_and_torrent_lists_skip_history_but_dispatch(monkeypatch) -> None: """ 传统交互候选列表标记为不保存历史时,只发送到渠道,不写入消息表。 """ @@ -267,7 +274,9 @@ def test_transient_media_and_torrent_lists_skip_history_but_dispatch() -> None: ), ) - chain.messagequeue.send_message = Mock() + # messagequeue 是全局单例,用 monkeypatch 避免用例间污染 + send_message = Mock() + monkeypatch.setattr(chain.messagequeue, "send_message", send_message) chain.post_medias_message( Notification(title="请选择媒体", save_history=False), @@ -279,4 +288,4 @@ def test_transient_media_and_torrent_lists_skip_history_but_dispatch() -> None: ) assert MessageOper().list_by_page(page=1, count=10) == [] - assert chain.messagequeue.send_message.call_count == 2 + assert send_message.call_count == 2 diff --git a/tests/test_music_plugin_recognize.py b/tests/test_music_plugin_recognize.py index 020c5c3c9..2b994c2c3 100644 --- a/tests/test_music_plugin_recognize.py +++ b/tests/test_music_plugin_recognize.py @@ -369,7 +369,7 @@ def test_chain_recognize_media_music_plugin_supplement(): with patch.object(chain, "recognize_music_from_source", return_value=fallback), \ patch.object(chain.eventmanager, "check", return_value=True), \ patch.object(chain.eventmanager, "send_event", return_value=event), \ - patch("app.chain.MoviePilotServerHelper.report_recognize_share") as report_mock: + patch("app.chain._recognition.MoviePilotServerHelper.report_recognize_share") as report_mock: result = chain.recognize_media(meta=meta, cache=False) assert result is not fallback diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py index 51c830196..76b46daf3 100644 --- a/tests/test_music_subscribe.py +++ b/tests/test_music_subscribe.py @@ -132,9 +132,9 @@ def test_music_subscribe_reuses_search_download_and_finish_flow(): chain.filter_torrents = Mock(side_effect=lambda **kwargs: kwargs["torrent_list"]) with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \ - patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ - patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \ - patch("app.chain.subscribe.SubscribeOper") as subscribe_oper: + patch("app.chain._music.SearchChain", return_value=search_chain), \ + patch("app.chain._music.DownloadChain", return_value=download_chain), \ + patch("app.chain._music.SubscribeOper") as subscribe_oper: subscribe_oper.return_value.get.return_value = subscribe chain._search_music_subscribe(subscribe) @@ -239,8 +239,8 @@ def test_music_best_version_persists_downloaded_rule_priority(): chain = SubscribeChain() chain.finish_subscribe_or_not = Mock() - with patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \ - patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper): + with patch("app.chain._music.DownloadChain", return_value=download_chain), \ + patch("app.chain._music.SubscribeOper", return_value=subscribe_oper): chain._download_music_subscribe(subscribe, _music_info(), [downloaded]) subscribe_oper.update.assert_called_once_with( @@ -441,8 +441,9 @@ def test_music_rss_match_reuses_cached_context_without_second_site_search(): torrent_helper.filter_torrent.return_value = True with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \ patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \ - patch("app.chain.subscribe.TorrentHelper", return_value=torrent_helper), \ - patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \ + patch("app.chain._music.SubscribeOper", return_value=subscribe_oper), \ + patch("app.chain._music.TorrentHelper", return_value=torrent_helper), \ + patch("app.chain._music.DownloadChain", return_value=download_chain), \ patch("app.chain.subscribe.SearchChain") as search_chain, \ patch("app.chain.subscribe.MediaChain") as media_chain: chain.match({"music.example": [source_context]}) @@ -519,7 +520,7 @@ def test_legacy_music_without_identity_uses_recording_recognition_boundary(): media_chain = Mock() media_chain.recognize_media.return_value = recording - with patch("app.chain.subscribe.MediaChain", return_value=media_chain): + with patch("app.chain._music.MediaChain", return_value=media_chain): restored = SubscribeChain._recognize_music_subscribe(subscribe) assert restored is recording @@ -637,7 +638,7 @@ def test_recording_target_sync_clears_stale_album_track_count(): subscribe = _subscribe(total_tracks=11) subscribe_oper = Mock() - with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper): + with patch("app.chain._music.SubscribeOper", return_value=subscribe_oper): SubscribeChain._sync_music_subscribe_target(subscribe, _music_info()) subscribe_oper.update.assert_called_once_with(subscribe.id, {"total_tracks": None}) diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index d35aa3d93..4e1d7935b 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -68,6 +68,7 @@ def test_music_retry_restores_history_entity_namespace(tmp_path, monkeypatch): title="叶惠美", ) monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain) + monkeypatch.setattr("app.chain._mixins.MediaChain", lambda: media_chain) result = TransferChain()._recognize_music_retry_media( history, @@ -188,8 +189,8 @@ def test_music_scrape_batch_event_preserves_each_track_context(): ) tasks.append(task) target_paths.append(target_path) - chain._TransferChain__register_scrape_batch_task(task) - chain._TransferChain__record_scrape_target( + chain._register_scrape_batch_task(task) + chain._record_scrape_target( task, TransferInfo( success=True, @@ -199,9 +200,9 @@ def test_music_scrape_batch_event_preserves_each_track_context(): ), ) - chain._TransferChain__close_scrape_batch(batch_id) + chain._close_scrape_batch(batch_id) for task in tasks: - chain._TransferChain__finish_scrape_batch_task(task) + chain._finish_scrape_batch_task(task) scrape_calls = [ call @@ -537,6 +538,7 @@ def test_success_file_aggregation_is_isolated_between_music_jobs_in_same_directo "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace()) monkeypatch.setattr( "app.chain.transfer.add_transfer_success", lambda **kwargs: SimpleNamespace(id=1), @@ -782,6 +784,7 @@ def test_downloader_process_forwards_music_history_type(tmp_path, monkeypatch): ), ) monkeypatch.setattr("app.chain.transfer.MediaChain", lambda: media_chain) + monkeypatch.setattr("app.chain._mixins.MediaChain", lambda: media_chain) monkeypatch.setattr(chain, "do_transfer", Mock(return_value=(True, ""))) monkeypatch.setattr(chain, "run_module", run_module) diff --git a/tests/test_music_workflows.py b/tests/test_music_workflows.py index c6ba86de1..232416d9c 100644 --- a/tests/test_music_workflows.py +++ b/tests/test_music_workflows.py @@ -435,7 +435,7 @@ def test_media_chain_default_recognition_only_queries_musicbrainz(monkeypatch): recognize_source = Mock(return_value=expected) monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) - with patch("app.chain.MoviePilotServerHelper.report_recognize_share"): + with patch("app.chain._recognition.MoviePilotServerHelper.report_recognize_share"): result = chain.recognize_media(meta=meta) assert result is expected @@ -484,7 +484,7 @@ def test_default_recognition_does_not_fallback_after_musicbrainz_miss(monkeypatc monkeypatch.setattr(chain, "recognize_music_from_source", recognize_source) with patch( - "app.chain.MoviePilotServerHelper.query_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.query_recognize_share", return_value=None, ): assert chain.recognize_media(meta=meta) is None @@ -506,7 +506,7 @@ def test_async_default_recognition_only_queries_musicbrainz(monkeypatch): monkeypatch.setattr(chain, "async_recognize_music_from_source", recognize_source) with patch( - "app.chain.MoviePilotServerHelper.async_report_recognize_share", + "app.chain._recognition.MoviePilotServerHelper.async_report_recognize_share", new=AsyncMock(), ): result = asyncio.run(chain.async_recognize_media(meta=meta)) diff --git a/tests/test_qbittorrent_compat.py b/tests/test_qbittorrent_compat.py index ebbedcc2b..cd3fdcb82 100644 --- a/tests/test_qbittorrent_compat.py +++ b/tests/test_qbittorrent_compat.py @@ -92,6 +92,40 @@ def _load_qbittorrent_modules(): def __class_getitem__(cls, _item): return cls + # 隔离环境下的下载器业务样板基类,镜像 app.modules._base.downloader 的行为 + class _DownloaderModuleBase(_ModuleBase, _DownloaderBase): + def test(self): + return True, "" + + def scheduler_job(self): + pass + + def _get_torrent_info(self, content): + torrent_info, torrent_content = None, None + if isinstance(content, Path): + torrent_content = content.read_bytes() if content.exists() else None + else: + torrent_content = content + if torrent_content: + if torrent_rules_module.is_magnet_link(torrent_content): + return None, torrent_content + torrent_info = torrentool_torrent_module.Torrent.from_string(torrent_content) + return torrent_info, torrent_content + + @staticmethod + def _normalize_query_status(status): + status_value = getattr(status, "value", status) + status_text = str(status_value or "").strip().lower() + if status_text in {"transfer", TorrentStatus.TRANSFER.value}: + return TorrentQueryStatus.TRANSFER + if status_text in {"downloading", TorrentStatus.DOWNLOADING.value}: + return TorrentQueryStatus.DOWNLOADING + if status_text in {"completed", "seeding", "complete", "完成", "已完成"}: + return TorrentQueryStatus.COMPLETED + if status_text in {"paused", "pause", "暂停", "已暂停"}: + return TorrentQueryStatus.PAUSED + return TorrentQueryStatus.ALL + class _Torrent: @staticmethod def from_string(content): @@ -143,6 +177,9 @@ def _load_qbittorrent_modules(): temporal_tools_module.format_duration = _format_duration modules_module._ModuleBase = _ModuleBase modules_module._DownloaderBase = _DownloaderBase + base_module = types.ModuleType("app.modules._base") + base_module._DownloaderModuleBase = _DownloaderModuleBase + modules_module._base = base_module torrentool_torrent_module.Torrent = _Torrent qbittorrentapi_module.TorrentDictionary = dict qbittorrentapi_module.TorrentFilesList = list @@ -186,6 +223,7 @@ def _load_qbittorrent_modules(): "app.domain.metainfo": metainfo_module, "app.runtime.log": log_module, "app.modules": modules_module, + "app.modules._base": base_module, "app.modules.qbittorrent": qbittorrent_package_module, "app.schemas": schemas_module, "app.schemas.types": schema_types_module, diff --git a/tests/test_recognize_source_selection.py b/tests/test_recognize_source_selection.py index 3c0db6375..b35e1e076 100644 --- a/tests/test_recognize_source_selection.py +++ b/tests/test_recognize_source_selection.py @@ -39,7 +39,7 @@ def test_recognize_media_with_source_only_uses_name_search(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.chain.MoviePilotServerHelper"): + patch("app.chain._recognition.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) assert result is not None @@ -70,7 +70,7 @@ def test_async_recognize_media_with_source_only_uses_name_search(): with patch.object(chain, "_async_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_async_supplement_media_recognize", side_effect=fake_supplement), \ - patch("app.chain.MoviePilotServerHelper", helper): + patch("app.chain._recognition.MoviePilotServerHelper", helper): result = asyncio.run( chain.async_recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) ) @@ -93,7 +93,7 @@ def test_recognize_media_accepts_string_source_only(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.chain.MoviePilotServerHelper"): + patch("app.chain._recognition.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source="themoviedb", cache=False) assert result is not None @@ -112,7 +112,7 @@ def test_recognize_media_meta_identity_same_source_uses_id(): with patch.object(chain, "_run_native_media_recognize", side_effect=fake_native), \ patch.object(chain, "_supplement_media_recognize", side_effect=lambda **kw: kw["mediainfo"]), \ - patch("app.chain.MoviePilotServerHelper"): + patch("app.chain._recognition.MoviePilotServerHelper"): result = chain.recognize_media(meta=meta, media_source=MediaSource.TMDB, cache=False) assert result is not None diff --git a/tests/test_slack_command_registration.py b/tests/test_slack_command_registration.py index 428aa2c4f..09c9226a1 100644 --- a/tests/test_slack_command_registration.py +++ b/tests/test_slack_command_registration.py @@ -29,7 +29,7 @@ def test_slack_module_register_commands_filters_event_subset(): return_value={"slack-main": SimpleNamespace(name="slack-main", config={})}, ), patch.object(module, "get_instance", return_value=client), - patch("app.modules.slack.eventmanager.send_event", return_value=event), + patch("app.modules._base.notification.eventmanager.send_event", return_value=event), ): module.register_commands(original_commands) diff --git a/tests/test_slash_command_interactions.py b/tests/test_slash_command_interactions.py index 0602c0295..9b4b5d2f8 100644 --- a/tests/test_slash_command_interactions.py +++ b/tests/test_slash_command_interactions.py @@ -12,9 +12,11 @@ ensure_optional_stub("pyquery", PyQuery=object) from app.chain.message import MessageChain from app.application.messaging.interaction import InteractionContext -from app.chain.site import SiteChain, site_interaction_manager +from app.chain.site import SiteChain +from app.application.messaging.site import site_interaction_manager from app.application.messaging.skill import skill_interaction_manager -from app.chain.subscribe import SubscribeChain, subscribe_interaction_manager +from app.chain.subscribe import SubscribeChain +from app.application.messaging.subscribe import subscribe_interaction_manager from app.schemas.types import MessageChannel diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index bbe8749cb..3d7ea6b64 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -19,6 +19,11 @@ def _load_subscribe_chain_class(): module = sys.modules[module_name] return module, module.SubscribeChain + # 交互处理器模块须在打桩上下文之外预先真实加载:其模块级会话管理器单例 + # 由 SlashInteractionManager 构造,若在桩内导入会绑定桩类并残留 sys.modules, + # 污染依赖真实会话管理器的后续测试 + import app.application.messaging.subscribe # noqa: F401 + stub_deps = {} def ensure_module(name: str, module: types.ModuleType): @@ -43,6 +48,12 @@ def _load_subscribe_chain_class(): chain_module.ChainBase = _ChainBase + # 链内功能域 mixin:交互四件套委托与音乐订阅域,隔离加载以空 mixin 注入 + interaction_mixin_module = ensure_module("app.chain._interaction", types.ModuleType("app.chain._interaction")) + interaction_mixin_module.InteractionChainMixin = type("InteractionChainMixin", (), {}) + music_mixin_module = ensure_module("app.chain._music", types.ModuleType("app.chain._music")) + music_mixin_module.MusicSubscribeMixin = type("MusicSubscribeMixin", (), {}) + class _MediaChain: """提供订阅链隔离测试所需的统一媒体识别接口。""" @@ -77,6 +88,8 @@ def _load_subscribe_chain_class(): def remove(self, *args, **kwargs): return None + # 真实导入 app.application.messaging.subscribe 需要 MessageGateway 类型符号 + interaction_module.MessageGateway = type("MessageGateway", (), {}) interaction_module.SlashInteractionManager = _SlashInteractionManager interaction_module.build_navigation_buttons = lambda *args, **kwargs: [] interaction_module.format_markdown_table = lambda *args, **kwargs: "" diff --git a/tests/test_system_notification_dispatch.py b/tests/test_system_notification_dispatch.py index 0f2894aef..812fa2ac7 100644 --- a/tests/test_system_notification_dispatch.py +++ b/tests/test_system_notification_dispatch.py @@ -36,7 +36,7 @@ class TestSystemNotificationDispatch(unittest.TestCase): text="任务完成", ) - with patch("app.chain.MessageTemplateHelper.render", return_value=message), patch.object( + with patch("app.chain._messaging.MessageTemplateHelper.render", return_value=message), patch.object( chain.messagehelper, "put" ), patch.object(chain.messageoper, "add"), patch.object( chain.eventmanager, "send_event" diff --git a/tests/test_telegram_typing_lifecycle.py b/tests/test_telegram_typing_lifecycle.py index dc01e7de7..dda99f46e 100644 --- a/tests/test_telegram_typing_lifecycle.py +++ b/tests/test_telegram_typing_lifecycle.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import asyncio import threading import time @@ -263,7 +278,7 @@ class TestTelegramTypingLifecycle(unittest.TestCase): ) as start_status, patch( "app.chain.message.settings.AI_AGENT_ENABLE", True ), patch( - "app.chain.message.agent_manager.process_message", + "app.application.agent._agent_manager.process_message", new_callable=AsyncMock, ) as process_message, patch( "app.chain.message.asyncio.run_coroutine_threadsafe", diff --git a/tests/test_transfer_custom_words.py b/tests/test_transfer_custom_words.py index b2a4c0873..30563b137 100644 --- a/tests/test_transfer_custom_words.py +++ b/tests/test_transfer_custom_words.py @@ -7,7 +7,7 @@ """ from types import SimpleNamespace -import app.chain.transfer as transfer_module +import app.chain._mixins as mixins_module from app.chain.transfer import TransferChain @@ -26,7 +26,7 @@ def test_transfer_prefers_snapshot_over_live_lookup(monkeypatch): called["lookup"] = True return SimpleNamespace(custom_words="不应使用\n实时反查") - monkeypatch.setattr(transfer_module, "SubscribeChain", _GuardSubscribeChain) + monkeypatch.setattr(mixins_module, "SubscribeChain", _GuardSubscribeChain) history = _fake_history( custom_words="S04 => S01\n第 <> 集 >> EP+66", @@ -46,7 +46,7 @@ def test_transfer_falls_back_to_live_lookup_without_snapshot(monkeypatch): assert source == "Subscribe|{...}" return SimpleNamespace(custom_words="A => B") - monkeypatch.setattr(transfer_module, "SubscribeChain", _FakeSubscribeChain) + monkeypatch.setattr(mixins_module, "SubscribeChain", _FakeSubscribeChain) history = _fake_history(custom_words=None, note={"source": "Subscribe|{...}"}) result = TransferChain._get_subscribe_custom_words(history) @@ -61,7 +61,7 @@ def test_transfer_returns_none_when_unavailable(monkeypatch): def get_subscribe_by_source(self, source): return None - monkeypatch.setattr(transfer_module, "SubscribeChain", _NoneSubscribeChain) + monkeypatch.setattr(mixins_module, "SubscribeChain", _NoneSubscribeChain) # 无下载记录 assert TransferChain._get_subscribe_custom_words(None) is None diff --git a/tests/test_transfer_failed_retry_buttons.py b/tests/test_transfer_failed_retry_buttons.py index 495be8b57..b096b8891 100644 --- a/tests/test_transfer_failed_retry_buttons.py +++ b/tests/test_transfer_failed_retry_buttons.py @@ -1,3 +1,18 @@ +# 把真实 Agent 服务注册进 application 门面(幂等),供测试 patch 门面背后的单例方法。 +from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.orchestrator import agent_manager +from app.agent.prompt import prompt_manager +from app.agent.prompt.transfer_redo import build_manual_redo_prompt +from app.application.agent import register_agent_services + +register_agent_services( + agent_manager=agent_manager, + prompt_manager=prompt_manager, + capability_manager=AgentCapabilityManager, + llm_helper=LLMHelper, + manual_redo_prompt_builder=build_manual_redo_prompt, +) + import unittest import asyncio import sys @@ -134,10 +149,14 @@ class TestTransferFailedRetryButtons(unittest.TestCase): with patch( "app.chain.transfer.TransferHistoryOper" ) as history_oper_cls, patch( + # mixin 中按自身模块命名空间解析 TransferHistoryOper,需同步镜像 + "app.chain._mixins.TransferHistoryOper" + ) as mixins_history_oper_cls, patch( "app.chain.transfer.asyncio.run_coroutine_threadsafe", side_effect=_close_pending_coro, ) as run_task: history_oper_cls.return_value.get.return_value = history + mixins_history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message") as post_message: chain.handle_failed_transfer_callback( callback_data="transfer_ai_retry_34", @@ -204,13 +223,17 @@ class TestTransferFailedRetryButtons(unittest.TestCase): with patch( "app.chain.transfer.TransferHistoryOper" ) as history_oper_cls, patch( - "app.chain.transfer.agent_manager.run_background_prompt", + # mixin 中按自身模块命名空间解析 TransferHistoryOper,需同步镜像 + "app.chain._mixins.TransferHistoryOper" + ) as mixins_history_oper_cls, patch( + "app.application.agent._agent_manager.run_background_prompt", side_effect=fake_run_background_prompt, ), patch( "app.chain.transfer.asyncio.run_coroutine_threadsafe", side_effect=_run_pending_coro, ): history_oper_cls.return_value.get.return_value = history + mixins_history_oper_cls.return_value.get.return_value = history with patch.object(chain, "post_message"), patch.object( chain, "async_post_message", side_effect=fake_async_post_message ): diff --git a/tests/test_transfer_job_manager.py b/tests/test_transfer_job_manager.py index 52abe07fd..7388aa136 100644 --- a/tests/test_transfer_job_manager.py +++ b/tests/test_transfer_job_manager.py @@ -454,8 +454,8 @@ class TransferJobManagerTest(unittest.TestCase): (source_fileitem, False) ] chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None def fake_handle_transfer(task, callback=None): planned_episodes.append(task.meta.begin_episode) @@ -1051,8 +1051,8 @@ class TransferJobManagerTest(unittest.TestCase): (main_fileitem, False) ] chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None def fake_handle_transfer(task, callback=None): planned.append(task.fileitem.path) @@ -1161,8 +1161,8 @@ class TransferJobManagerTest(unittest.TestCase): ) chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None def fake_handle_transfer(task, callback=None): planned.append((task.fileitem.path, task.meta.begin_episode)) @@ -1236,8 +1236,8 @@ class TransferJobManagerTest(unittest.TestCase): (main_fileitem, False) ] chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None def fake_handle_transfer(task, callback=None): planned.append((task.fileitem.path, task.meta.begin_episode)) @@ -1331,8 +1331,8 @@ class TransferJobManagerTest(unittest.TestCase): (other_title_fileitem, False), ] chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None def fake_handle_transfer(task, callback=None): planned.append((task.fileitem.path, task.meta.begin_episode)) @@ -1420,9 +1420,9 @@ class TransferJobManagerTest(unittest.TestCase): task.background = False task.manual = True self.assertTrue(chain._TransferChain__put_to_jobview(task)) - chain._TransferChain__register_scrape_batch_task(task) + chain._register_scrape_batch_task(task) - chain._TransferChain__close_scrape_batch(batch_id) + chain._close_scrape_batch(batch_id) transferinfos = [ TransferInfo( @@ -1474,7 +1474,7 @@ class TransferJobManagerTest(unittest.TestCase): storage_chain_cls.return_value.is_bluray_folder.return_value = False for task, transferinfo in zip(tasks, transferinfos): chain._TransferChain__default_callback(task, transferinfo) - chain._TransferChain__finish_scrape_batch_task(task) + chain._finish_scrape_batch_task(task) metadata_calls = [ call diff --git a/tests/test_transfer_mounted_disk_cleanup.py b/tests/test_transfer_mounted_disk_cleanup.py index c0e61474e..1c4732c31 100644 --- a/tests/test_transfer_mounted_disk_cleanup.py +++ b/tests/test_transfer_mounted_disk_cleanup.py @@ -31,10 +31,10 @@ def test_enabled_cleanup_skips_filesystem_detection(): 开关开启时应保持旧行为,且不产生额外文件系统检测。 """ with patch( - "app.chain.transfer.SystemUtils.is_network_filesystem" + "app.chain._mixins.SystemUtils.is_network_filesystem" ) as is_network_filesystem: should_delete = ( - TransferChain._TransferChain__should_delete_empty_source_directories( + TransferChain._should_delete_empty_source_directories( _make_task(), True, {}, @@ -50,11 +50,11 @@ def test_disabled_cleanup_keeps_mounted_local_source_directories(): 开关关闭时应保留网络或 FUSE 挂载的本地源目录。 """ with patch( - "app.chain.transfer.SystemUtils.is_network_filesystem", + "app.chain._mixins.SystemUtils.is_network_filesystem", return_value=True, ) as is_network_filesystem: should_delete = ( - TransferChain._TransferChain__should_delete_empty_source_directories( + TransferChain._should_delete_empty_source_directories( _make_task(), False, {}, @@ -72,11 +72,11 @@ def test_disabled_cleanup_still_deletes_ordinary_local_source_directories(): 开关关闭时普通本地文件系统仍应删除空目录。 """ with patch( - "app.chain.transfer.SystemUtils.is_network_filesystem", + "app.chain._mixins.SystemUtils.is_network_filesystem", return_value=False, ): should_delete = ( - TransferChain._TransferChain__should_delete_empty_source_directories( + TransferChain._should_delete_empty_source_directories( _make_task(download_path="/downloads"), False, {}, @@ -91,10 +91,10 @@ def test_disabled_cleanup_does_not_change_remote_storage_cleanup(): 开关关闭时非本地存储仍应执行原有空目录清理。 """ with patch( - "app.chain.transfer.SystemUtils.is_network_filesystem" + "app.chain._mixins.SystemUtils.is_network_filesystem" ) as is_network_filesystem: should_delete = ( - TransferChain._TransferChain__should_delete_empty_source_directories( + TransferChain._should_delete_empty_source_directories( _make_task(storage="alist", download_path="/downloads"), False, {}, @@ -111,12 +111,12 @@ def test_mounted_filesystem_detection_is_cached_by_source_directory(): """ mounted_filesystem_cache = {} with patch( - "app.chain.transfer.SystemUtils.is_network_filesystem", + "app.chain._mixins.SystemUtils.is_network_filesystem", return_value=True, ) as is_network_filesystem: for _ in range(2): should_delete = ( - TransferChain._TransferChain__should_delete_empty_source_directories( + TransferChain._should_delete_empty_source_directories( _make_task(), False, mounted_filesystem_cache, diff --git a/tests/test_transfer_movie_collection.py b/tests/test_transfer_movie_collection.py index 174858e9b..3d00ed21b 100644 --- a/tests/test_transfer_movie_collection.py +++ b/tests/test_transfer_movie_collection.py @@ -28,8 +28,8 @@ def _make_chain() -> TransferChain: (fileitem, False) ] chain._TransferChain__put_to_jobview = lambda task: True - chain._TransferChain__register_scrape_batch_task = lambda task: None - chain._TransferChain__close_scrape_batch = lambda batch_id: None + chain._register_scrape_batch_task = lambda task: None + chain._close_scrape_batch = lambda batch_id: None return chain @@ -107,6 +107,7 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch) "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_type_tmdbid=lambda **kwargs: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_type_tmdbid=lambda **kwargs: None)) monkeypatch.setattr( "app.chain.transfer.MediaChain", lambda: SimpleNamespace( @@ -117,6 +118,13 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch) supplement_tmdb_info=lambda media, _meta: media, ), ) + monkeypatch.setattr("app.chain._mixins.MediaChain", lambda: SimpleNamespace( + recognize_media=lambda **kwargs: pytest.fail("不应按合集历史 ID 识别"), + recognize_by_meta=lambda meta, obtain_images: ( + recognized_meta.append(meta) or fallback_media + ), + supplement_tmdb_info=lambda media, _meta: media, + )) task = TransferTask( fileitem=FileItem( storage="local", @@ -182,12 +190,16 @@ def test_movie_collection_conflict_only_drops_automatic_media( "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr("app.chain.transfer.DownloadHistoryOper", lambda: history_oper) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: history_oper) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.StorageChain", lambda: SimpleNamespace()) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace()) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda *args, **kwargs: file_meta) # 用真 MediaInfo 而非 SimpleNamespace:它会被装进 TransferTask.mediainfo, diff --git a/tests/test_transfer_overwrite_declined.py b/tests/test_transfer_overwrite_declined.py index 6d5d07890..650653e59 100644 --- a/tests/test_transfer_overwrite_declined.py +++ b/tests/test_transfer_overwrite_declined.py @@ -53,7 +53,7 @@ def test_overwrite_declined_false_when_flag_not_set(): transferinfo = TransferInfo(success=False, overwrite_skipped=False) transferhis = make_history_oper(raise_on_query=True) - result = TransferChain._TransferChain__is_overwrite_declined( + result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) @@ -67,7 +67,7 @@ def test_overwrite_declined_true_when_success_history_exists(): transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=success_history) - result = TransferChain._TransferChain__is_overwrite_declined( + result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) @@ -80,7 +80,7 @@ def test_overwrite_declined_false_when_no_history(): transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=None) - result = TransferChain._TransferChain__is_overwrite_declined( + result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) @@ -94,7 +94,7 @@ def test_overwrite_declined_false_when_only_failed_history(): transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(history=failed_history, success_history=None) - result = TransferChain._TransferChain__is_overwrite_declined( + result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) @@ -107,7 +107,7 @@ def test_overwrite_declined_false_when_query_raises(): transferinfo = TransferInfo(success=False, overwrite_skipped=True) transferhis = make_history_oper(raise_on_query=True) - result = TransferChain._TransferChain__is_overwrite_declined( + result = TransferChain._is_overwrite_declined( task, transferinfo, transferhis ) diff --git a/tests/test_transfer_stale_tasks.py b/tests/test_transfer_stale_tasks.py index 1b23b488d..90210bb0d 100644 --- a/tests/test_transfer_stale_tasks.py +++ b/tests/test_transfer_stale_tasks.py @@ -1,10 +1,10 @@ """整理任务失活收敛行为测试。""" -from app.chain import transfer from app.chain.transfer import JobManager from app.domain.meta.metabase import MetaBase from app.schemas import FileItem from app.application.transfer import TransferTask +from app.application import transfer as app_transfer from app.schemas.types import MediaType @@ -74,7 +74,7 @@ def _make_task(name: str = "Test.Show.S01E01.mkv") -> TransferTask: def test_external_running_task_expires_without_heartbeat(monkeypatch): """外部接管的运行中任务超过心跳期限后应被标记失败并清理。""" clock = [100.0] - monkeypatch.setattr(transfer, "monotonic", lambda: clock[0]) + monkeypatch.setattr(app_transfer, "monotonic", lambda: clock[0]) manager = JobManager() task = _make_task() assert manager.add_task(task) @@ -90,7 +90,7 @@ def test_external_running_task_expires_without_heartbeat(monkeypatch): def test_main_thread_execution_is_not_expired(monkeypatch): """主程序整理线程仍在执行的任务不应被失活检测伪清理。""" clock = [100.0] - monkeypatch.setattr(transfer, "monotonic", lambda: clock[0]) + monkeypatch.setattr(app_transfer, "monotonic", lambda: clock[0]) manager = JobManager() task = _make_task() assert manager.add_task(task) @@ -110,7 +110,7 @@ def test_main_thread_execution_is_not_expired(monkeypatch): def test_waiting_task_and_refreshed_heartbeat_do_not_expire(monkeypatch): """等待中任务不受失活期限影响,重复运行状态更新可刷新外部心跳。""" clock = [100.0] - monkeypatch.setattr(transfer, "monotonic", lambda: clock[0]) + monkeypatch.setattr(app_transfer, "monotonic", lambda: clock[0]) manager = JobManager() waiting_task = _make_task("Test.Show.S01E01.waiting.mkv") running_task = _make_task("Test.Show.S01E02.running.mkv") diff --git a/tests/test_transfer_sync_extra_files.py b/tests/test_transfer_sync_extra_files.py index c4549ec8f..3b02a50e6 100644 --- a/tests/test_transfer_sync_extra_files.py +++ b/tests/test_transfer_sync_extra_files.py @@ -112,12 +112,12 @@ def test_sync_extra_subtitle_inherits_matching_video_episode(monkeypatch): monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -142,6 +142,7 @@ def test_sync_extra_subtitle_inherits_matching_video_episode(monkeypatch): "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -151,10 +152,17 @@ def test_sync_extra_subtitle_inherits_matching_video_episode(monkeypatch): get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path) state, errmsg = TransferChain.do_transfer( @@ -201,12 +209,12 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch): monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -231,6 +239,7 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch): "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -240,10 +249,17 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch): get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -254,6 +270,13 @@ def test_single_subtitle_transfer_reuses_same_name_video_episode(monkeypatch): ], ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + get_parent_item=lambda fileitem: parent_fileitem, + list_files=lambda fileitem, recursion=False: [ + main_fileitem, + subtitle_fileitem, + ], + )) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path) state, errmsg = TransferChain.do_transfer( @@ -299,12 +322,12 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -331,6 +354,7 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -340,10 +364,17 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -351,6 +382,10 @@ def test_single_video_transfer_lists_parent_once_for_same_name_extra(monkeypatch list_files=fake_list_files, ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + get_parent_item=lambda fileitem: parent_fileitem, + list_files=fake_list_files, + )) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(2)) state, errmsg = TransferChain.do_transfer( @@ -396,12 +431,12 @@ def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch): monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -417,6 +452,7 @@ def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch): "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -426,10 +462,17 @@ def test_episode_format_filters_extra_files_before_sync_planning(monkeypatch): get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1)) state, errmsg = TransferChain.do_transfer( @@ -475,12 +518,12 @@ def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch): monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -496,6 +539,7 @@ def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch): "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -505,10 +549,17 @@ def test_episode_format_keeps_matching_extra_files_following_main(monkeypatch): get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1)) state, errmsg = TransferChain.do_transfer( @@ -554,12 +605,12 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -584,6 +635,7 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -593,10 +645,17 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( @@ -607,6 +666,13 @@ def test_single_matching_subtitle_uses_unmatched_video_only_as_context(monkeypat ], ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + get_parent_item=lambda fileitem: parent_fileitem, + list_files=lambda fileitem, recursion=False: [ + main_fileitem, + subtitle_fileitem, + ], + )) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", fake_meta_info_path) state, errmsg = TransferChain.do_transfer( @@ -644,12 +710,12 @@ def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeyp monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, - "_TransferChain__register_scrape_batch_task", + "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, - "_TransferChain__close_scrape_batch", + "_close_scrape_batch", lambda batch_id: None, ) @@ -665,6 +731,7 @@ def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeyp "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace(get_by_src=lambda src, storage=None: None)) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( @@ -674,16 +741,26 @@ def test_cleanup_dest_fileitem_is_deleted_only_after_allowed_items_exist(monkeyp get_by_path=lambda path: None, ), ) + monkeypatch.setattr("app.chain._mixins.DownloadHistoryOper", lambda: SimpleNamespace( + get_by_hash=lambda download_hash: None, + get_file_by_fullpath=lambda fullpath: None, + get_files_by_savepath=lambda savepath: [], + get_by_path=lambda path: None, + )) monkeypatch.setattr( "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, + )) monkeypatch.setattr("app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1)) state, errmsg = TransferChain.do_transfer( @@ -722,12 +799,16 @@ def test_cleanup_dest_fileitem_is_kept_when_episode_format_matches_nothing(monke "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, ), ) + monkeypatch.setattr("app.chain._mixins.StorageChain", lambda: SimpleNamespace( + delete_media_file=lambda fileitem: delete_calls.append(fileitem.path) or True, + )) state, errmsg = TransferChain.do_transfer( chain, @@ -760,6 +841,7 @@ def test_episode_format_matched_but_filtered_by_size_returns_failure(monkeypatch "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) state, errmsg = TransferChain.do_transfer( chain, @@ -800,6 +882,7 @@ def test_candidate_collection_checks_continue_callback(monkeypatch): "app.chain.transfer.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None), ) + monkeypatch.setattr("app.chain._mixins.SystemConfigOper", lambda: SimpleNamespace(get=lambda key: None)) state, errmsg = TransferChain.do_transfer( chain, diff --git a/tests/test_transfer_tmdb_category.py b/tests/test_transfer_tmdb_category.py index 9a3c6be17..bae4ed2ed 100644 --- a/tests/test_transfer_tmdb_category.py +++ b/tests/test_transfer_tmdb_category.py @@ -79,12 +79,16 @@ def test_transfer_stops_when_automatic_category_has_no_tmdb_result(monkeypatch) "app.chain.transfer.TransferHistoryOper", lambda: SimpleNamespace(), ) + monkeypatch.setattr("app.chain._mixins.TransferHistoryOper", lambda: SimpleNamespace()) monkeypatch.setattr( "app.chain.transfer.MediaChain", lambda: SimpleNamespace( supplement_tmdb_info=lambda media, _meta: media, ), ) + monkeypatch.setattr("app.chain._mixins.MediaChain", lambda: SimpleNamespace( + supplement_tmdb_info=lambda media, _meta: media, + )) task = TransferTask( fileitem=FileItem( storage="local",