Merge remote-tracking branch 'origin/v3' into v3

# Conflicts:
#	app/api/endpoints/agent.py
#	app/api/endpoints/anthropic.py
#	app/api/endpoints/openai.py
#	app/chain/__init__.py
#	app/chain/message.py
#	app/chain/site.py
#	app/chain/subscribe.py
#	app/chain/transfer.py
#	app/modules/discord/__init__.py
#	app/modules/qqbot/__init__.py
#	app/modules/slack/__init__.py
#	app/modules/telegram/__init__.py
#	app/modules/wechat/__init__.py
#	app/runtime/extensions/module_manager.py
#	app/runtime/extensions/service_registry.py
#	tests/test_agent_interaction.py
#	tests/test_slash_command_interactions.py
#	tests/test_web_agent_stream.py
This commit is contained in:
jxxghp
2026-08-16 19:44:43 +08:00
232 changed files with 21375 additions and 6683 deletions
+4
View File
@@ -19,6 +19,7 @@ app/plugins/**
config/cookies/ config/cookies/
config/app.env config/app.env
config/user.db* config/user.db*
config/systemconfig.db*
config/sites/** config/sites/**
config/agent/ config/agent/
config/logs/ config/logs/
@@ -26,6 +27,9 @@ config/plugins/
config/temp/ config/temp/
config/cache/ config/cache/
config/.cache/ config/.cache/
# 运行期设置持久化目录(settings 写回 app.env 的落点)与本地验证产物
app/config/
.verify_tmp/
.runtime/ .runtime/
public/ public/
.moviepilot.env .moviepilot.env
+7 -7
View File
@@ -60,19 +60,19 @@ The legacy roots have no physical directories in the source tree. Current images
|---|---|---|---| |---|---|---|---|
| `app/foundation/` | 无状态、无配置和无 I/O 的底层机制:反射/动态导入、加密、DOM、身份、集合、单例、文本、URL 和版本比较 | `settings`、DB/SystemConfig、网络请求、运行日志、MoviePilot 业务规则、旧导入路径 | `reflection.py`, `crypto.py`, `collections.py`, `text.py`, `url.py` | | `app/foundation/` | 无状态、无配置和无 I/O 的底层机制:反射/动态导入、加密、DOM、身份、集合、单例、文本、URL 和版本比较 | `settings`、DB/SystemConfig、网络请求、运行日志、MoviePilot 业务规则、旧导入路径 | `reflection.py`, `crypto.py`, `collections.py`, `text.py`, `url.py` |
| `app/domain/` | Pure MoviePilot business semantics and models for media, recognition, sites, and torrents | Persistence, global settings reads, network/filesystem clients, Rust imports, service discovery, process lifecycle | `context.py`, `media.py`, `metainfo.py`, `scraper.py`, `meta/` | | `app/domain/` | Pure MoviePilot business semantics and models for media, recognition, sites, and torrents | Persistence, global settings reads, network/filesystem clients, Rust imports, service discovery, process lifecycle | `context.py`, `media.py`, `metainfo.py`, `scraper.py`, `meta/` |
| `app/runtime/` | 进程级运行机制和策略:配置、事件、完整日志、缓存契约/内存行为、并发、调度、限流、本地化、GC 和重启状态 | 具体外部产品、业务流程、Redis/文件缓存实现 | `config.py`, `events.py`, `log.py`, `cache.py`, `thread.py`, `state.py` | | `app/runtime/` | 进程级运行机制和策略:配置、事件、完整日志、缓存契约/内存行为、托管资源门面、并发、调度、限流、本地化、GC 和重启状态 | 具体外部产品、业务流程、Redis/文件缓存实现 | `config.py`, `events.py`, `log.py`, `cache.py`, `managed_resources.py`, `thread.py`, `state.py` |
| `app/runtime/extensions/` | 模块、插件配置化服务实现的发现、注册与生命周期 | 通用反射机制、插件公开 API、无关业务流程 | `module_manager.py`, `plugin_manager.py`, `service_registry.py` | | `app/runtime/extensions/` | 模块、插件配置化服务和托管资源实现的发现、注册与生命周期适配 | 通用反射机制、插件公开 API、无关业务流程 | `module_manager.py`, `plugin_manager.py`, `managed_resource_adapter.py`, `service_registry.py` |
| `app/adapters/network/` | HTTP、浏览器、DNS、Cloudflare 和 IP 等通用网络技术适配 | RSS/站点业务编排、身份认证策略、命名外部产品流程 | `http.py`, `browser.py`, `doh.py`, `ip.py` | | `app/adapters/network/` | HTTP、浏览器、DNS、Cloudflare 和 IP 等通用网络技术适配 | RSS/站点业务编排、身份认证策略、命名外部产品流程 | `http.py`, `browser.py`, `doh.py`, `ip.py` |
| `app/adapters/cache/` | Redis 与文件缓存等具体持久化实现 | 缓存协议、装饰器和进程内缓存策略 | `backends.py`, `redis.py` | | `app/adapters/cache/` | Redis 与文件缓存等具体持久化实现 | 缓存协议、装饰器和进程内缓存策略 | `backends.py`, `redis.py` |
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` | | `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `display/`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` | | `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` |
| `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `filter_rules.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` | | `app/application/` | 读取配置/持久化状态的聚焦应用服务和服务族规则 | 多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `filter_rules.py`, `notification.py`, `mediaserver.py`, `rss.py`, `site/sites.*` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` | | `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` | | `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` | | `app/chain/` | Reusable use-case orchestration across modules, services, Oper classes, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/startup/` | Composition root: inject providers/adapters, order initialization and shutdown, decide restart/lifecycle policy | Reusable business rules or adapter implementation details | `lifecycle.py`, `domain_initializer.py`, `cache_initializer.py`, `modules_initializer.py` | | `app/startup/` | Composition root: inject providers/adapters, order initialization and shutdown, decide restart/lifecycle policy | Reusable business rules or adapter implementation details | `lifecycle.py`, `domain_initializer.py`, `cache_initializer.py`, `managed_resources_initializer.py`, `modules_initializer.py` |
| `app/sdk/` | Deliberately curated stable imports for new plugins | Canonical implementation logic or host-internal dependencies | `cache.py`, `logging.py`, `media.py`, `network.py`, `services.py` | | `app/sdk/` | Deliberately curated stable imports for new plugins | Canonical implementation logic or host-internal dependencies | `browser.py`, `cache.py`, `logging.py`, `media.py`, `network.py`, `services.py` |
| `app/runtime/compat/` | 仅依赖标准库的精确旧导入路由和 DEBUG 诊断 | 业务实现、通配猜测、目标模块的提前导入 | `manifest.py`, `imports.py`, `diagnostics.py` | | `app/runtime/compat/` | 仅依赖标准库的精确旧导入路由、资源前置扫描和 DEBUG 诊断 | 业务实现、通配猜测、目标模块的提前导入 | `manifest.py`, `imports.py`, `resource_imports.py`, `diagnostics.py` |
容易误分的三个边界必须按实际职责判断:`application/rss.py` 同时承担 Feed/种子语义、站点规则和浏览器回退,不是单纯 HTTP 传输;`application/site/sites.*``user.sites.v3.bin` 共同构成站点目录、认证和索引应用能力,只有下载安装机制留在 `adapters/system/resource.py``foundation/crypto.py` 只提供无状态 RSA/摘要/AES 算法,认证、签名、令牌和二次验证策略仍属于 `application/security/` 容易误分的三个边界必须按实际职责判断:`application/rss.py` 同时承担 Feed/种子语义、站点规则和浏览器回退,不是单纯 HTTP 传输;`application/site/sites.*``user.sites.v3.bin` 共同构成站点目录、认证和索引应用能力,只有下载安装机制留在 `adapters/system/resource.py``foundation/crypto.py` 只提供无状态 RSA/摘要/AES 算法,认证、签名、令牌和二次验证策略仍属于 `application/security/`
@@ -156,4 +156,4 @@ For the full documentation map and cross-references, refer to:
**[Documentation Hub Index](./docs/rules/README.md)** **[Documentation Hub Index](./docs/rules/README.md)**
*Last Updated: 2026-08-15* *Last Updated: 2026-08-16*
+51 -11
View File
@@ -9,6 +9,10 @@ from urllib.parse import urlparse
from app.runtime.config import settings from app.runtime.config import settings
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.managed_resources import (
acquire_managed_resource,
acquire_managed_resource_async,
)
from app.adapters.network.http import RequestUtils, cookie_parse from app.adapters.network.http import RequestUtils, cookie_parse
@@ -117,6 +121,47 @@ class BrowserPage(Protocol):
... ...
def launch_browser_context(headless: bool = True, **kwargs: Any) -> BrowserContext:
"""
启动同步浏览器上下文;有界面模式先显式获取宿主显示资源。
:param headless: 是否使用无头模式
:param kwargs: 浏览器实现接受的其余启动参数
:return: 浏览器上下文
"""
if not headless:
acquire_managed_resource(
"host.display",
reason="headed_browser_launch",
retry=True,
)
from cloakbrowser import launch_context
return launch_context(headless=headless, **kwargs)
async def launch_browser_context_async(
headless: bool = True,
**kwargs: Any,
) -> Any:
"""
启动异步浏览器上下文;有界面模式等待宿主显示资源就绪。
:param headless: 是否使用无头模式
:param kwargs: 浏览器实现接受的其余启动参数
:return: 浏览器上下文
"""
if not headless:
await acquire_managed_resource_async(
"host.display",
reason="headed_browser_launch",
retry=True,
)
from cloakbrowser import launch_context_async
return await launch_context_async(headless=headless, **kwargs)
@dataclass @dataclass
class _BrowserSessionState: class _BrowserSessionState:
"""保存一个可复用浏览器上下文及其页面游标。""" """保存一个可复用浏览器上下文及其页面游标。"""
@@ -662,10 +707,7 @@ class BrowserSessionHelper:
viewport: Optional[dict[str, int]] = None, viewport: Optional[dict[str, int]] = None,
) -> BrowserContext: ) -> BrowserContext:
"""按宿主反检测配置创建 CloakBrowser 上下文。""" """按宿主反检测配置创建 CloakBrowser 上下文。"""
from cloakbrowser import launch_context
context_kwargs = { context_kwargs = {
"headless": headless,
"humanize": settings.CLOAKBROWSER_HUMANIZE, "humanize": settings.CLOAKBROWSER_HUMANIZE,
"human_preset": settings.CLOAKBROWSER_HUMAN_PRESET, "human_preset": settings.CLOAKBROWSER_HUMAN_PRESET,
} }
@@ -673,7 +715,7 @@ class BrowserSessionHelper:
context_kwargs["user_agent"] = user_agent context_kwargs["user_agent"] = user_agent
if viewport: if viewport:
context_kwargs["viewport"] = viewport context_kwargs["viewport"] = viewport
return launch_context(**context_kwargs) return launch_browser_context(headless=headless, **context_kwargs)
def _get_or_create_session( def _get_or_create_session(
self, self,
@@ -883,13 +925,11 @@ class PlaywrightHelper:
""" """
启动 CloakBrowser 上下文。 启动 CloakBrowser 上下文。
""" """
from cloakbrowser import launch_context return launch_browser_context(headless=headless,
proxy=proxies,
return launch_context(headless=headless, user_agent=user_agent,
proxy=proxies, humanize=settings.CLOAKBROWSER_HUMANIZE,
user_agent=user_agent, human_preset=settings.CLOAKBROWSER_HUMAN_PRESET)
humanize=settings.CLOAKBROWSER_HUMANIZE,
human_preset=settings.CLOAKBROWSER_HUMAN_PRESET)
@staticmethod @staticmethod
def __fs_cookie_str(cookies: list) -> str: def __fs_cookie_str(cookies: list) -> str:
-29
View File
@@ -1,29 +0,0 @@
from pyvirtualdisplay import Display
from app.runtime.log import logger
from app.foundation.singleton import Singleton
from app.adapters.system.host import SystemUtils
import os
class DisplayHelper(metaclass=Singleton):
"""在容器环境中管理浏览器所需的虚拟显示。"""
def __init__(self):
"""仅在 Docker 内启动虚拟显示服务。"""
self._display = None
if not SystemUtils.is_docker():
return
try:
self._display = Display(visible=False, size=(1024, 768), extra_args=[os.environ['DISPLAY']])
self._display.start()
except Exception as err:
logger.error(f"DisplayHelper init error: {str(err)}")
def stop(self):
"""停止已经启动的虚拟显示服务。"""
if self._display:
logger.info("正在停止虚拟显示...")
self._display.stop()
logger.info("虚拟显示已停止")
+53
View File
@@ -0,0 +1,53 @@
"""虚拟显示适配器及旧 DisplayHelper 兼容入口。"""
from __future__ import annotations
from importlib import import_module
from typing import Any
from app.foundation.singleton import Singleton
from app.runtime.log import logger
from app.runtime.managed_resources import (
acquire_managed_resource,
stop_managed_resource,
)
DISPLAY_CAPABILITY_ID = "host.display"
class DisplayHelper(metaclass=Singleton):
"""保留旧构造 API,并把资源所有权委托给 host.display 能力。"""
def __init__(self) -> None:
"""显式构造旧门面时激活虚拟显示,失败保持旧 API 的日志语义。"""
try:
acquire_managed_resource(
DISPLAY_CAPABILITY_ID,
reason="legacy_display_helper",
retry=True,
)
except Exception as error:
logger.error("DisplayHelper init error: %s", error)
def stop(self) -> None:
"""停止已激活的虚拟显示;未配置 Runtime 时保持幂等。"""
stop_managed_resource(
DISPLAY_CAPABILITY_ID,
reason="legacy_display_helper_stop",
)
__all__ = ["DISPLAY_CAPABILITY_ID", "DisplayHelper", "VirtualDisplayResource"]
def __getattr__(name: str) -> Any:
"""按需公开资源实现,普通兼容导入不加载显示后端。"""
if name != "VirtualDisplayResource":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
value = getattr(
import_module("app.adapters.system.display.resource"),
"VirtualDisplayResource",
)
globals()[name] = value
return value
@@ -0,0 +1,12 @@
schema_version = 1
id = "host.display"
kind = "managed_resource.sync"
entrypoint = "app.adapters.system.display.resource:VirtualDisplayResource"
depends_on = []
[metadata]
name = "Virtual Display"
[activation]
policy = "on_first_use"
watch = []
+45
View File
@@ -0,0 +1,45 @@
"""虚拟显示进程的托管资源实现。"""
from __future__ import annotations
import os
from typing import Any, Optional
from app.adapters.system.host import SystemUtils
from app.runtime.log import logger
class VirtualDisplayResource:
"""按需拥有一个容器内虚拟显示进程。"""
def __init__(self) -> None:
self._display: Optional[Any] = None
@property
def display(self) -> Optional[Any]:
"""返回当前拥有的显示对象;未启动或已停止时为 None。"""
return self._display
def start(self) -> None:
"""仅在容器环境启动虚拟显示,重复启动保持幂等。"""
if self._display is not None or not SystemUtils.is_docker():
return
from pyvirtualdisplay import Display
display = Display(
visible=False,
size=(1024, 768),
extra_args=[os.environ["DISPLAY"]],
)
self._display = display
display.start()
def stop(self) -> None:
"""停止当前资源拥有的显示进程,失败时保留句柄供 Runtime 重试。"""
display = self._display
if display is None:
return
logger.info("正在停止虚拟显示...")
display.stop()
self._display = None
logger.info("虚拟显示已停止")
+8
View File
@@ -0,0 +1,8 @@
"""Agent Capability 声明与通用入口适配器。"""
AGENT_ENTRYPOINT_KIND = "agent_entrypoint"
AGENT_SERVICE_KIND = "agent_service"
AGENT_MANAGER_CAPABILITY_ID = "agent.manager"
AGENT_SERVICE_CAPABILITY_ID = "agent.service"
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID = "agent.moviepilot_type"
TOOL_FACTORY_CAPABILITY_ID = "agent.tool_factory"
+218
View File
@@ -0,0 +1,218 @@
"""Agent canonical entrypoint 的 Capability Runtime 适配器。"""
from __future__ import annotations
import asyncio
import importlib
import inspect
from pathlib import Path
from typing import Any, Iterable, Mapping
from app.agent.capabilities import AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND
from app.runtime.capabilities.errors import CapabilityAdapterContractError
from app.runtime.capabilities.model import (
ActivationPolicy,
AdapterExecutionMode,
CapabilitySpec,
SelectorSchema,
)
from app.runtime.capabilities.registry import CapabilityRegistry
from app.runtime.config import settings
_DEFAULT_CAPABILITY_ROOT = Path(__file__).resolve().parent
_SETTING_SELECTOR = "setting_truthy"
def _validate_setting_selector(config: Mapping[str, Any]) -> None:
"""限制 selector 只能读取已声明的应用设置。"""
key = config["key"]
if not isinstance(key, str) or not key or not hasattr(settings, key):
raise ValueError(f"未知应用设置:{key!r}")
AGENT_SELECTOR_SCHEMAS = {
_SETTING_SELECTOR: SelectorSchema(
required_fields=frozenset({"key"}),
validator=_validate_setting_selector,
)
}
def _load_entrypoint(spec: CapabilitySpec) -> Any:
"""按 manifest 解析 canonical 符号,不创建额外业务对象。"""
module_name, symbol_name = spec.entrypoint.split(":", maxsplit=1)
module = importlib.import_module(module_name)
try:
return getattr(module, symbol_name)
except AttributeError as error:
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 未公开 Agent entrypoint"
) from error
def _lifecycle_method(spec: CapabilitySpec, candidate: Any, name: str) -> Any:
"""读取 Agent Service 必需的异步生命周期方法。"""
callback = getattr(candidate, name, None)
if not callable(callback):
raise CapabilityAdapterContractError(
f"{spec.entrypoint} 的 Agent Service 缺少 {name}()"
)
return callback
class AgentEntrypointAdapter:
"""把 canonical Python 符号作为无资源副作用的同步能力发布。"""
execution_mode = AdapterExecutionMode.SYNC
@staticmethod
def materialize(spec: CapabilitySpec) -> Any:
"""按 manifest entrypoint 导入 canonical 符号。"""
return _load_entrypoint(spec)
@staticmethod
def create(
_spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""发布 canonical 符号本身,不创建第二份业务对象。"""
return implementation
@staticmethod
def start(
_spec: CapabilitySpec,
_candidate: Any,
_generation: int,
) -> None:
"""entrypoint 不拥有业务资源,初始化由独立 service 能力负责。"""
@staticmethod
def stop(
_spec: CapabilitySpec,
_instance: Any,
_generation: int,
) -> None:
"""撤销入口可见性;业务资源由独立 service 能力关闭。"""
@staticmethod
def cleanup(
_spec: CapabilitySpec,
_candidate: Any,
_generation: int,
_error: BaseException,
) -> None:
"""entrypoint 启动无副作用,因此失败候选无需额外释放。"""
class AgentServiceAdapter:
"""把具备 initialize/close 的 canonical 对象接入异步资源生命周期。"""
execution_mode = AdapterExecutionMode.ASYNC
@staticmethod
async def materialize(spec: CapabilitySpec) -> Any:
"""在线程中导入 canonical service,避免阻塞应用事件循环。"""
return await asyncio.to_thread(_load_entrypoint, spec)
@staticmethod
async def create(
_spec: CapabilitySpec,
implementation: Any,
_generation: int,
_previous: Any = None,
) -> Any:
"""复用 canonical service,不复制其内部队列和后台任务所有权。"""
return implementation
@staticmethod
async def start(
spec: CapabilitySpec,
candidate: Any,
_generation: int,
) -> None:
"""等待 service 在当前应用事件循环完成初始化。"""
result = _lifecycle_method(spec, candidate, "initialize")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.initialize() 必须返回 awaitable"
)
await result
@staticmethod
async def stop(
spec: CapabilitySpec,
instance: Any,
_generation: int,
) -> None:
"""等待 service 停止后台任务并释放其资源。"""
result = _lifecycle_method(spec, instance, "close")()
if not inspect.isawaitable(result):
raise CapabilityAdapterContractError(
f"{spec.entrypoint}.close() 必须返回 awaitable"
)
await result
@staticmethod
async def cleanup(
spec: CapabilitySpec,
candidate: Any,
generation: int,
_error: BaseException,
) -> None:
"""初始化失败或关闭竞态时按相同 close 合同释放部分资源。"""
await AgentServiceAdapter.stop(spec, candidate, generation)
def _validate_registry(registry: CapabilityRegistry) -> None:
"""固定 entrypoint 物化轴与 service 资源轴的声明合同。"""
for spec in registry.list_specs():
if set(spec.metadata) != {"name"}:
raise ValueError(f"{spec.source}: Agent Capability metadata 只能包含 name")
if spec.kind == AGENT_ENTRYPOINT_KIND:
if spec.activation is not ActivationPolicy.ON_FIRST_USE:
raise ValueError(
f"{spec.source}: Agent entrypoint 必须使用 on_first_use"
)
if spec.selector is not None or spec.watch:
raise ValueError(
f"{spec.source}: Agent entrypoint 不接受 selector 或 watch"
)
continue
if spec.activation is not ActivationPolicy.WHEN_CONFIGURED:
raise ValueError(f"{spec.source}: Agent Service 必须使用 when_configured")
selector = spec.selector
if selector is None or selector.kind != _SETTING_SELECTOR:
raise ValueError(f"{spec.source}: Agent Service 必须声明 setting_truthy")
selector_key = str(selector.config["key"])
if spec.watch != (selector_key,):
raise ValueError(
f"{spec.source}: Agent Service watch 必须只包含 selector key"
)
def build_agent_capability_registry(
roots: Iterable[Path | str] | None = None,
) -> CapabilityRegistry:
"""发现 data-only Agent manifests,不导入编排器、Provider 或工具实现。"""
registry = CapabilityRegistry.discover(
tuple(roots) if roots is not None else (_DEFAULT_CAPABILITY_ROOT,),
kinds={AGENT_ENTRYPOINT_KIND, AGENT_SERVICE_KIND},
selector_schemas=AGENT_SELECTOR_SCHEMAS,
)
_validate_registry(registry)
return registry
def should_run_agent_service(spec: CapabilitySpec) -> bool:
"""依据 manifest selector 判断 service 是否应拥有运行实例。"""
selector = spec.selector
if (
spec.kind != AGENT_SERVICE_KIND
or selector is None
or selector.kind != _SETTING_SELECTOR
):
raise ValueError(f"{spec.source}: 不是可协调的 Agent Service 声明")
return bool(getattr(settings, selector.config["key"]))
@@ -0,0 +1,12 @@
schema_version = 1
id = "agent.manager"
kind = "agent_entrypoint"
entrypoint = "app.agent.orchestrator:agent_manager"
depends_on = []
[metadata]
name = "Agent Manager"
[activation]
policy = "on_first_use"
watch = []
@@ -0,0 +1,12 @@
schema_version = 1
id = "agent.moviepilot_type"
kind = "agent_entrypoint"
entrypoint = "app.agent.orchestrator:MoviePilotAgent"
depends_on = []
[metadata]
name = "MoviePilot Agent Type"
[activation]
policy = "on_first_use"
watch = []
@@ -0,0 +1,16 @@
schema_version = 1
id = "agent.service"
kind = "agent_service"
entrypoint = "app.agent.orchestrator:agent_manager"
depends_on = []
[metadata]
name = "Agent Service"
[activation]
policy = "when_configured"
watch = ["AI_AGENT_ENABLE"]
[activation.selector]
kind = "setting_truthy"
key = "AI_AGENT_ENABLE"
@@ -0,0 +1,12 @@
schema_version = 1
id = "agent.tool_factory"
kind = "agent_entrypoint"
entrypoint = "app.agent.tools.factory:MoviePilotToolFactory"
depends_on = []
[metadata]
name = "Agent Tool Factory"
[activation]
policy = "on_first_use"
watch = []
+35
View File
@@ -0,0 +1,35 @@
"""Agent 轻量公共合同,不触发模型、工具或编排运行时加载。"""
import uuid
from datetime import datetime
from typing import Any, Optional
from app.schemas.types import ReplyMode
def build_display_message(
role: str,
content: str = "",
attachments: Optional[list[dict]] = None,
status: str = "done",
) -> dict[str, Any]:
"""构造前后端共享的 Agent 会话展示消息。"""
normalized_content = content or ""
return {
"id": f"{role}-{uuid.uuid4().hex}",
"role": role,
"content": normalized_content,
"createdAt": int(datetime.now().timestamp() * 1000),
"status": status,
"tools": [],
"segments": (
[{"type": "text", "content": normalized_content}]
if normalized_content
else []
),
"attachments": attachments or [],
"choices": [],
}
__all__ = ["ReplyMode", "build_display_message"]
+52 -16
View File
@@ -1,20 +1,56 @@
"""Agent 内部使用的 LLM 适配层。""" """Agent 内部使用的 LLM 适配层,公开对象按需解析"""
from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout from importlib import import_module
from app.agent.llm.capability import ( from typing import TYPE_CHECKING, Any
AgentCapabilityManager,
AgentCapabilityProvider, if TYPE_CHECKING:
AudioCapabilityProvider, from app.agent.llm.capability import (
MiMoAudioProvider, AgentCapabilityManager,
OpenAIChatAudioProvider, AgentCapabilityProvider,
OpenAIAudioProvider, AudioCapabilityProvider,
) MiMoAudioProvider,
from app.agent.llm.provider import ( OpenAIAudioProvider,
LLMProviderAuthError, OpenAIChatAudioProvider,
LLMProviderError, )
LLMProviderManager, from app.agent.llm.helper import LLMHelper, LLMTestError, LLMTestTimeout
render_auth_result_html, from app.agent.llm.provider import (
) LLMProviderAuthError,
LLMProviderError,
LLMProviderManager,
render_auth_result_html,
)
_EXPORT_MODULES = {
"LLMHelper": "app.agent.llm.helper",
"LLMTestError": "app.agent.llm.helper",
"LLMTestTimeout": "app.agent.llm.helper",
"AgentCapabilityManager": "app.agent.llm.capability",
"AgentCapabilityProvider": "app.agent.llm.capability",
"AudioCapabilityProvider": "app.agent.llm.capability",
"MiMoAudioProvider": "app.agent.llm.capability",
"OpenAIChatAudioProvider": "app.agent.llm.capability",
"OpenAIAudioProvider": "app.agent.llm.capability",
"LLMProviderAuthError": "app.agent.llm.provider",
"LLMProviderError": "app.agent.llm.provider",
"LLMProviderManager": "app.agent.llm.provider",
"render_auth_result_html": "app.agent.llm.provider",
}
def __getattr__(name: str) -> Any:
"""首次访问公开对象时只加载其所属适配模块。"""
module_name = _EXPORT_MODULES.get(name)
if module_name is None:
raise AttributeError(f"module 'app.agent.llm' has no attribute {name!r}")
value = getattr(import_module(module_name), name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""让延迟公开对象继续支持交互式发现。"""
return sorted(set(globals()) | set(_EXPORT_MODULES))
__all__ = [ __all__ = [
"LLMHelper", "LLMHelper",
+157 -123
View File
@@ -4,9 +4,9 @@ import json
import re import re
import traceback import traceback
import uuid import uuid
import warnings
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
@@ -17,12 +17,10 @@ from langchain_core.messages import ( # noqa: F401
SystemMessage, SystemMessage,
) )
import warnings
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler from app.agent.callback import StreamingHandler
from app.agent.contracts import ReplyMode, build_display_message
from app.agent.llm import LLMHelper from app.agent.llm import LLMHelper
from app.agent.llm.server_tools import ServerToolRegistry from app.agent.llm.server_tools import ServerToolRegistry
from app.agent.memory import memory_manager from app.agent.memory import memory_manager
@@ -61,14 +59,13 @@ from app.agent.policy import (
) )
from app.agent.runtime import agent_runtime_manager from app.agent.runtime import agent_runtime_manager
from app.agent.mcp import agent_mcp_manager from app.agent.mcp import agent_mcp_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.catalog import ToolCatalogSnapshot from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.impl.mcp import ( from app.agent.tools.impl.mcp import (
create_external_mcp_tools, create_external_mcp_tools,
select_legacy_mcp_tools, select_legacy_mcp_tools,
) )
from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool 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.config import settings
from app.runtime.events import eventmanager from app.runtime.events import eventmanager
from app.runtime.extensions.plugin_manager import PluginManager from app.runtime.extensions.plugin_manager import PluginManager
@@ -81,11 +78,7 @@ from app.schemas.notification import ChannelCapabilityManager, ChannelCapability
from app.schemas.types import ChainEventType, EventType, NotificationChannel from app.schemas.types import ChainEventType, EventType, NotificationChannel
from app.foundation.identity import SYSTEM_INTERNAL_USER_ID from app.foundation.identity import SYSTEM_INTERNAL_USER_ID
warnings.filterwarnings("ignore", message=".*allowed_objects.*")
class AgentChain(ChainBase):
"""Agent 业务处理链。"""
pass
def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None: def _finish_processing_status(status: Optional[dict], user_id: Optional[str] = None) -> None:
@@ -321,15 +314,6 @@ class _ThinkTagStripper:
self.buffer = "" self.buffer = ""
class ReplyMode(str, Enum):
"""
Agent 最终回复处理模式。
"""
DISPATCH = "dispatch"
CAPTURE_ONLY = "capture_only"
HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_" HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_"
UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。" UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败" AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
@@ -408,11 +392,6 @@ class MoviePilotAgent:
# 流式token管理 # 流式token管理
self.stream_handler = StreamingHandler() self.stream_handler = StreamingHandler()
@staticmethod
def _current_timestamp_ms() -> int:
"""返回当前毫秒时间戳。"""
return int(datetime.now().timestamp() * 1000)
@classmethod @classmethod
def build_display_message( def build_display_message(
cls, cls,
@@ -424,22 +403,12 @@ class MoviePilotAgent:
""" """
构造可展示的 Agent 会话消息。 构造可展示的 Agent 会话消息。
""" """
normalized_content = content or "" return build_display_message(
return { role=role,
"id": f"{role}-{uuid.uuid4().hex}", content=content,
"role": role, attachments=attachments,
"content": normalized_content, status=status,
"createdAt": cls._current_timestamp_ms(), )
"status": status,
"tools": [],
"segments": (
[{"type": "text", "content": normalized_content}]
if normalized_content
else []
),
"attachments": attachments or [],
"choices": [],
}
def _should_save_display_history(self) -> bool: def _should_save_display_history(self) -> bool:
""" """
@@ -1575,7 +1544,9 @@ class MoviePilotAgent:
""" """
初始化主 Agent 本地工具实例。 初始化主 Agent 本地工具实例。
""" """
return MoviePilotToolFactory.create_tools( from app.agent.runtime_loader import get_tool_factory
return get_tool_factory().create_tools(
session_id=self.session_id, session_id=self.session_id,
user_id=self.user_id, user_id=self.user_id,
channel=self.channel, channel=self.channel,
@@ -1590,14 +1561,17 @@ class MoviePilotAgent:
self, self,
) -> tuple[ToolCatalogSnapshot, ToolCatalogSnapshot]: ) -> tuple[ToolCatalogSnapshot, ToolCatalogSnapshot]:
"""在同一插件 revision 窗口内建立主图和子图工具目录。""" """在同一插件 revision 窗口内建立主图和子图工具目录。"""
from app.agent.runtime_loader import get_tool_factory
tool_factory = get_tool_factory()
plugin_manager = PluginManager() plugin_manager = PluginManager()
for _attempt in range(MoviePilotToolFactory.CATALOG_BUILD_MAX_ATTEMPTS): for _attempt in range(tool_factory.CATALOG_BUILD_MAX_ATTEMPTS):
before_revision = plugin_manager.get_plugin_agent_tools_revision() before_revision = plugin_manager.get_plugin_agent_tools_revision()
tools = self._initialize_tools() tools = self._initialize_tools()
subagent_tools = self._initialize_subagent_tools() subagent_tools = self._initialize_subagent_tools()
after_revision = plugin_manager.get_plugin_agent_tools_revision() after_revision = plugin_manager.get_plugin_agent_tools_revision()
if before_revision == after_revision: if before_revision == after_revision:
factory_revision = MoviePilotToolFactory.catalog_factory_revision() factory_revision = tool_factory.catalog_factory_revision()
return ( return (
ToolCatalogSnapshot.from_tools( ToolCatalogSnapshot.from_tools(
tools, tools,
@@ -1685,12 +1659,19 @@ class MoviePilotAgent:
(tool_catalog.signature, subagent_catalog.signature) (tool_catalog.signature, subagent_catalog.signature)
if tool_catalog is not None and subagent_catalog is not None if tool_catalog is not None and subagent_catalog is not None
else ( else (
MoviePilotToolFactory.catalog_factory_revision(), self._tool_factory_revision(),
PluginManager().get_plugin_agent_tools_revision(), PluginManager().get_plugin_agent_tools_revision(),
) )
), ),
) )
@staticmethod
def _tool_factory_revision() -> str:
"""在目录签名确实需要时解析工具工厂版本。"""
from app.agent.runtime_loader import get_tool_factory
return get_tool_factory().catalog_factory_revision()
def _get_cached_agent( def _get_cached_agent(
self, signature: tuple[Any, ...], streaming: bool self, signature: tuple[Any, ...], streaming: bool
) -> Optional[Any]: ) -> Optional[Any]:
@@ -1737,7 +1718,9 @@ class MoviePilotAgent:
""" """
初始化子代理专用静默工具列表。 初始化子代理专用静默工具列表。
""" """
return MoviePilotToolFactory.create_tools( from app.agent.runtime_loader import get_tool_factory
return get_tool_factory().create_tools(
session_id=self.session_id, session_id=self.session_id,
user_id=self.user_id, user_id=self.user_id,
channel=self.channel, channel=self.channel,
@@ -1922,8 +1905,10 @@ class MoviePilotAgent:
logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}") logger.debug(f"复用会话内 Agent 图: session_id={self.session_id}")
return cached_agent return cached_agent
max_tools = settings.LLM_MAX_TOOLS max_tools = settings.LLM_MAX_TOOLS
from app.agent.runtime_loader import get_tool_factory
always_include_tools = ( always_include_tools = (
MoviePilotToolFactory.get_tool_selector_always_include_names(tools) get_tool_factory().get_tool_selector_always_include_names(tools)
) )
if subagent_task_tools: if subagent_task_tools:
always_include_tools.extend( always_include_tools.extend(
@@ -2453,9 +2438,16 @@ class _MessageTask:
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None
message_callback: Optional[Callable[[Any], None]] = None message_callback: Optional[Callable[[Any], None]] = None
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None agent_factory: Optional[Callable[..., MoviePilotAgent]] = None
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None
completion_future: Optional[asyncio.Future] = None completion_future: Optional[asyncio.Future] = None
class AgentManagerUnavailableError(RuntimeError):
"""AgentManager 未运行或已开始关闭,不能再接收新任务。"""
code = "agent_manager_unavailable"
class AgentManager: class AgentManager:
""" """
AI智能体管理器 AI智能体管理器
@@ -2473,6 +2465,9 @@ class AgentManager:
self._idle_cleanup_task: Optional[asyncio.Task] = None self._idle_cleanup_task: Optional[asyncio.Task] = None
self._idle_session_ttl = timedelta(hours=24) self._idle_session_ttl = timedelta(hours=24)
self._idle_cleanup_interval = 60 * 60 self._idle_cleanup_interval = 60 * 60
# 接收门禁与队列写入共用一把锁,确保关闭开始后不会再创建 worker。
self._lifecycle_lock = asyncio.Lock()
self._accepting_tasks = False
def get_session_status(self, session_id: str) -> dict[str, Any]: def get_session_status(self, session_id: str) -> dict[str, Any]:
"""获取会话当前模型与 token 使用状态。""" """获取会话当前模型与 token 使用状态。"""
@@ -2519,40 +2514,51 @@ class AgentManager:
""" """
初始化管理器 初始化管理器
""" """
memory_manager.initialize() async with self._lifecycle_lock:
if self._idle_cleanup_task and not self._idle_cleanup_task.done(): if self._accepting_tasks:
return return
self._idle_cleanup_task = asyncio.create_task(self._cleanup_idle_sessions()) memory_manager.initialize()
if not self._idle_cleanup_task or self._idle_cleanup_task.done():
self._idle_cleanup_task = asyncio.create_task(
self._cleanup_idle_sessions()
)
self._accepting_tasks = True
async def close(self): async def close(self):
""" """
关闭管理器 关闭管理器
""" """
if self._idle_cleanup_task: async with self._lifecycle_lock:
self._idle_cleanup_task.cancel() # 门禁必须先关闭;锁内完成清理可阻止等待中的请求在收口期间重新入队。
try: self._accepting_tasks = False
await self._idle_cleanup_task if self._idle_cleanup_task:
except asyncio.CancelledError: self._idle_cleanup_task.cancel()
pass try:
self._idle_cleanup_task = None await self._idle_cleanup_task
await memory_manager.close() except asyncio.CancelledError:
# 取消所有会话worker pass
for task in list(self._session_workers.values()): self._idle_cleanup_task = None
task.cancel() # 取消所有会话worker
# 等待所有worker结束 for task in list(self._session_workers.values()):
for session_id, task in list(self._session_workers.items()): task.cancel()
try: # 等待所有worker结束
await task for session_id, task in list(self._session_workers.items()):
except asyncio.CancelledError: try:
pass await task
self._session_workers.clear() except asyncio.CancelledError:
for queue in list(self._session_queues.values()): pass
self._discard_queued_messages(queue) self._session_workers.clear()
self._session_queues.clear() for queue in list(self._session_queues.values()):
self._session_last_used.clear() self._discard_queued_messages(
for agent in list(self.active_agents.values()): queue,
await agent.cleanup() error=AgentManagerUnavailableError("AgentManager 已关闭"),
self.active_agents.clear() )
self._session_queues.clear()
self._session_last_used.clear()
for agent in list(self.active_agents.values()):
await agent.cleanup()
self.active_agents.clear()
await memory_manager.close()
def _record_session_activity(self, session_id: str, user_id: str) -> None: def _record_session_activity(self, session_id: str, user_id: str) -> None:
""" """
@@ -2622,6 +2628,7 @@ class AgentManager:
protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None, protected_output_callback: Optional[Callable[[str], Optional[bool]]] = None,
message_callback: Optional[Callable[[Any], None]] = None, message_callback: Optional[Callable[[Any], None]] = None,
agent_factory: Optional[Callable[..., MoviePilotAgent]] = None, agent_factory: Optional[Callable[..., MoviePilotAgent]] = None,
agent_setup: Optional[Callable[[MoviePilotAgent], None]] = None,
wait_for_completion: bool = False, wait_for_completion: bool = False,
) -> str: ) -> str:
""" """
@@ -2650,38 +2657,40 @@ class AgentManager:
protected_output_callback=protected_output_callback, protected_output_callback=protected_output_callback,
message_callback=message_callback, message_callback=message_callback,
agent_factory=agent_factory, agent_factory=agent_factory,
agent_setup=agent_setup,
completion_future=completion_future, completion_future=completion_future,
) )
self._record_session_activity(session_id, user_id) async with self._lifecycle_lock:
if not self._accepting_tasks:
raise AgentManagerUnavailableError("AgentManager 未运行或已关闭")
self._record_session_activity(session_id, user_id)
# 获取或创建会话队列 # 获取或创建会话队列
if session_id not in self._session_queues: if session_id not in self._session_queues:
self._session_queues[session_id] = asyncio.Queue() self._session_queues[session_id] = asyncio.Queue()
queue = self._session_queues[session_id] queue = self._session_queues[session_id]
queue_size = queue.qsize() queue_size = queue.qsize()
# 如果队列中已有等待的消息,通知用户消息已排队 # 如果队列中已有等待的消息,通知用户消息已排队
if queue_size > 0 or ( if queue_size > 0 or (
session_id in self._session_workers session_id in self._session_workers
and not self._session_workers[session_id].done() and not self._session_workers[session_id].done()
): ):
logger.info( logger.info(
f"会话 {session_id} 有任务正在处理,消息已排队等待 " f"会话 {session_id} 有任务正在处理,消息已排队等待 "
f"(队列中待处理: {queue_size} 条)" f"(队列中待处理: {queue_size} 条)"
) )
# 放入队列 # 放入队列并创建 worker 与关闭门禁保持原子关系。
await queue.put(task) await queue.put(task)
if (
# 确保该会话有一个worker在运行 session_id not in self._session_workers
if ( or self._session_workers[session_id].done()
session_id not in self._session_workers ):
or self._session_workers[session_id].done() self._session_workers[session_id] = asyncio.create_task(
): self._session_worker(session_id)
self._session_workers[session_id] = asyncio.create_task( )
self._session_worker(session_id)
)
if completion_future: if completion_future:
return await completion_future return await completion_future
@@ -2713,7 +2722,12 @@ class AgentManager:
task.completion_future.set_result(result) task.completion_future.set_result(result)
except asyncio.CancelledError: except asyncio.CancelledError:
if task.completion_future and not task.completion_future.done(): if task.completion_future and not task.completion_future.done():
task.completion_future.cancel() if self._accepting_tasks:
task.completion_future.cancel()
else:
task.completion_future.set_exception(
AgentManagerUnavailableError("AgentManager 已关闭")
)
raise raise
except Exception as e: except Exception as e:
logger.error(f"处理会话 {session_id} 的消息失败: {e}") logger.error(f"处理会话 {session_id} 的消息失败: {e}")
@@ -2738,7 +2752,10 @@ class AgentManager:
self._session_queues.pop(session_id, None) self._session_queues.pop(session_id, None)
@staticmethod @staticmethod
def _discard_queued_messages(queue: asyncio.Queue) -> None: def _discard_queued_messages(
queue: asyncio.Queue,
error: Optional[Exception] = None,
) -> None:
"""丢弃会话队列时同步结束等待任务完成的调用方。""" """丢弃会话队列时同步结束等待任务完成的调用方。"""
while not queue.empty(): while not queue.empty():
try: try:
@@ -2746,7 +2763,10 @@ class AgentManager:
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
if task.completion_future and not task.completion_future.done(): if task.completion_future and not task.completion_future.done():
task.completion_future.cancel() if error is None:
task.completion_future.cancel()
else:
task.completion_future.set_exception(error)
queue.task_done() queue.task_done()
@staticmethod @staticmethod
@@ -2825,6 +2845,9 @@ class AgentManager:
if task.message_callback is not None and hasattr(agent, "set_message_callback"): if task.message_callback is not None and hasattr(agent, "set_message_callback"):
agent.set_message_callback(task.message_callback) agent.set_message_callback(task.message_callback)
if task.agent_setup is not None:
task.agent_setup(agent)
process_kwargs = { process_kwargs = {
"images": task.images, "images": task.images,
"files": task.files, "files": task.files,
@@ -2839,6 +2862,11 @@ class AgentManager:
与 clear_session 不同,此方法不会销毁Agent实例或清除记忆, 与 clear_session 不同,此方法不会销毁Agent实例或清除记忆,
用户可以在停止后继续对话。 用户可以在停止后继续对话。
""" """
async with self._lifecycle_lock:
return await self._stop_current_task_locked(session_id)
async def _stop_current_task_locked(self, session_id: str):
"""在 lifecycle 互斥域内停止会话 worker。"""
stopped = False stopped = False
worker = self._session_workers.get(session_id) worker = self._session_workers.get(session_id)
@@ -2846,7 +2874,7 @@ class AgentManager:
if queue and self._session_queues.get(session_id) is queue: if queue and self._session_queues.get(session_id) is queue:
self._session_queues.pop(session_id, None) self._session_queues.pop(session_id, None)
# 先摘下旧队列;清理期间的新消息进入新队列,但等待 worker 完全退出后再执行 # 先摘下旧队列等待 worker 退出;lifecycle 锁保证清理期间不会并发建立新队列
if worker: if worker:
worker.cancel() worker.cancel()
if queue: if queue:
@@ -2884,6 +2912,11 @@ class AgentManager:
""" """
清空会话 清空会话
""" """
async with self._lifecycle_lock:
await self._clear_session_locked(session_id=session_id, user_id=user_id)
async def _clear_session_locked(self, session_id: str, user_id: str) -> None:
"""在 lifecycle 互斥域内释放会话、Agent 与记忆。"""
self._session_last_used.pop(session_id, None) self._session_last_used.pop(session_id, None)
# 取消该会话的worker # 取消该会话的worker
if session_id in self._session_workers: if session_id in self._session_workers:
@@ -2894,8 +2927,10 @@ class AgentManager:
pass pass
self._session_workers.pop(session_id, None) # noqa self._session_workers.pop(session_id, None) # noqa
# 清理队列 # 清理队列时同步结束未执行请求,避免 wait_for_completion 调用方永久等待。
self._session_queues.pop(session_id, None) queue = self._session_queues.pop(session_id, None)
if queue:
self._discard_queued_messages(queue)
# 清理agent # 清理agent
if session_id in self.active_agents: if session_id in self.active_agents:
@@ -2905,8 +2940,8 @@ class AgentManager:
memory_manager.clear_memory(session_id, user_id) memory_manager.clear_memory(session_id, user_id)
logger.info(f"会话 {session_id} 的记忆已清空") logger.info(f"会话 {session_id} 的记忆已清空")
@staticmethod
async def run_background_prompt( async def run_background_prompt(
self,
message: str, message: str,
session_prefix: str = "__agent_background", session_prefix: str = "__agent_background",
output_callback: Optional[Callable[[str], None]] = None, output_callback: Optional[Callable[[str], None]] = None,
@@ -2924,22 +2959,21 @@ class AgentManager:
elif allow_message_tools is None: elif allow_message_tools is None:
allow_message_tools = True allow_message_tools = True
agent = MoviePilotAgent(
session_id=session_id,
user_id=user_id,
channel=None,
source=None,
username=settings.SUPERUSER,
replay_mode=reply_mode,
output_callback=output_callback,
allow_message_tools=allow_message_tools,
)
try: try:
await agent.process(message) await self.process_message(
session_id=session_id,
user_id=user_id,
message=message,
channel=None,
source=None,
username=settings.SUPERUSER,
reply_mode=reply_mode,
output_callback=output_callback,
allow_message_tools=allow_message_tools,
wait_for_completion=True,
)
finally: finally:
await agent.cleanup() await self.clear_session(session_id=session_id, user_id=user_id)
memory_manager.clear_memory(session_id, user_id)
async def execute_scheduled_task( async def execute_scheduled_task(
self, self,
+137
View File
@@ -0,0 +1,137 @@
"""Agent 重量级 canonical 对象的轻量首用入口。"""
from __future__ import annotations
import threading
from typing import Any
from app.agent.capabilities import (
AGENT_ENTRYPOINT_KIND,
AGENT_MANAGER_CAPABILITY_ID,
AGENT_SERVICE_CAPABILITY_ID,
AGENT_SERVICE_KIND,
MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID,
TOOL_FACTORY_CAPABILITY_ID,
)
from app.agent.capabilities.adapter import (
AgentEntrypointAdapter,
AgentServiceAdapter,
build_agent_capability_registry,
should_run_agent_service,
)
from app.runtime.capabilities.model import CapabilityMaterializationState
from app.runtime.capabilities.runtime import CapabilityRuntime
_runtime_lock = threading.RLock()
_agent_runtime: CapabilityRuntime | None = None
def _build_agent_runtime() -> CapabilityRuntime:
"""装配 Agent Runtime;构建阶段只解析 manifests。"""
return CapabilityRuntime(
build_agent_capability_registry(),
adapters={
AGENT_ENTRYPOINT_KIND: AgentEntrypointAdapter(),
AGENT_SERVICE_KIND: AgentServiceAdapter(),
},
)
def _ensure_runtime() -> CapabilityRuntime:
"""返回进程唯一 Runtime,同进程关闭后不重新创建。"""
global _agent_runtime
with _runtime_lock:
if _agent_runtime is None:
_agent_runtime = _build_agent_runtime()
return _agent_runtime
def _materialize_entrypoint(capability_id: str) -> Any:
"""通过通用 Runtime 完成并发 single-flight 物化,不声明资源运行态。"""
return _ensure_runtime().materialize(
capability_id,
reason="agent_entrypoint_first_use",
)
def get_agent_manager() -> Any:
"""返回 canonical Agent Manager;关闭门禁生效后稳定拒绝首用。"""
return _materialize_entrypoint(AGENT_MANAGER_CAPABILITY_ID)
async def reconcile_agent_service(
*,
reason: str,
changed_keys: set[str] | None = None,
retry: bool = False,
) -> Any | None:
"""按 manifest watch/selector 协调唯一 Agent Service 生命周期。"""
runtime = _ensure_runtime()
spec = runtime.get_spec(AGENT_SERVICE_CAPABILITY_ID)
if spec is None:
raise RuntimeError("缺少 agent.service capability")
if changed_keys is not None and not changed_keys.intersection(spec.watch):
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
if not should_run_agent_service(spec):
# stop_async 会等待并发首启后再撤销实例;未物化能力则保持零导入。
await runtime.stop_async(
AGENT_SERVICE_CAPABILITY_ID,
reason=reason,
)
return None
return await runtime.activate_async(
AGENT_SERVICE_CAPABILITY_ID,
reason=reason,
retry=retry,
)
async def activate_agent_service(*, retry: bool = False) -> Any | None:
"""执行启动期协调;selector 未启用时保持 service 未物化。"""
return await reconcile_agent_service(
reason="agent_service_startup_reconcile",
retry=retry,
)
def get_running_agent_manager() -> Any | None:
"""只读返回 RUNNING Agent Service;未构建 Runtime 时不触发声明发现。"""
with _runtime_lock:
runtime = _agent_runtime
if runtime is None:
return None
return runtime.get_running(AGENT_SERVICE_CAPABILITY_ID)
def get_moviepilot_agent_type() -> type:
"""返回 canonical MoviePilotAgent 类型。"""
agent_type = _materialize_entrypoint(MOVIEPILOT_AGENT_TYPE_CAPABILITY_ID)
if not isinstance(agent_type, type):
raise TypeError("MoviePilot Agent entrypoint 必须是类型")
return agent_type
def get_tool_factory() -> type:
"""返回 canonical 工具工厂类型。"""
factory_type = _materialize_entrypoint(TOOL_FACTORY_CAPABILITY_ID)
if not isinstance(factory_type, type):
raise TypeError("Agent Tool Factory entrypoint 必须是类型")
return factory_type
def is_tool_factory_materialized() -> bool:
"""只读判断工具工厂是否已解析;未建 Runtime 时不触发发现或导入。"""
with _runtime_lock:
runtime = _agent_runtime
if runtime is None:
return False
return (
runtime.snapshot(TOOL_FACTORY_CAPABILITY_ID).materialization
is CapabilityMaterializationState.RESOLVED
)
async def begin_agent_shutdown() -> None:
"""不可逆关闭首用闸门,并等待全部同步及异步能力释放。"""
await _ensure_runtime().shutdown_async(reason="application_shutdown")
+60 -4
View File
@@ -5,12 +5,11 @@ from abc import ABCMeta, abstractmethod
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Any, Callable, ClassVar, Optional from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Protocol
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from pydantic import PrivateAttr from pydantic import PrivateAttr
from app.agent.callback import StreamingHandler
from app.agent.policy.sanitizer import ( from app.agent.policy.sanitizer import (
summarize_error, summarize_error,
summarize_input, summarize_input,
@@ -25,6 +24,54 @@ from app.runtime.log import logger
from app.schemas import Message from app.schemas import Message
from app.schemas.types import NotificationChannel, MessageType from app.schemas.types import NotificationChannel, MessageType
if TYPE_CHECKING:
from app.agent.callback import StreamingHandler as _StreamingHandlerProtocol
else:
class _StreamingHandlerProtocol(Protocol):
"""工具执行仅依赖的流式缓冲合同。"""
@property
def is_streaming(self) -> bool:
"""是否正在收集流式输出。"""
...
@property
def is_auto_flushing(self) -> bool:
"""是否由渠道编辑能力自动刷新缓冲。"""
...
@property
def last_buffer_char(self) -> str:
"""返回缓冲区最后一个字符。"""
...
def emit(self, token: str) -> str:
"""追加流式文本并返回实际追加内容。"""
...
async def take(self) -> str:
"""取出并清空当前缓冲内容。"""
...
def record_tool_call(
self,
tool_name: str,
tool_message: Optional[str] = None,
tool_kwargs: Optional[dict[str, Any]] = None,
) -> None:
"""记录一次待汇总的工具调用。"""
...
def __getattr__(name: str) -> Any:
"""显式访问历史 StreamingHandler 符号时返回 canonical 实现。"""
if name == "StreamingHandler":
from app.agent.callback import StreamingHandler
return StreamingHandler
raise AttributeError(f"module 'app.agent.tools.base' has no attribute {name!r}")
class ToolChain(ChainBase): class ToolChain(ChainBase):
pass pass
@@ -206,7 +253,7 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
_channel: Optional[str] = PrivateAttr(default=None) _channel: Optional[str] = PrivateAttr(default=None)
_source: Optional[str] = PrivateAttr(default=None) _source: Optional[str] = PrivateAttr(default=None)
_username: Optional[str] = PrivateAttr(default=None) _username: Optional[str] = PrivateAttr(default=None)
_stream_handler: Optional[StreamingHandler] = PrivateAttr(default=None) _stream_handler: Optional[_StreamingHandlerProtocol] = PrivateAttr(default=None)
_require_admin: bool = PrivateAttr(default=False) _require_admin: bool = PrivateAttr(default=False)
_agent_context: dict = PrivateAttr(default_factory=dict) _agent_context: dict = PrivateAttr(default_factory=dict)
@@ -387,7 +434,9 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
self._source = source self._source = source
self._username = username self._username = username
def set_stream_handler(self, stream_handler: StreamingHandler): def set_stream_handler(
self, stream_handler: Optional[_StreamingHandlerProtocol]
) -> None:
""" """
设置回调处理器 设置回调处理器
""" """
@@ -642,3 +691,10 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
save_history=False, save_history=False,
) )
) )
# 普通导入保持 callback 冷态;显式导入或历史星号导入仍解析真实类。
__all__ = sorted(
{name for name in globals() if not name.startswith("_")}
| {"StreamingHandler"}
)
+3 -3
View File
@@ -7,9 +7,9 @@ from typing import Any, Dict, Iterable, Optional
from app.runtime.events import eventmanager from app.runtime.events import eventmanager
from app.db.oper.subscribe import SubscribeOper from app.db.oper.subscribe import SubscribeOper
from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.systemconfig import SystemConfigOper
from app.application.filter import RuleHelper from app.application.rules import RuleHelper
from app.application.filter_rules import RuleParser from app.application.rules import RuleParser
from app.application.filter_rules import BUILTIN_RULE_SET from app.application.rules import BUILTIN_RULE_SET
from app.schemas import CustomRule, FilterRuleGroup from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.event import ConfigChangeEventData from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType, SystemConfigKey from app.schemas.types import EventType, SystemConfigKey
+12 -9
View File
@@ -70,14 +70,14 @@ def reload_plugin_runtime(plugin_id: str) -> None:
重载插件并重新注册其命令、定时任务和 API。 重载插件并重新注册其命令、定时任务和 API。
""" """
# 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。 # 这些依赖只在真正执行重载时才导入,避免普通查询工具引入不必要的初始化开销。
from app.api.endpoints.plugin import register_plugin_api from app.application.plugins import register_plugin_api
from app.command import Command from app.application.commands import init_commands
from app.scheduler import Scheduler from app.application.scheduling import update_plugin_job
plugin_manager = PluginManager() plugin_manager = PluginManager()
plugin_manager.reload_plugin(plugin_id) plugin_manager.reload_plugin(plugin_id)
Scheduler().update_plugin_job(plugin_id) update_plugin_job(plugin_id)
Command().init_commands(plugin_id) init_commands(plugin_id)
register_plugin_api(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.application.plugins import (
from app.scheduler import Scheduler remove_plugin_api,
remove_plugin_from_folders,
)
from app.application.scheduling import remove_plugin_job
config_oper = SystemConfigOper() config_oper = SystemConfigOper()
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or [] 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) await config_oper.async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
remove_plugin_api(plugin_id) remove_plugin_api(plugin_id)
Scheduler().remove_plugin_job(plugin_id) remove_plugin_job(plugin_id)
plugin_manager = PluginManager() plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id) 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: except Exception:
clone_files_removed = False clone_files_removed = False
_remove_plugin_from_folders(plugin_id) remove_plugin_from_folders(plugin_id)
plugin_manager.remove_plugin(plugin_id) plugin_manager.remove_plugin(plugin_id)
return { return {
@@ -133,6 +133,7 @@ def simplify_search_result(
context: Context, context: Context,
index: int, index: int,
include_description: bool = False, include_description: bool = False,
include_labels: bool = False,
) -> dict: ) -> dict:
""" """
精简单条搜索结果 精简单条搜索结果
@@ -140,6 +141,7 @@ def simplify_search_result(
:param context: 搜索结果上下文 :param context: 搜索结果上下文
:param index: 搜索结果在原始缓存中的序号 :param index: 搜索结果在原始缓存中的序号
:param include_description: 是否返回种子简介 :param include_description: 是否返回种子简介
:param include_labels: 是否返回种子标签
:return: 精简后的搜索结果 :return: 精简后的搜索结果
""" """
simplified = {} simplified = {}
@@ -162,6 +164,8 @@ def simplify_search_result(
} }
if include_description: if include_description:
simplified["torrent_info"]["description"] = torrent_info.description simplified["torrent_info"]["description"] = torrent_info.description
if include_labels:
simplified["torrent_info"]["labels"] = torrent_info.labels or []
if media_info: if media_info:
if getattr(media_info, "type", None) == MediaType.MUSIC: if getattr(media_info, "type", None) == MediaType.MUSIC:
+2 -3
View File
@@ -99,7 +99,7 @@ class CreateAgentTaskTool(MoviePilotTool):
def _create_task(self, payload: CreateAgentTaskInput) -> dict: 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 trigger_value = payload.trigger
if payload.trigger_type == "date" and payload.delay_minutes is not None: 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), source=self._source or (chat.source if chat else None),
original_chat_id=chat.original_chat_id if chat else None, original_chat_id=chat.original_chat_id if chat else None,
) )
scheduler = Scheduler() next_run_at = update_agent_task_job(task.id)
next_run_at = scheduler.update_agent_task_job(task.id)
return AgentTaskOper.to_dict( return AgentTaskOper.to_dict(
task, task,
next_run_at=next_run_at, next_run_at=next_run_at,
+2 -2
View File
@@ -31,14 +31,14 @@ class DeleteAgentTaskTool(MoviePilotTool):
def _delete_task(self, task_id: int) -> bool: 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( deleted = AgentTaskOper().delete(
task_id=task_id, task_id=task_id,
user_id=str(self._user_id), user_id=str(self._user_id),
) )
if deleted: if deleted:
Scheduler().remove_agent_task_job(task_id) remove_agent_task_job(task_id)
return deleted return deleted
async def run(self, task_id: int, **kwargs: object) -> str: async def run(self, task_id: int, **kwargs: object) -> str:
+8 -1
View File
@@ -42,6 +42,10 @@ class GetSearchResultsInput(BaseModel):
False, False,
description="Whether to include torrent descriptions in returned results", description="Whether to include torrent descriptions in returned results",
) )
include_labels: Optional[bool] = Field(
False,
description="Whether to include torrent labels in returned results",
)
show_filter_options: Optional[bool] = Field( show_filter_options: Optional[bool] = Field(
False, False,
description="Whether to return only optional filter options for re-checking available conditions", description="Whether to return only optional filter options for re-checking available conditions",
@@ -79,6 +83,7 @@ class GetSearchResultsTool(MoviePilotTool):
title_pattern: Optional[str] = None, title_pattern: Optional[str] = None,
content_pattern: Optional[str] = None, content_pattern: Optional[str] = None,
include_description: bool = False, include_description: bool = False,
include_labels: bool = False,
show_filter_options: bool = False, show_filter_options: bool = False,
page: Optional[int] = 1, page: Optional[int] = 1,
**kwargs, **kwargs,
@@ -96,6 +101,7 @@ class GetSearchResultsTool(MoviePilotTool):
:param title_pattern: 仅匹配种子标题的正则表达式 :param title_pattern: 仅匹配种子标题的正则表达式
:param content_pattern: 匹配种子标题、简介和标签的正则表达式 :param content_pattern: 匹配种子标题、简介和标签的正则表达式
:param include_description: 是否在结果中返回种子简介 :param include_description: 是否在结果中返回种子简介
:param include_labels: 是否在结果中返回种子标签
:param show_filter_options: 是否只返回可用筛选项 :param show_filter_options: 是否只返回可用筛选项
:param page: 分页页码 :param page: 分页页码
:param kwargs: 工具框架附加参数 :param kwargs: 工具框架附加参数
@@ -103,7 +109,7 @@ class GetSearchResultsTool(MoviePilotTool):
""" """
page = max(1, page or 1) page = max(1, page or 1)
logger.info( logger.info(
f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, show_filter_options={show_filter_options}, page={page}" f"执行工具: {self.name}, 参数: site={site}, season={season}, free_state={free_state}, video_code={video_code}, edition={edition}, resolution={resolution}, release_group={release_group}, title_pattern={title_pattern}, content_pattern={content_pattern}, include_description={include_description}, include_labels={include_labels}, show_filter_options={show_filter_options}, page={page}"
) )
try: try:
@@ -193,6 +199,7 @@ class GetSearchResultsTool(MoviePilotTool):
item, item,
index, index,
include_description=include_description, include_description=include_description,
include_labels=include_labels,
) )
for item, index in zip(page_items, page_indices) for item, index in zip(page_items, page_indices)
] ]
+2 -4
View File
@@ -14,7 +14,6 @@ class ListSlashCommandsInput(BaseModel):
"""查询所有可用斜杠命令工具的输入参数模型""" """查询所有可用斜杠命令工具的输入参数模型"""
class ListSlashCommandsTool(MoviePilotTool): class ListSlashCommandsTool(MoviePilotTool):
name: str = "list_slash_commands" name: str = "list_slash_commands"
tags: list[str] = [ tags: list[str] = [
@@ -41,10 +40,9 @@ class ListSlashCommandsTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}") logger.info(f"执行工具: {self.name}")
try: try:
from app.command import Command from app.application.commands import get_commands
command_obj = Command() all_commands = get_commands()
all_commands = command_obj.get_commands()
if not all_commands: if not all_commands:
return "当前没有可用的命令" return "当前没有可用的命令"
+2 -3
View File
@@ -48,7 +48,7 @@ class QueryAgentTasksTool(MoviePilotTool):
enabled: Optional[bool], enabled: Optional[bool],
) -> list[dict]: ) -> list[dict]:
"""读取当前用户的任务及运行时下一次触发时间。""" """读取当前用户的任务及运行时下一次触发时间。"""
from app.scheduler import Scheduler from app.application.scheduling import get_agent_task_next_run
oper = AgentTaskOper() oper = AgentTaskOper()
if task_id: if task_id:
@@ -56,12 +56,11 @@ class QueryAgentTasksTool(MoviePilotTool):
tasks = [task] if task else [] tasks = [task] if task else []
else: else:
tasks = oper.list(user_id=str(self._user_id), enabled=enabled) tasks = oper.list(user_id=str(self._user_id), enabled=enabled)
scheduler = Scheduler()
result = [] result = []
for task in tasks: for task in tasks:
data = oper.to_dict( data = oper.to_dict(
task, 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, timezone=settings.TZ,
) )
if task_id: if task_id:
+5 -3
View File
@@ -39,13 +39,15 @@ class QuerySchedulersTool(MoviePilotTool):
"""查询非 Agent 自主任务的运行时定时服务。""" """查询非 Agent 自主任务的运行时定时服务。"""
logger.info(f"执行工具: {self.name}") logger.info(f"执行工具: {self.name}")
try: 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}-" agent_task_prefix = f"{AGENT_TASK_JOB_PREFIX}-"
schedulers = [ schedulers = [
scheduler_item 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 not str(scheduler_item.id or "").startswith(agent_task_prefix)
] ]
if schedulers: if schedulers:
+2 -2
View File
@@ -56,7 +56,7 @@ class RunAgentTaskTool(MoviePilotTool):
async def run(self, task_id: int, **kwargs: object) -> str: async def run(self, task_id: int, **kwargs: object) -> str:
"""立即执行当前用户拥有且已启用的 Agent 自主定时任务。""" """立即执行当前用户拥有且已启用的 Agent 自主定时任务。"""
from app.scheduler import Scheduler from app.application.scheduling import start_agent_task
payload = RunAgentTaskInput(task_id=task_id) payload = RunAgentTaskInput(task_id=task_id)
status, task_name = await self.run_blocking( status, task_name = await self.run_blocking(
@@ -70,7 +70,7 @@ class RunAgentTaskTool(MoviePilotTool):
return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行" return f"Agent 定时任务 {task_id} 已暂停,请先恢复后再执行"
if status == "running": if status == "running":
return f"Agent 定时任务 {task_id} 正在执行,请勿重复触发" 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} 尚未注册到运行时调度器,无法立即执行"
return ( return (
f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}" f"Agent 定时任务 {task_id} 已提交立即执行:{task_name}"
+7 -5
View File
@@ -46,12 +46,14 @@ class RunSchedulerTool(MoviePilotTool):
@staticmethod @staticmethod
def _run_scheduler_sync(job_id: str) -> tuple[bool, str]: 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 list_scheduler_jobs():
for scheduler_item in scheduler.list():
if scheduler_item.id == job_id: if scheduler_item.id == job_id:
scheduler.start(job_id) start_scheduler_job(job_id)
return True, scheduler_item.name return True, scheduler_item.name
return False, "" return False, ""
@@ -60,7 +62,7 @@ class RunSchedulerTool(MoviePilotTool):
logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}") logger.info(f"执行工具: {self.name}, 参数: job_id={job_id}")
try: 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}-"): if job_id.startswith(f"{AGENT_TASK_JOB_PREFIX}-"):
return ( return (
+4 -5
View File
@@ -57,16 +57,15 @@ class RunSlashCommandTool(MoviePilotTool):
if not command.startswith("/"): if not command.startswith("/"):
command = f"/{command}" command = f"/{command}"
# 从全局 Command 单例中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令) # 从命令注册表中验证命令是否存在(包含系统预设命令 + 插件命令 + 其他命令)
from app.command import Command from app.application.commands import get_command, get_commands
cmd_name = command.split()[0] cmd_name = command.split()[0]
command_obj = Command() matched_command = get_command(cmd_name)
matched_command = command_obj.get(cmd_name)
if not matched_command: if not matched_command:
# 列出所有可用命令帮助用户 # 列出所有可用命令帮助用户
all_commands = command_obj.get_commands() all_commands = get_commands()
available_cmds = [ available_cmds = [
f"{cmd} - {info.get('description', '无描述')}" f"{cmd} - {info.get('description', '无描述')}"
for cmd, info in all_commands.items() for cmd, info in all_commands.items()
+2 -3
View File
@@ -100,7 +100,7 @@ class UpdateAgentTaskTool(MoviePilotTool):
def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]: def _update_task(self, payload: UpdateAgentTaskInput) -> Optional[dict]:
"""更新当前用户的任务并刷新运行时调度。""" """更新当前用户的任务并刷新运行时调度。"""
from app.scheduler import Scheduler from app.application.scheduling import update_agent_task_job
oper = AgentTaskOper() oper = AgentTaskOper()
task = oper.get(task_id=payload.task_id, user_id=str(self._user_id)) 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": if current and current.last_status == "running":
return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"} return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
return None return None
scheduler = Scheduler() next_run_at = update_agent_task_job(payload.task_id)
next_run_at = scheduler.update_agent_task_job(payload.task_id)
updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id)) updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
return oper.to_dict( return oper.to_dict(
updated_task, updated_task,
+135 -65
View File
@@ -1,24 +1,16 @@
from __future__ import annotations
import json import json
import threading import threading
import uuid import uuid
from typing import Any, Dict, List, Optional from typing import TYPE_CHECKING, Any, Dict, List, Optional
from app.agent.policy import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
AgentToolPolicyOrchestrator,
AuthSource,
PrincipalType,
ToolOrigin,
ToolPolicyContext,
call_policy_hook,
summarize_error,
)
from app.agent.tools.base import ToolExecutionTimeoutError, format_tool_result_for_agent
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.log import logger from app.runtime.log import logger
if TYPE_CHECKING:
from app.agent.policy import AgentToolPolicyOrchestrator, ToolPolicyContext
from app.agent.tools.catalog import ToolCatalogSnapshot
class ToolDefinition: class ToolDefinition:
""" """
@@ -53,31 +45,33 @@ class MoviePilotToolsManager:
self.user_id = user_id self.user_id = user_id
self.session_id = session_id self.session_id = session_id
self.is_admin = is_admin self.is_admin = is_admin
self.policy_orchestrator = ( self.policy_orchestrator = policy_orchestrator
policy_orchestrator or DEFAULT_TOOL_POLICY_ORCHESTRATOR self._policy_context: Optional[ToolPolicyContext] = None
)
self._policy_context = ToolPolicyContext(
session_id=session_id,
user_id=user_id,
origin=ToolOrigin.OPERATOR_DIRECT,
principal_type=PrincipalType.SYSTEM_ADMIN_INTEGRATION,
auth_source=AuthSource.API_TOKEN,
channel=None,
source="api",
agent_context={"is_admin": is_admin},
)
self.tools: List[Any] = [] self.tools: List[Any] = []
self.catalog: Optional[ToolCatalogSnapshot] = None self.catalog: Optional[ToolCatalogSnapshot] = None
self._tools_lock = threading.Lock() self._tools_lock = threading.Lock()
self._plugin_agent_tools_revision = -1 self._plugin_agent_tools_revision = -1
self._load_tools() self._catalog_materialized = False
self._catalog_managed_by_factory = False
def _load_tools(self) -> None: @staticmethod
def _summarize_error(error: Exception) -> str:
"""仅在错误路径加载策略脱敏器,保持默认导入轻量。"""
from app.agent.policy import summarize_error
return summarize_error(error)
def _load_tools_locked(self) -> None:
""" """
加载所有MoviePilot工具 manager 锁内加载所有 MoviePilot 工具
工厂负责插件 revision 前后稳定窗口manager 只发布完整快照避免
并发调用观察到一半刷新后的工具列表
""" """
from app.agent.runtime_loader import get_tool_factory
try: try:
catalog = MoviePilotToolFactory.create_catalog( catalog = get_tool_factory().create_catalog(
session_id=self.session_id, session_id=self.session_id,
user_id=self.user_id, user_id=self.user_id,
channel=None, channel=None,
@@ -89,17 +83,43 @@ class MoviePilotToolsManager:
self.catalog = catalog self.catalog = catalog
self.tools = catalog.tools self.tools = catalog.tools
self._plugin_agent_tools_revision = catalog.plugin_revision self._plugin_agent_tools_revision = catalog.plugin_revision
self._catalog_materialized = True
self._catalog_managed_by_factory = True
logger.info(f"成功加载 {len(self.tools)} 个工具") logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e: except Exception as e:
logger.error(f"加载工具失败: {summarize_error(e)}") logger.error(f"加载工具失败: {self._summarize_error(e)}")
self.tools = [] self.tools = []
self.catalog = None self.catalog = None
self._plugin_agent_tools_revision = -1 self._plugin_agent_tools_revision = -1
self._catalog_materialized = False
self._catalog_managed_by_factory = False
def _load_tools(self) -> None:
"""兼容显式刷新入口,并保证外部调用仍原子发布完整目录。"""
with self._tools_lock:
self._load_tools_locked()
def _ensure_tools_current(self) -> None: def _ensure_tools_current(self) -> None:
""" """
在插件工具注册表变化后惰性刷新工具实例 首次使用时加载目录在插件注册表变化后惰性刷新工具实例
""" """
# 调用方可能显式注入工具实例;这些实例仍由调用方拥有,manager 不应
# 在第一次查询时用全量目录覆盖它们。
if not self._catalog_materialized and self.tools:
self._catalog_materialized = True
return
if self._catalog_materialized and not self._catalog_managed_by_factory:
return
if not self._catalog_materialized:
with self._tools_lock:
if not self._catalog_materialized:
self._load_tools_locked()
return
from app.runtime.extensions.plugin_manager import PluginManager
plugin_manager = PluginManager() plugin_manager = PluginManager()
if ( if (
self._plugin_agent_tools_revision self._plugin_agent_tools_revision
@@ -112,7 +132,41 @@ class MoviePilotToolsManager:
== plugin_manager.get_plugin_agent_tools_revision() == plugin_manager.get_plugin_agent_tools_revision()
): ):
return return
self._load_tools() self._load_tools_locked()
def _ensure_policy_runtime(
self,
) -> tuple[AgentToolPolicyOrchestrator, ToolPolicyContext]:
"""返回 direct 入口的策略对象,仅在真实工具调用前完成构造。"""
policy_orchestrator = self.policy_orchestrator
policy_context = self._policy_context
if policy_orchestrator is not None and policy_context is not None:
return policy_orchestrator, policy_context
from app.agent.policy import (
DEFAULT_TOOL_POLICY_ORCHESTRATOR,
AuthSource,
PrincipalType,
ToolOrigin,
ToolPolicyContext,
)
if policy_orchestrator is None:
policy_orchestrator = DEFAULT_TOOL_POLICY_ORCHESTRATOR
if policy_context is None:
policy_context = ToolPolicyContext(
session_id=self.session_id,
user_id=self.user_id,
origin=ToolOrigin.OPERATOR_DIRECT,
principal_type=PrincipalType.SYSTEM_ADMIN_INTEGRATION,
auth_source=AuthSource.API_TOKEN,
channel=None,
source="api",
agent_context={"is_admin": self.is_admin},
)
self.policy_orchestrator = policy_orchestrator
self._policy_context = policy_context
return policy_orchestrator, policy_context
def list_tools(self) -> List[ToolDefinition]: def list_tools(self) -> List[ToolDefinition]:
""" """
@@ -122,8 +176,10 @@ class MoviePilotToolsManager:
工具定义列表 工具定义列表
""" """
self._ensure_tools_current() self._ensure_tools_current()
with self._tools_lock:
tools = list(self.tools)
tools_list = [] tools_list = []
for tool in self.tools: for tool in tools:
if getattr(tool, "_require_admin", False) and not self.is_admin: if getattr(tool, "_require_admin", False) and not self.is_admin:
continue continue
# 获取工具的输入参数模型 # 获取工具的输入参数模型
@@ -156,26 +212,31 @@ class MoviePilotToolsManager:
工具实例如果未找到返回None 工具实例如果未找到返回None
""" """
self._ensure_tools_current() self._ensure_tools_current()
return next( with self._tools_lock:
(tool for tool in self.tools if tool.name == tool_name), return next(
None, (tool for tool in self.tools if tool.name == tool_name),
) None,
)
def get_strict_tool(self, tool_name: str) -> Optional[Any]: def get_strict_tool(self, tool_name: str) -> Optional[Any]:
"""按当前目录唯一身份解析严格调用,重名时稳定失败。""" """按当前目录唯一身份解析严格调用,重名时稳定失败。"""
self._ensure_tools_current() self._ensure_tools_current()
if self.catalog is None or [ with self._tools_lock:
id(tool) for tool in self.catalog.tools if self.catalog is None or [
] != [id(tool) for tool in self.tools]: id(tool) for tool in self.catalog.tools
self.catalog = ToolCatalogSnapshot.from_tools( ] != [id(tool) for tool in self.tools]:
self.tools, from app.agent.runtime_loader import get_tool_factory
plugin_revision=self._plugin_agent_tools_revision, from app.agent.tools.catalog import ToolCatalogSnapshot
factory_revision=MoviePilotToolFactory.catalog_factory_revision(),
) self.catalog = ToolCatalogSnapshot.from_tools(
if self.catalog is None: self.tools,
return None plugin_revision=self._plugin_agent_tools_revision,
entry = self.catalog.resolve_unique(tool_name) factory_revision=get_tool_factory().catalog_factory_revision(),
return entry.tool if entry else None )
if self.catalog is None:
return None
entry = self.catalog.resolve_unique(tool_name)
return entry.tool if entry else None
@staticmethod @staticmethod
def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]: def _resolve_field_schema(field_info: Dict[str, Any]) -> Dict[str, Any]:
@@ -265,7 +326,7 @@ class MoviePilotToolsManager:
schema = args_schema.model_json_schema() schema = args_schema.model_json_schema()
properties = schema.get("properties", {}) properties = schema.get("properties", {})
except Exception as e: except Exception as e:
logger.warning(f"获取工具schema失败: {summarize_error(e)}") logger.warning(f"获取工具schema失败: {MoviePilotToolsManager._summarize_error(e)}")
return arguments return arguments
# 规范化参数 # 规范化参数
@@ -320,7 +381,14 @@ class MoviePilotToolsManager:
) )
return error_msg return error_msg
from app.agent.policy import call_policy_hook
from app.agent.tools.base import (
ToolExecutionTimeoutError,
format_tool_result_for_agent,
)
observation = None observation = None
policy_orchestrator = None
try: try:
permission_error = self._check_tool_permission(tool_instance) permission_error = self._check_tool_permission(tool_instance)
if permission_error: if permission_error:
@@ -328,11 +396,12 @@ class MoviePilotToolsManager:
# 规范化参数类型 # 规范化参数类型
normalized_arguments = self._normalize_arguments(tool_instance, arguments) normalized_arguments = self._normalize_arguments(tool_instance, arguments)
self._policy_context.agent_context["is_admin"] = self.is_admin policy_orchestrator, policy_context = self._ensure_policy_runtime()
policy_context.agent_context["is_admin"] = self.is_admin
observation = call_policy_hook( observation = call_policy_hook(
"start", "start",
self.policy_orchestrator.start, policy_orchestrator.start,
context=self._policy_context, context=policy_context,
tool=tool_instance, tool=tool_instance,
arguments=normalized_arguments, arguments=normalized_arguments,
) )
@@ -346,28 +415,29 @@ class MoviePilotToolsManager:
max_chars=getattr(tool_instance, "result_max_chars", None), max_chars=getattr(tool_instance, "result_max_chars", None),
) )
except ToolExecutionTimeoutError as e: except ToolExecutionTimeoutError as e:
if observation: if observation is not None and policy_orchestrator is not None:
call_policy_hook("fail", self.policy_orchestrator.fail, observation, e) call_policy_hook("fail", policy_orchestrator.fail, observation, e)
logger.warning(summarize_error(e)) error_summary = self._summarize_error(e)
logger.warning(error_summary)
return format_tool_result_for_agent( return format_tool_result_for_agent(
summarize_error(e), error_summary,
tool_name=tool_name, tool_name=tool_name,
max_chars=getattr(tool_instance, "result_max_chars", None), max_chars=getattr(tool_instance, "result_max_chars", None),
) )
except Exception as e: except Exception as e:
if observation: if observation is not None and policy_orchestrator is not None:
call_policy_hook("fail", self.policy_orchestrator.fail, observation, e) call_policy_hook("fail", policy_orchestrator.fail, observation, e)
error_summary = summarize_error(e) error_summary = self._summarize_error(e)
logger.error(f"调用工具 {tool_name} 时发生错误: {error_summary}") logger.error(f"调用工具 {tool_name} 时发生错误: {error_summary}")
error_msg = json.dumps( error_msg = json.dumps(
{"error": f"调用工具 '{tool_name}' 时发生错误: {error_summary}"}, {"error": f"调用工具 '{tool_name}' 时发生错误: {error_summary}"},
ensure_ascii=False, ensure_ascii=False,
) )
return error_msg return error_msg
if observation: if observation is not None and policy_orchestrator is not None:
call_policy_hook( call_policy_hook(
"finish", "finish",
self.policy_orchestrator.finish, policy_orchestrator.finish,
observation, observation,
str_result, str_result,
) )
+93 -16
View File
@@ -20,10 +20,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas from app import schemas
from app.api.response import ResponseAPIRouter from app.api.response import ResponseAPIRouter
from app.agent.callback import StreamingHandler from app.agent.contracts import ReplyMode, build_display_message
from app.agent.orchestrator import MoviePilotAgent, ReplyMode, agent_manager
from app.agent.llm.capability import AgentCapabilityManager from app.agent.llm.capability import AgentCapabilityManager
from app.agent.mcp import agent_mcp_manager from app.agent.mcp import agent_mcp_manager
from app.agent.runtime_loader import (
get_moviepilot_agent_type,
get_running_agent_manager,
)
from app.chain.message import MessageChain from app.chain.message import MessageChain
from app.command import Command from app.command import Command
from app.runtime.config import global_vars, settings from app.runtime.config import global_vars, settings
@@ -254,7 +257,7 @@ async def test_agent_mcp_server(
) )
class _WebAgentStreamingHandler(StreamingHandler): class _WebAgentStreamingHandlerMixin:
""" """
Web 前端专用流式处理器将工具提示和文本统一回调给 SSE Web 前端专用流式处理器将工具提示和文本统一回调给 SSE
""" """
@@ -342,7 +345,28 @@ class _WebAgentStreamingHandler(StreamingHandler):
return True return True
class _WebAgentMoviePilotAgent(MoviePilotAgent): def _get_web_agent_streaming_handler_type() -> type:
"""首次构造 Web Agent 时才解析完整流式处理器实现。"""
global _WEB_AGENT_STREAMING_HANDLER_TYPE
if _WEB_AGENT_STREAMING_HANDLER_TYPE is not None:
return _WEB_AGENT_STREAMING_HANDLER_TYPE
with _WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK:
if _WEB_AGENT_STREAMING_HANDLER_TYPE is None:
from app.agent.callback import StreamingHandler
_WEB_AGENT_STREAMING_HANDLER_TYPE = type(
"_RuntimeWebAgentStreamingHandler",
(_WebAgentStreamingHandlerMixin, StreamingHandler),
{"__module__": __name__},
)
return _WEB_AGENT_STREAMING_HANDLER_TYPE
_WEB_AGENT_STREAMING_HANDLER_TYPE_LOCK = Lock()
_WEB_AGENT_STREAMING_HANDLER_TYPE: Optional[type] = None
class _WebAgentMoviePilotAgentMixin:
""" """
Web 前端专用 Agent强制使用流式推理 Web 前端专用 Agent强制使用流式推理
""" """
@@ -355,7 +379,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
) -> None: ) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._message_callback = message_callback self._message_callback = message_callback
self.stream_handler = _WebAgentStreamingHandler(self._emit_output) self.stream_handler = _get_web_agent_streaming_handler_type()(
self._emit_output
)
def _should_stream(self) -> bool: def _should_stream(self) -> bool:
"""Web 对话实时输出,复用会话执行后台任务时改用非流式广播。""" """Web 对话实时输出,复用会话执行后台任务时改用非流式广播。"""
@@ -381,7 +407,9 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
:param output_callback: 当前请求的输出回调 :param output_callback: 当前请求的输出回调
""" """
self.output_callback = output_callback self.output_callback = output_callback
if output_callback and isinstance(self.stream_handler, _WebAgentStreamingHandler): if output_callback and isinstance(
self.stream_handler, _WebAgentStreamingHandlerMixin
):
self.stream_handler.set_emit_callback(self._emit_output) self.stream_handler.set_emit_callback(self._emit_output)
async def _is_system_admin_context(self) -> bool: async def _is_system_admin_context(self) -> bool:
@@ -420,6 +448,30 @@ class _WebAgentMoviePilotAgent(MoviePilotAgent):
logger.debug(f"Web智能体输出回调失败: {e}") logger.debug(f"Web智能体输出回调失败: {e}")
def _build_web_agent_type(agent_base_type: type) -> type:
"""为 Web 通道组合唯一的运行时 Agent 类型。"""
return type(
"_RuntimeWebAgentMoviePilotAgent",
(_WebAgentMoviePilotAgentMixin, agent_base_type),
{"__module__": __name__},
)
_WEB_AGENT_TYPE_LOCK = Lock()
_WEB_AGENT_TYPE: Optional[type] = None
def _get_web_agent_type() -> type:
"""在真实 Web Agent 调用边界 single-flight 解析运行时类型。"""
global _WEB_AGENT_TYPE
if _WEB_AGENT_TYPE is not None:
return _WEB_AGENT_TYPE
with _WEB_AGENT_TYPE_LOCK:
if _WEB_AGENT_TYPE is None:
_WEB_AGENT_TYPE = _build_web_agent_type(get_moviepilot_agent_type())
return _WEB_AGENT_TYPE
def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str: def _build_web_agent_session_id(user: User, session_id: Optional[str]) -> str:
""" """
构建前端 Agent 会话 ID 构建前端 Agent 会话 ID
@@ -1131,7 +1183,7 @@ def _build_web_agent_display_message_from_events(
:param events: 已转换的 WebAgent SSE 事件列表 :param events: 已转换的 WebAgent SSE 事件列表
:return: 可持久化的助手展示消息 :return: 可持久化的助手展示消息
""" """
message = MoviePilotAgent.build_display_message( message = build_display_message(
role="assistant", role="assistant",
status="streaming", status="streaming",
) )
@@ -1725,7 +1777,8 @@ async def get_agent_chat_session(
if server_session_id != session_id: if server_session_id != session_id:
chat = await _get_accessible_agent_chat(oper, server_session_id, current_user) chat = await _get_accessible_agent_chat(oper, server_session_id, current_user)
if not chat: if not chat:
if agent_manager.is_session_busy(server_session_id): manager = get_running_agent_manager()
if manager and manager.is_session_busy(server_session_id):
return schemas.Response( return schemas.Response(
success=True, success=True,
data={ data={
@@ -1737,7 +1790,10 @@ async def get_agent_chat_session(
) )
return schemas.Response(success=False, message="会话不存在或无权访问") return schemas.Response(success=False, message="会话不存在或无权访问")
data = AgentChatOper.to_detail(chat) data = AgentChatOper.to_detail(chat)
data["is_processing"] = agent_manager.is_session_busy(chat.session_id) manager = get_running_agent_manager()
data["is_processing"] = bool(
manager and manager.is_session_busy(chat.session_id)
)
return schemas.Response(success=True, data=data) return schemas.Response(success=True, data=data)
@@ -1836,7 +1892,8 @@ async def stop_web_agent_session_task(
if chat and not _can_access_agent_chat(chat, current_user): if chat and not _can_access_agent_chat(chat, current_user):
return schemas.Response(success=False, message="会话不存在或无权访问") return schemas.Response(success=False, message="会话不存在或无权访问")
stopped = await agent_manager.stop_current_task(server_session_id) manager = get_running_agent_manager()
stopped = await manager.stop_current_task(server_session_id) if manager else False
return schemas.Response( return schemas.Response(
success=True, success=True,
data={"stopped": stopped}, data={"stopped": stopped},
@@ -1881,7 +1938,8 @@ async def web_agent_stream(
) )
is_secret_confirmation_control = ( is_secret_confirmation_control = (
is_secret_confirmation_candidate is_secret_confirmation_candidate
and agent_manager.matches_secret_confirmation( and (manager := get_running_agent_manager()) is not None
and manager.matches_secret_confirmation(
session_id, session_id,
str(current_user.id), str(current_user.id),
channel=NotificationChannel.WebAgent.value, channel=NotificationChannel.WebAgent.value,
@@ -1943,7 +2001,7 @@ async def web_agent_stream(
display_messages = [] display_messages = []
if payload.echo_user: if payload.echo_user:
display_messages.append( display_messages.append(
MoviePilotAgent.build_display_message( build_display_message(
role="user", role="user",
content=display_prompt or prompt, content=display_prompt or prompt,
attachments=user_attachments, attachments=user_attachments,
@@ -2038,6 +2096,19 @@ async def web_agent_stream(
media_type="text/event-stream", media_type="text/event-stream",
) )
manager = get_running_agent_manager()
if manager is None:
return StreamingResponse(
iter([
_build_web_agent_sse(
"error",
{"message": "智能助手服务尚未就绪,请稍后重试。"},
locale=locale,
)
]),
media_type="text/event-stream",
)
transcript = _transcribe_web_agent_audio_refs(payload.audio_refs or []) transcript = _transcribe_web_agent_audio_refs(payload.audio_refs or [])
prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript) prompt = _merge_web_agent_prompt_with_transcript(prompt, transcript)
display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript) display_prompt = _merge_web_agent_prompt_with_transcript(display_prompt, transcript)
@@ -2077,7 +2148,7 @@ async def web_agent_stream(
) )
display_messages = [] display_messages = []
if payload.echo_user and not is_secret_confirmation_control: if payload.echo_user and not is_secret_confirmation_control:
user_display_message = MoviePilotAgent.build_display_message( user_display_message = build_display_message(
role="user", role="user",
content=display_prompt or prompt, content=display_prompt or prompt,
attachments=user_attachments, attachments=user_attachments,
@@ -2085,7 +2156,7 @@ async def web_agent_stream(
if payload.choice_selection: if payload.choice_selection:
user_display_message["choice_selection"] = payload.choice_selection user_display_message["choice_selection"] = payload.choice_selection
display_messages.append(user_display_message) display_messages.append(user_display_message)
assistant_display_message = MoviePilotAgent.build_display_message( assistant_display_message = build_display_message(
role="assistant", role="assistant",
status="streaming", status="streaming",
) )
@@ -2132,7 +2203,10 @@ async def web_agent_stream(
async def run_agent() -> None: async def run_agent() -> None:
"""后台执行 Agent,并将结果写入事件队列。""" """后台执行 Agent,并将结果写入事件队列。"""
try: try:
await agent_manager.process_message( runtime_manager = get_running_agent_manager()
if runtime_manager is None:
raise RuntimeError("智能助手服务尚未就绪,请稍后重试。")
await runtime_manager.process_message(
session_id=session_id, session_id=session_id,
user_id=str(current_user.id), user_id=str(current_user.id),
message=prompt, message=prompt,
@@ -2151,9 +2225,12 @@ async def web_agent_stream(
else None else None
), ),
message_callback=message_callback, message_callback=message_callback,
agent_factory=_WebAgentMoviePilotAgent, agent_factory=_get_web_agent_type(),
wait_for_completion=True, wait_for_completion=True,
) )
except asyncio.CancelledError:
# 显式停止会话沿用正常终止语义;服务关闭会由 manager 的稳定异常分支处理。
pass
except Exception as err: except Exception as err:
logger.error(f"Web智能助手执行失败: {str(err)}") logger.error(f"Web智能助手执行失败: {str(err)}")
error_event = { error_event = {
+60 -20
View File
@@ -9,16 +9,17 @@ from fastapi.responses import JSONResponse, StreamingResponse
from app import schemas from app import schemas
from app.api.endpoints.openai import ( from app.api.endpoints.openai import (
MODEL_ID, MODEL_ID,
_CollectingMoviePilotAgent, _is_manager_unavailable,
_run_managed_agent,
) )
from app.api.openai_utils import ( from app.api.openai_utils import (
build_anthropic_messages, build_anthropic_messages,
build_prompt, build_prompt,
build_session_id, build_session_id,
) )
from app.agent.runtime_loader import get_running_agent_manager
from app.runtime.config import settings from app.runtime.config import settings
from app.application.security.access import anthropic_api_key_header from app.application.security.access import anthropic_api_key_header
from app.schemas.types import NotificationChannel
ANTHROPIC_ERROR_RESPONSES = { ANTHROPIC_ERROR_RESPONSES = {
400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"}, 400: {"model": schemas.AnthropicErrorResponse, "description": "请求格式错误"},
@@ -60,19 +61,31 @@ def _check_auth(api_key: Optional[str]) -> Optional[JSONResponse]:
async def _stream_anthropic_response( async def _stream_anthropic_response(
agent: _CollectingMoviePilotAgent, manager,
session_id: str,
user_id: str,
prompt: str, prompt: str,
images: List[str], images: List[str],
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
event_queue: asyncio.Queue = asyncio.Queue() event_queue: asyncio.Queue = asyncio.Queue()
if hasattr(agent.stream_handler, "bind_queue"):
agent.stream_handler.bind_queue(event_queue)
message_id = f"msg_{uuid.uuid4().hex}" message_id = f"msg_{uuid.uuid4().hex}"
async def _run_agent(): async def _run_agent():
try: try:
await agent.process(prompt, images=images, files=None) await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=user_id,
username="anthropic-client",
source="anthropic",
prompt=prompt,
images=images,
stream_mode=True,
event_queue=event_queue,
)
except asyncio.CancelledError:
await event_queue.put({"error": "MoviePilot AI agent is unavailable."})
except Exception as exc: except Exception as exc:
await event_queue.put({"error": str(exc)}) await event_queue.put({"error": str(exc)})
finally: finally:
@@ -87,7 +100,12 @@ async def _stream_anthropic_response(
if item is None: if item is None:
break break
if isinstance(item, dict) and item.get("error"): if isinstance(item, dict) and item.get("error"):
raise RuntimeError(str(item["error"])) yield (
"event: error\n"
f"data: {json.dumps({'type': 'error', 'error': {'type': 'api_error', 'message': str(item['error'])}}, ensure_ascii=False)}\n\n"
)
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n"
return
text = str(item or "") text = str(item or "")
if not text: if not text:
continue continue
@@ -96,6 +114,7 @@ async def _stream_anthropic_response(
yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': 0}}, ensure_ascii=False)}\n\n" yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': 0}}, ensure_ascii=False)}\n\n"
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n" yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'}, ensure_ascii=False)}\n\n"
finally: finally:
await manager.clear_session(session_id=session_id, user_id=user_id)
if not task.done(): if not task.done():
task.cancel() task.cancel()
try: try:
@@ -132,6 +151,13 @@ async def messages(
503, 503,
error_type="api_error", error_type="api_error",
) )
manager = get_running_agent_manager()
if manager is None:
return _anthropic_error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="api_error",
)
normalized_messages = build_anthropic_messages(payload.system, payload.messages) normalized_messages = build_anthropic_messages(payload.system, payload.messages)
try: try:
@@ -141,19 +167,15 @@ async def messages(
session_seed = anthropic_version or "anthropic" session_seed = anthropic_version or "anthropic"
session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX) session_id = build_session_id(f"{session_seed}:{uuid.uuid4().hex}", SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_id,
channel=NotificationChannel.Web.value,
source="anthropic",
username="anthropic-client",
stream_mode=payload.stream,
)
if payload.stream: if payload.stream:
return StreamingResponse( return StreamingResponse(
_stream_anthropic_response(agent=agent, prompt=prompt, images=images), _stream_anthropic_response(
manager=manager,
session_id=session_id,
user_id=session_id,
prompt=prompt,
images=images,
),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@@ -162,14 +184,32 @@ async def messages(
}, },
) )
collected_messages = []
try: try:
result = await agent.process(prompt, images=images, files=None) result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_id,
username="anthropic-client",
source="anthropic",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc: except Exception as exc:
if _is_manager_unavailable(exc):
return _anthropic_error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="api_error",
)
return _anthropic_error_response(str(exc), 500, error_type="api_error") return _anthropic_error_response(str(exc), 500, error_type="api_error")
finally:
await manager.clear_session(session_id=session_id, user_id=session_id)
content = "\n\n".join( content = "\n\n".join(
message.strip() message.strip()
for message in agent.collected_messages for message in collected_messages
if message and message.strip() if message and message.strip()
).strip() ).strip()
if not content and result: if not content and result:
+13 -3
View File
@@ -9,7 +9,8 @@ from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.api.response import ResponseAPIRouter from app.api.response import ResponseAPIRouter
from app.agent.orchestrator import ReplyMode, agent_manager from app.agent.contracts import ReplyMode
from app.agent.runtime_loader import get_running_agent_manager
from app.agent.prompt.transfer_redo import ( from app.agent.prompt.transfer_redo import (
build_batch_manual_redo_prompt, build_batch_manual_redo_prompt,
build_manual_redo_prompt, build_manual_redo_prompt,
@@ -31,6 +32,7 @@ from app.runtime.progress import ProgressHelper
from app.application.history import clear_transfer_failures from app.application.history import clear_transfer_failures
from app.schemas.types import EventType from app.schemas.types import EventType
from app.foundation.text import cut as jieba_cut from app.foundation.text import cut as jieba_cut
from app.runtime.log import logger
router = ResponseAPIRouter() router = ResponseAPIRouter()
@@ -58,7 +60,11 @@ def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str):
async def runner(): async def runner():
try: try:
await agent_manager.run_background_prompt( manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过单条整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt, message=prompt,
session_prefix=f"__agent_manual_redo_{history_id}", session_prefix=f"__agent_manual_redo_{history_id}",
output_callback=update_output, output_callback=update_output,
@@ -103,7 +109,11 @@ def _start_batch_ai_redo_task(
async def runner(): async def runner():
try: try:
await agent_manager.run_background_prompt( manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过批量整理历史 AI 重做")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt, message=prompt,
session_prefix="__agent_manual_redo_batch", session_prefix="__agent_manual_redo_batch",
output_callback=update_output, output_callback=update_output,
+11 -3
View File
@@ -5,13 +5,19 @@ from fastapi.responses import HTMLResponse
from app import schemas from app import schemas
from app.api.response import ResponseAPIRouter from app.api.response import ResponseAPIRouter
from app.agent.llm import LLMProviderManager, render_auth_result_html
from app.db.models import User from app.db.models import User
from app.api.deps import get_current_active_superuser_async from app.api.deps import get_current_active_superuser_async
router = ResponseAPIRouter() router = ResponseAPIRouter()
def _get_llm_provider_manager_type() -> type:
"""在真实管理请求边界解析 provider 运行时。"""
from app.agent.llm.provider import LLMProviderManager
return LLMProviderManager
@router.post( @router.post(
"/manage", "/manage",
summary="LLM提供商统一管理", summary="LLM提供商统一管理",
@@ -37,7 +43,7 @@ async def manage_provider(
"callback_url", "callback_url",
str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)), str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)),
) )
result = await LLMProviderManager().provider_manage( result = await _get_llm_provider_manager_type()().provider_manage(
payload.target, payload.action, **params payload.target, payload.action, **params
) )
return schemas.Response( return schemas.Response(
@@ -70,11 +76,13 @@ async def llm_provider_auth_callback(
""" """
处理需要浏览器回跳的 OAuth provider 处理需要浏览器回跳的 OAuth provider
""" """
success, message = await LLMProviderManager().handle_chatgpt_callback( success, message = await _get_llm_provider_manager_type()().handle_chatgpt_callback(
provider_id, provider_id,
code, code,
state, state,
error, error,
error_description, error_description,
) )
from app.agent.llm.provider import render_auth_result_html
return HTMLResponse(content=render_auth_result_html(success, message)) return HTMLResponse(content=render_auth_result_html(success, message))
+12 -3
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import json import json
import time import time
from typing import Union, Any, List, Optional from typing import Protocol, Union, Any, List, Optional
from fastapi import BackgroundTasks, Depends, Request from fastapi import BackgroundTasks, Depends, Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
@@ -27,7 +28,13 @@ router = ResponseAPIRouter()
_WNS_DEFAULT_TTL = 86400 _WNS_DEFAULT_TTL = 86400
def is_webpush_subscription_gone(error: WebPushException) -> bool: class WebPushError(Protocol):
"""Web Push 订阅状态判断所需的最小异常协议。"""
response: Any # 推送服务响应,状态码字段由具体 SDK 提供
def is_webpush_subscription_gone(error: WebPushError) -> bool:
"""判断 Web Push 订阅是否已在浏览器或推送服务侧失效。""" """判断 Web Push 订阅是否已在浏览器或推送服务侧失效。"""
response: Any = getattr(error, "response", None) response: Any = getattr(error, "response", None)
status_code = getattr(response, "status_code", None) or getattr( status_code = getattr(response, "status_code", None) or getattr(
@@ -359,6 +366,8 @@ def send_notification(
""" """
发送webpush通知 发送webpush通知
""" """
from pywebpush import WebPushException, webpush
for sub in global_vars.get_subscriptions(): for sub in global_vars.get_subscriptions():
try: try:
webpush( webpush(
+243 -36
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import time import time
import uuid import uuid
from threading import Lock
from typing import AsyncIterator, List, Optional, Tuple from typing import AsyncIterator, List, Optional, Tuple
from fastapi import APIRouter, Request, Security from fastapi import APIRouter, Request, Security
@@ -15,8 +16,11 @@ from app.api.openai_utils import (
build_responses_input, build_responses_input,
build_session_id, build_session_id,
) )
from app.agent.callback import StreamingHandler from app.agent.runtime_loader import (
from app.agent.orchestrator import MoviePilotAgent get_moviepilot_agent_type,
get_running_agent_manager,
)
from app.agent.contracts import ReplyMode
from app.runtime.config import settings from app.runtime.config import settings
from app.application.security.access import openai_bearer_scheme from app.application.security.access import openai_bearer_scheme
from app.schemas.types import NotificationChannel from app.schemas.types import NotificationChannel
@@ -35,7 +39,7 @@ MODEL_ID = "moviepilot-agent"
SESSION_PREFIX = "openai:" SESSION_PREFIX = "openai:"
class _CollectingMoviePilotAgent(MoviePilotAgent): class _CollectingMoviePilotAgentMixin:
""" """
捕获 Agent 最终输出避免再通过消息渠道二次发送 捕获 Agent 最终输出避免再通过消息渠道二次发送
""" """
@@ -45,11 +49,38 @@ class _CollectingMoviePilotAgent(MoviePilotAgent):
self.collected_messages: List[str] = [] self.collected_messages: List[str] = []
self.stream_mode = stream_mode self.stream_mode = stream_mode
if stream_mode: if stream_mode:
self.stream_handler = _OpenAIStreamingHandler() self.stream_handler = _get_openai_streaming_handler_type()()
def _should_stream(self) -> bool: def _should_stream(self) -> bool:
return self.stream_mode return self.stream_mode
def configure_protocol_request(
self,
*,
stream_mode: bool,
event_queue: Optional[asyncio.Queue],
) -> None:
"""切换请求级输出目标,并保持已编译工具引用的 handler identity。"""
self.collected_messages = []
self.stream_mode = stream_mode
if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin):
self.stream_handler.bind_queue(event_queue if stream_mode else None)
return
if not stream_mode:
return
self.stream_handler = _get_openai_streaming_handler_type()()
self.stream_handler.bind_queue(event_queue)
# 已编译工具持有旧 handler;identity 变化时必须重建图和工具目录。
self._compiled_agent_bundle = None
def release_protocol_request(
self,
event_queue: Optional[asyncio.Queue],
) -> None:
"""释放已结束请求的输出队列,不影响同会话已重绑的新请求。"""
if isinstance(self.stream_handler, _OpenAIStreamingHandlerMixin):
self.stream_handler.unbind_queue(event_queue)
async def send_agent_message(self, message: str, title: str = ""): async def send_agent_message(self, message: str, title: str = ""):
text = (message or "").strip() text = (message or "").strip()
if title and text: if title and text:
@@ -62,7 +93,7 @@ class _CollectingMoviePilotAgent(MoviePilotAgent):
self.stream_handler.emit(text) self.stream_handler.emit(text)
class _OpenAIStreamingHandler(StreamingHandler): class _OpenAIStreamingHandlerMixin:
""" """
Agent 流式输出转发到 OpenAI SSE 队列不向站内消息系统落消息 Agent 流式输出转发到 OpenAI SSE 队列不向站内消息系统落消息
""" """
@@ -71,9 +102,15 @@ class _OpenAIStreamingHandler(StreamingHandler):
super().__init__() super().__init__()
self._event_queue: Optional[asyncio.Queue] = None self._event_queue: Optional[asyncio.Queue] = None
def bind_queue(self, queue: asyncio.Queue): def bind_queue(self, queue: Optional[asyncio.Queue]):
"""绑定当前协议请求的输出队列。"""
self._event_queue = queue self._event_queue = queue
def unbind_queue(self, queue: Optional[asyncio.Queue]) -> None:
"""仅当仍指向该请求时解除绑定,避免清掉已排队的新请求。"""
if self._event_queue is queue:
self._event_queue = None
def emit(self, token: str): def emit(self, token: str):
emitted = super().emit(token) emitted = super().emit(token)
if emitted and self._event_queue is not None: if emitted and self._event_queue is not None:
@@ -121,18 +158,67 @@ class _OpenAIStreamingHandler(StreamingHandler):
return True, final_text return True, final_text
def _get_openai_streaming_handler_type() -> type:
"""首次兼容协议调用时才解析完整流式处理器。"""
global _OPENAI_STREAMING_HANDLER_TYPE
if _OPENAI_STREAMING_HANDLER_TYPE is not None:
return _OPENAI_STREAMING_HANDLER_TYPE
with _OPENAI_STREAMING_HANDLER_TYPE_LOCK:
if _OPENAI_STREAMING_HANDLER_TYPE is None:
from app.agent.callback import StreamingHandler
_OPENAI_STREAMING_HANDLER_TYPE = type(
"_RuntimeOpenAIStreamingHandler",
(_OpenAIStreamingHandlerMixin, StreamingHandler),
{"__module__": __name__},
)
return _OPENAI_STREAMING_HANDLER_TYPE
_OPENAI_STREAMING_HANDLER_TYPE_LOCK = Lock()
_OPENAI_STREAMING_HANDLER_TYPE: Optional[type] = None
def _build_collecting_agent_type(agent_base_type: type) -> type:
"""为 OpenAI 与 Anthropic 兼容协议组合唯一的运行时类型。"""
return type(
"_RuntimeCollectingMoviePilotAgent",
(_CollectingMoviePilotAgentMixin, agent_base_type),
{"__module__": __name__},
)
_COLLECTING_AGENT_TYPE_LOCK = Lock()
_COLLECTING_AGENT_TYPE: Optional[type] = None
def _get_collecting_agent_type() -> type:
"""在首个真实兼容协议请求边界 single-flight 解析 Agent 类型。"""
global _COLLECTING_AGENT_TYPE
if _COLLECTING_AGENT_TYPE is not None:
return _COLLECTING_AGENT_TYPE
with _COLLECTING_AGENT_TYPE_LOCK:
if _COLLECTING_AGENT_TYPE is None:
_COLLECTING_AGENT_TYPE = _build_collecting_agent_type(
get_moviepilot_agent_type()
)
return _COLLECTING_AGENT_TYPE
def _sse_payload(data: dict) -> str: def _sse_payload(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _stream_response( async def _stream_response(
agent: _CollectingMoviePilotAgent, manager,
session_id: str,
user_id: str,
username: str,
prompt: str, prompt: str,
images: List[str], images: List[str],
cleanup_session: bool,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
event_queue: asyncio.Queue = asyncio.Queue() event_queue: asyncio.Queue = asyncio.Queue()
if isinstance(agent.stream_handler, _OpenAIStreamingHandler):
agent.stream_handler.bind_queue(event_queue)
created = int(time.time()) created = int(time.time())
completion_id = f"chatcmpl-{uuid.uuid4().hex}" completion_id = f"chatcmpl-{uuid.uuid4().hex}"
@@ -140,7 +226,19 @@ async def _stream_response(
async def _run_agent(): async def _run_agent():
try: try:
await agent.process(prompt, images=images, files=None) await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=user_id,
username=username,
source="openai",
prompt=prompt,
images=images,
stream_mode=True,
event_queue=event_queue,
)
except asyncio.CancelledError:
await event_queue.put({"error": "MoviePilot AI agent is unavailable."})
except Exception as exc: except Exception as exc:
await event_queue.put({"error": str(exc)}) await event_queue.put({"error": str(exc)})
finally: finally:
@@ -170,7 +268,17 @@ async def _stream_response(
if item is None: if item is None:
break break
if isinstance(item, dict) and item.get("error"): if isinstance(item, dict) and item.get("error"):
raise RuntimeError(str(item["error"])) yield _sse_payload(
{
"error": {
"message": str(item["error"]),
"type": "server_error",
"code": "agent_execution_failed",
}
}
)
yield "data: [DONE]\n\n"
return
text = str(item or "") text = str(item or "")
if not text: if not text:
continue continue
@@ -208,6 +316,10 @@ async def _stream_response(
) )
yield "data: [DONE]\n\n" yield "data: [DONE]\n\n"
finally: finally:
if cleanup_session:
await manager.clear_session(session_id=session_id, user_id=user_id)
elif not task.done():
await manager.stop_current_task(session_id)
if not task.done(): if not task.done():
task.cancel() task.cancel()
try: try:
@@ -218,6 +330,57 @@ async def _stream_response(
await task await task
def _is_manager_unavailable(error: BaseException) -> bool:
"""识别 manager acceptance gate 的稳定错误,不导入完整编排模块。"""
return getattr(error, "code", None) == "agent_manager_unavailable"
async def _run_managed_agent(
*,
manager,
session_id: str,
user_id: str,
username: str,
source: str,
prompt: str,
images: List[str],
stream_mode: bool,
event_queue: Optional[asyncio.Queue] = None,
) -> tuple[str, List[str]]:
"""通过 AgentManager 执行协议请求,并在 worker 内配置请求级输出。"""
agent_holder = {}
def configure_agent(agent) -> None:
agent.configure_protocol_request(
stream_mode=stream_mode,
event_queue=event_queue,
)
agent_holder["agent"] = agent
try:
result = await manager.process_message(
session_id=session_id,
user_id=user_id,
message=prompt,
images=images,
files=None,
channel=NotificationChannel.Web.value,
source=source,
username=username,
reply_mode=ReplyMode.CAPTURE_ONLY,
allow_message_tools=True,
agent_factory=_get_collecting_agent_type(),
agent_setup=configure_agent,
wait_for_completion=True,
)
agent = agent_holder.get("agent")
return result, list(agent.collected_messages if agent else [])
finally:
agent = agent_holder.get("agent")
if agent is not None:
agent.release_protocol_request(event_queue)
def _error_response( def _error_response(
message: str, message: str,
status_code: int, status_code: int,
@@ -310,6 +473,14 @@ async def chat_completions(
error_type="server_error", error_type="server_error",
code="ai_agent_disabled", code="ai_agent_disabled",
) )
manager = get_running_agent_manager()
if manager is None:
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
if not payload.messages: if not payload.messages:
return _error_response( return _error_response(
@@ -337,19 +508,17 @@ async def chat_completions(
session_id = build_session_id(session_key, SESSION_PREFIX) session_id = build_session_id(session_key, SESSION_PREFIX)
username = str(payload.user or "openai-client") username = str(payload.user or "openai-client")
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=NotificationChannel.Web.value,
source="openai",
username=username,
stream_mode=payload.stream,
)
if payload.stream: if payload.stream:
return StreamingResponse( return StreamingResponse(
_stream_response(agent=agent, prompt=prompt, images=images), _stream_response(
manager=manager,
session_id=session_id,
user_id=session_key,
username=username,
prompt=prompt,
images=images,
cleanup_session=not use_server_session,
),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
@@ -358,19 +527,39 @@ async def chat_completions(
}, },
) )
collected_messages = []
try: try:
result = await agent.process(prompt, images=images, files=None) result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_key,
username=username,
source="openai",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc: except Exception as exc:
if _is_manager_unavailable(exc):
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
return _error_response( return _error_response(
str(exc), str(exc),
500, 500,
error_type="server_error", error_type="server_error",
code="agent_execution_failed", code="agent_execution_failed",
) )
finally:
if not use_server_session:
await manager.clear_session(session_id=session_id, user_id=session_key)
content = "\n\n".join( content = "\n\n".join(
message.strip() message.strip()
for message in agent.collected_messages for message in collected_messages
if message and message.strip() if message and message.strip()
).strip() ).strip()
if not content and result: if not content and result:
@@ -403,6 +592,14 @@ async def responses(
error_type="server_error", error_type="server_error",
code="ai_agent_disabled", code="ai_agent_disabled",
) )
manager = get_running_agent_manager()
if manager is None:
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
if payload.stream: if payload.stream:
return _error_response( return _error_response(
@@ -430,29 +627,39 @@ async def responses(
session_key = str(payload.user or uuid.uuid4()) session_key = str(payload.user or uuid.uuid4())
session_id = build_session_id(session_key, SESSION_PREFIX) session_id = build_session_id(session_key, SESSION_PREFIX)
# 兼容接口的 API_TOKEN 客户端按管理员级 MoviePilot Agent 集成处理。 collected_messages = []
agent = _CollectingMoviePilotAgent(
session_id=session_id,
user_id=session_key,
channel=NotificationChannel.Web.value,
source="openai.responses",
username=str(payload.user or "openai-client"),
stream_mode=False,
)
try: try:
result = await agent.process(prompt, images=images, files=None) result, collected_messages = await _run_managed_agent(
manager=manager,
session_id=session_id,
user_id=session_key,
username=str(payload.user or "openai-client"),
source="openai.responses",
prompt=prompt,
images=images,
stream_mode=False,
)
except Exception as exc: except Exception as exc:
if _is_manager_unavailable(exc):
return _error_response(
"MoviePilot AI agent is unavailable.",
503,
error_type="server_error",
code="ai_agent_unavailable",
)
return _error_response( return _error_response(
str(exc), str(exc),
500, 500,
error_type="server_error", error_type="server_error",
code="agent_execution_failed", code="agent_execution_failed",
) )
finally:
if not payload.user:
await manager.clear_session(session_id=session_id, user_id=session_key)
content = "\n\n".join( content = "\n\n".join(
message.strip() message.strip()
for message in agent.collected_messages for message in collected_messages
if message and message.strip() if message and message.strip()
).strip() ).strip()
if not content and result: if not content and result:
+11 -157
View File
@@ -12,7 +12,13 @@ from starlette.responses import StreamingResponse
from app import schemas from app import schemas
from app.api.response import ResponseAPIRouter 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.cache import async_fresh
from app.runtime.config import settings from app.runtime.config import settings
from app.runtime.events import eventmanager 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.models import User
from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.systemconfig import SystemConfigOper
from app.api.deps import get_current_active_superuser, get_current_active_superuser_async 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.server import MoviePilotServerHelper
from app.adapters.external.market import PluginHelper from app.adapters.external.market import PluginHelper
from app.runtime.log import logger from app.runtime.log import logger
from app.scheduler import Scheduler
from app.schemas.event import PluginDataResetEventData from app.schemas.event import PluginDataResetEventData
from app.schemas.types import ChainEventType, SystemConfigKey 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() router = ResponseAPIRouter()
_plugin_release_refresh_tasks: set[asyncio.Task] = set() _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) 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): 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 # 注册插件API
register_plugin_api(plugin_id) register_plugin_api(plugin_id)
@@ -1045,7 +938,7 @@ def uninstall_plugin(
# 移除插件API # 移除插件API
remove_plugin_api(plugin_id) remove_plugin_api(plugin_id)
# 移除插件服务 # 移除插件服务
Scheduler().remove_plugin_job(plugin_id) remove_plugin_job(plugin_id)
# 判断是否为分身 # 判断是否为分身
plugin_manager = PluginManager() plugin_manager = PluginManager()
plugin_class = plugin_manager.plugins.get(plugin_id) plugin_class = plugin_manager.plugins.get(plugin_id)
@@ -1062,7 +955,7 @@ def uninstall_plugin(
except Exception as e: except Exception as e:
logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(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) plugin_manager.remove_plugin(plugin_id)
return schemas.Response(success=True) 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: except Exception as e:
logger.error(f"处理插件文件夹时出错:{str(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)}")
# 文件夹处理失败不影响插件卸载的整体流程
+4 -3
View File
@@ -41,7 +41,7 @@ from app.adapters.external.market import (
) )
from app.application.messaging.message import MessageHelper from app.application.messaging.message import MessageHelper
from app.runtime.progress import ProgressHelper from app.runtime.progress import ProgressHelper
from app.application.filter import RuleHelper from app.application.rules import RuleHelper
from app.adapters.external.server import MoviePilotServerHelper from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper from app.runtime.state import SystemHelper
from app.runtime.log import logger from app.runtime.log import logger
@@ -1405,8 +1405,9 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
查询已加载的模块ID列表 查询已加载的模块ID列表
""" """
modules = [] modules = []
for module_id, module in ModuleManager().get_modules().items(): for spec in ModuleManager().list_specs():
name = module.get_name() module_id = spec.id
name = str(spec.metadata["name"])
modules.append( modules.append(
{ {
"id": module_id, "id": module_id,
+131
View File
@@ -0,0 +1,131 @@
"""Agent 编排服务门面。
chain 层需要触发 Agent 后台任务渲染提示词查询模型能力时统一经本模块调用
具体实现由 startup 组合根注册形成依赖倒置
chain -> application.agent <- startup -> agent
门面保存 provider 而非重量级实现对象注册本身不会物化 AgentLLM 或工具树
本模块禁止静态或函数内导入 app.agent否则会重新形成跨层循环依赖
"""
from typing import Any, Callable, Optional
Provider = Callable[[], Any]
# provider 注册表由 startup/agent_initializer 在组合根装配。
_agent_manager_provider: Optional[Provider] = None
_running_agent_manager_provider: Optional[Provider] = None
_prompt_manager_provider: Optional[Provider] = None
_agent_capability_manager_provider: Optional[Provider] = None
_llm_helper_provider: Optional[Provider] = None
_manual_redo_prompt_builder_provider: Optional[Provider] = None
def register_agent_service_providers(
*,
agent_manager_provider: Provider,
running_agent_manager_provider: Provider,
prompt_manager_provider: Provider,
capability_manager_provider: Provider,
llm_helper_provider: Provider,
manual_redo_prompt_builder_provider: Provider,
) -> None:
"""注册 Agent 服务 provider,保持组合根装配阶段零重量实现导入。"""
global _agent_manager_provider, _running_agent_manager_provider
global _prompt_manager_provider, _agent_capability_manager_provider
global _llm_helper_provider, _manual_redo_prompt_builder_provider
_agent_manager_provider = agent_manager_provider
_running_agent_manager_provider = running_agent_manager_provider
_prompt_manager_provider = prompt_manager_provider
_agent_capability_manager_provider = capability_manager_provider
_llm_helper_provider = llm_helper_provider
_manual_redo_prompt_builder_provider = manual_redo_prompt_builder_provider
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:
"""兼容直接对象注入;生产组合根应注册惰性 provider。"""
register_agent_service_providers(
agent_manager_provider=lambda: agent_manager,
running_agent_manager_provider=lambda: agent_manager,
prompt_manager_provider=lambda: prompt_manager,
capability_manager_provider=lambda: capability_manager,
llm_helper_provider=lambda: llm_helper,
manual_redo_prompt_builder_provider=lambda: manual_redo_prompt_builder,
)
def _resolve(provider: Optional[Provider], service_name: str) -> Any:
"""解析已注册服务;缺少组合根装配时给出稳定错误。"""
if provider is None:
raise RuntimeError(
f"Agent 服务 {service_name} 未注册:"
"请先导入 app.startup.agent_initializer 完成组合根装配"
)
return provider()
def get_agent_manager() -> Any:
"""返回 canonical AgentManager;调用可能触发实现物化。"""
return _resolve(_agent_manager_provider, "agent_manager")
def get_running_agent_manager() -> Any | None:
"""返回已进入 RUNNING 的 AgentManager,不触发实现物化。"""
return _resolve(_running_agent_manager_provider, "running_agent_manager")
def get_prompt_manager() -> Any:
"""按需返回提示词管理器。"""
return _resolve(_prompt_manager_provider, "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:
"""判断当前模型是否启用了图片输入能力。"""
llm_helper = _resolve(_llm_helper_provider, "llm_helper")
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:
"""判断语音输入能力是否可用。"""
capability_manager = _resolve(
_agent_capability_manager_provider,
"agent_capability_manager",
)
return capability_manager.is_audio_input_available()
def transcribe_audio(content: bytes, filename: str = "input.ogg") -> Optional[str]:
"""把音频内容转写为文本。"""
capability_manager = _resolve(
_agent_capability_manager_provider,
"agent_capability_manager",
)
return capability_manager.transcribe_audio(content, filename=filename)
def build_manual_redo_prompt(history: Any) -> str:
"""构造整理记录 AI 重新整理提示词(builder 由 agent 层注册)。"""
builder = _resolve(
_manual_redo_prompt_builder_provider,
"manual_redo_prompt_builder",
)
if builder is None:
raise RuntimeError("整理记录重新整理提示词构建器未注册")
return builder(history)
+45
View File
@@ -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)
-66
View File
@@ -1,66 +0,0 @@
from typing import List, Optional
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.types import SystemConfigKey
class RuleHelper:
"""读取用户过滤规则配置,并按媒体上下文选择适用规则组。"""
@staticmethod
def get_rule_groups() -> List[FilterRuleGroup]:
"""返回用户配置的全部过滤规则组。"""
rule_groups: List[dict] = SystemConfigOper().get(
SystemConfigKey.UserFilterRuleGroups
)
if not rule_groups:
return []
return [FilterRuleGroup(**group) for group in rule_groups]
def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:
"""按名称返回过滤规则组。"""
return next(
(group for group in self.get_rule_groups() if group.name == group_name),
None,
)
def get_rule_group_by_media(
self,
media: Optional[MediaInfo] = None,
group_names: Optional[list] = None,
) -> List[FilterRuleGroup]:
"""按媒体类型、分类和候选名称筛选适用规则组。"""
rule_groups = self.get_rule_groups()
if group_names:
rule_groups = [
group for group in rule_groups if group.name in group_names
]
return [
group
for group in rule_groups
if not group.media_type
or (
media
and (
(not group.category and group.media_type == media.type.value)
or group.category == media.category
)
)
]
@staticmethod
def get_custom_rules() -> List[CustomRule]:
"""返回用户配置的全部自定义过滤规则。"""
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
if not rules:
return []
return [CustomRule(**rule) for rule in rules]
def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:
"""按 ID 返回一条自定义过滤规则。"""
return next(
(rule for rule in self.get_custom_rules() if rule.id == rule_id),
None,
)
+190
View File
@@ -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)}")
# 文件夹处理失败不影响插件卸载的整体流程
@@ -1,11 +1,79 @@
"""过滤规则解析器与内置规则定义,过滤模块与 Agent 工具共享同一事实来源。""" """
规则域用户规则组配置访问内置规则定义与规则解析器
过滤模块与 Agent 工具共享同一事实来源
"""
import threading import threading
from typing import Dict from typing import Dict, List, Optional
from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults from pyparsing import Forward, Literal, Word, alphas, infix_notation, opAssoc, alphanums, Combine, nums, ParseResults
from app.adapters.system import rust as rust_accel from app.adapters.system import rust as rust_accel
from app.db.oper.systemconfig import SystemConfigOper
from app.domain.context import MediaInfo
from app.schemas import CustomRule, FilterRuleGroup
from app.schemas.types import SystemConfigKey
class RuleHelper:
"""读取用户过滤规则配置,并按媒体上下文选择适用规则组。"""
@staticmethod
def get_rule_groups() -> List[FilterRuleGroup]:
"""返回用户配置的全部过滤规则组。"""
rule_groups: List[dict] = SystemConfigOper().get(
SystemConfigKey.UserFilterRuleGroups
)
if not rule_groups:
return []
return [FilterRuleGroup(**group) for group in rule_groups]
def get_rule_group(self, group_name: str) -> Optional[FilterRuleGroup]:
"""按名称返回过滤规则组。"""
return next(
(group for group in self.get_rule_groups() if group.name == group_name),
None,
)
def get_rule_group_by_media(
self,
media: Optional[MediaInfo] = None,
group_names: Optional[list] = None,
) -> List[FilterRuleGroup]:
"""按媒体类型、分类和候选名称筛选适用规则组。"""
rule_groups = self.get_rule_groups()
if group_names:
rule_groups = [
group for group in rule_groups if group.name in group_names
]
return [
group
for group in rule_groups
if not group.media_type
or (
media
and (
(not group.category and group.media_type == media.type.value)
or group.category == media.category
)
)
]
@staticmethod
def get_custom_rules() -> List[CustomRule]:
"""返回用户配置的全部自定义过滤规则。"""
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
if not rules:
return []
return [CustomRule(**rule) for rule in rules]
def get_custom_rule(self, rule_id: str) -> Optional[CustomRule]:
"""按 ID 返回一条自定义过滤规则。"""
return next(
(rule for rule in self.get_custom_rules() if rule.id == rule_id),
None,
)
# 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。 # 内置规则只在这里维护一份,便于过滤模块和 Agent 工具共享同一套事实来源。
BUILTIN_RULE_SET: Dict[str, dict] = { BUILTIN_RULE_SET: Dict[str, dict] = {
+73
View File
@@ -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)
+891 -4
View File
@@ -13,20 +13,38 @@ app.schemas -> app.schemas.transfer -> app.domain.* -> app.schemas.types -> app.
TransferJob / TransferJobTask那两个用 app.schemas 的同名 DTO一个是工作项一个是 TransferJob / TransferJobTask那两个用 app.schemas 的同名 DTO一个是工作项一个是
视图分开表达之后两边都不必再迁就对方 视图分开表达之后两边都不必再迁就对方
""" """
import asyncio
import threading
from copy import deepcopy
from pathlib import Path 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 pydantic import BaseModel, ConfigDict
from app import schemas
from app.adapters.system.host import SystemUtils
from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.domain.context import MediaInfo, MusicInfo 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.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.file import FileItem
from app.schemas.history import DownloadHistory 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.system import TransferDirectoryConf
from app.schemas.tmdb import TmdbEpisode from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo from app.schemas.transfer import TransferInfo, TransferJob, TransferJobTask
from app.schemas.types import MediaSource, MediaType from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaSource,
MediaType,
)
class TransferTask(OptionalMediaIdentityMixin, BaseModel): class TransferTask(OptionalMediaIdentityMixin, BaseModel):
@@ -89,3 +107,872 @@ class TransferQueue(BaseModel):
callback: Optional[Callable] = None callback: Optional[Callable] = None
# 整理结果 # 整理结果
result: Optional[TransferInfo] = 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,
# 不能复用 MediaInfoyear 为 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:
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过整理失败自动重试")
return
await 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}"
)
+18 -979
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
from typing import Optional, Tuple, Union
from app.schemas.types import NotificationChannel
class InteractionChainMixin:
"""
斜杠命令交互四件套委托remote_list / parse_callback /
handle_callback_interaction / handle_text_interaction
subscribesite 等业务链的交互入口完全同构唯一差异是各自的
交互处理器构造参数 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: NotificationChannel = 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: NotificationChannel,
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: NotificationChannel,
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,
)
+486
View File
@@ -0,0 +1,486 @@
"""消息处理与通知发送 mixin。
ChainBase 拆出的消息域渠道输入状态机通知派发规范化消息渲染
隔离路由与队列发送方法经 MRO 解析依赖 ChainBase 实例的 run_module
eventmanagermessageopermessagequeue 等协作对象
"""
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, Message, TransferInfo
from app.schemas.message import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import EventType, NotificationChannel
class MessageProcessingMixin:
"""消息输入/处理状态机与通知派发规范化。"""
def start_message_processing_status(
self,
channel: NotificationChannel,
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[NotificationChannel] = 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 = NotificationChannel(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: Message
) -> Message:
"""
规范化待发送的通知消息
后台任务会复用内部占位用户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: Message) -> dict:
"""
构造消息通知事件数据
"""
return {**message.model_dump(exclude={"save_history"}), "type": message.mtype}
class NotificationMixin:
"""通知消息发送域:渲染、隔离路由、队列发送与消息编辑。"""
def post_message(
self,
message: Optional[Message] = 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[Message] = 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: Message, 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: Message, 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: NotificationChannel,
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: NotificationChannel,
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 == NotificationChannel.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: Message) -> 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)
+420
View File
@@ -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)
+518
View File
@@ -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
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
"""Agent 业务处理链。
AgentChain agent 编排在链层的入口Agent 运行时会话需要复用
ChainBase 提供的消息处理状态机渠道处理状态直发消息等
因此继承关系归属链层具体 Agent 运行时MoviePilotAgent 留在 app.agent
"""
from app.chain import ChainBase
class AgentChain(ChainBase):
"""Agent 业务处理链。"""
pass
+69 -27
View File
@@ -11,8 +11,12 @@ from pathlib import Path
from typing import Any, Optional, Dict, Union, List, Tuple from typing import Any, Optional, Dict, Union, List, Tuple
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from app.agent.orchestrator import agent_manager from app.application.agent import (
from app.agent.llm import AgentCapabilityManager, LLMHelper get_running_agent_manager,
is_audio_input_available,
supports_image_input,
transcribe_audio,
)
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
@@ -66,9 +70,14 @@ class MessageChain(ChainBase):
""" """
if not session_id: if not session_id:
return return
manager = get_running_agent_manager()
if manager is None:
return
clear_task = None clear_task = None
try: try:
clear_task = agent_manager.clear_session(session_id=session_id, user_id=str(userid)) clear_task = manager.clear_session(
session_id=session_id, user_id=str(userid)
)
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
clear_task, clear_task,
global_vars.loop, global_vars.loop,
@@ -346,7 +355,8 @@ class MessageChain(ChainBase):
if not session_info: if not session_info:
return False return False
session_id, _ = session_info session_id, _ = session_info
if not agent_manager.matches_secret_confirmation( manager = get_running_agent_manager()
if manager is None or not manager.matches_secret_confirmation(
session_id, session_id,
str(userid), str(userid),
channel=channel.value, channel=channel.value,
@@ -964,19 +974,21 @@ class MessageChain(ChainBase):
# 如果有会话ID,同时清除智能体的会话记忆 # 如果有会话ID,同时清除智能体的会话记忆
if session_id: if session_id:
manager = get_running_agent_manager()
clear_task = None clear_task = None
try: if manager is not None:
clear_task = agent_manager.clear_session( try:
session_id=session_id, user_id=str(userid) clear_task = manager.clear_session(
) session_id=session_id, user_id=str(userid)
asyncio.run_coroutine_threadsafe( )
clear_task, asyncio.run_coroutine_threadsafe(
global_vars.loop, clear_task,
) global_vars.loop,
except Exception as e: )
if clear_task: except Exception as e:
clear_task.close() if clear_task:
logger.warning(f"清除智能体会话记忆失败: {e}") clear_task.close()
logger.warning(f"清除智能体会话记忆失败: {e}")
self.post_message( self.post_message(
Message( Message(
@@ -1013,12 +1025,16 @@ class MessageChain(ChainBase):
session_info = self._user_sessions.get(userid) session_info = self._user_sessions.get(userid)
if session_info: if session_info:
session_id, _ = session_info session_id, _ = session_info
manager = get_running_agent_manager()
try: try:
future = asyncio.run_coroutine_threadsafe( if manager is None:
agent_manager.stop_current_task(session_id=session_id), stopped = False
global_vars.loop, else:
) future = asyncio.run_coroutine_threadsafe(
stopped = future.result(timeout=10) manager.stop_current_task(session_id=session_id),
global_vars.loop,
)
stopped = future.result(timeout=10)
except Exception as e: except Exception as e:
logger.warning(f"停止Agent推理失败: {e}") logger.warning(f"停止Agent推理失败: {e}")
stopped = False stopped = False
@@ -1180,7 +1196,19 @@ class MessageChain(ChainBase):
return return
session_id, _ = session_info session_id, _ = session_info
status = agent_manager.get_session_status(session_id=session_id) manager = get_running_agent_manager()
if manager is None:
self.post_message(
Message(
channel=channel,
source=source,
title="您当前没有活跃的智能体会话",
userid=userid,
save_history=False,
)
)
return
status = manager.get_session_status(session_id=session_id)
self.post_message( self.post_message(
Message( Message(
channel=channel, channel=channel,
@@ -1225,6 +1253,20 @@ class MessageChain(ChainBase):
) )
return False return False
manager = get_running_agent_manager()
if manager is None:
self.post_message(
Message(
channel=channel,
source=source,
userid=userid,
username=username,
title="MoviePilot智能助手服务尚未就绪,请稍后重试",
save_history=False,
)
)
return False
images = IncomingMessage.MessageImage.normalize_list(images) images = IncomingMessage.MessageImage.normalize_list(images)
# 提取用户消息 # 提取用户消息
@@ -1254,7 +1296,7 @@ class MessageChain(ChainBase):
# 将可直接输入给 LLM 的附件统一转换为 data URL # 将可直接输入给 LLM 的附件统一转换为 data URL
original_images = images original_images = images
all_files = list(files or []) all_files = list(files or [])
if images and LLMHelper.supports_image_input( if images and supports_image_input(
provider=settings.LLM_PROVIDER, provider=settings.LLM_PROVIDER,
model=settings.LLM_MODEL, model=settings.LLM_MODEL,
): ):
@@ -1333,7 +1375,7 @@ class MessageChain(ChainBase):
process_kwargs["has_audio_input"] = True process_kwargs["has_audio_input"] = True
# 在事件循环中处理 # 在事件循环中处理
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
agent_manager.process_message(**process_kwargs), manager.process_message(**process_kwargs),
global_vars.loop, global_vars.loop,
) )
return True return True
@@ -1353,7 +1395,7 @@ class MessageChain(ChainBase):
""" """
if not audio_refs: if not audio_refs:
return None return None
if not AgentCapabilityManager.is_audio_input_available(): if not is_audio_input_available():
logger.warning("音频输入能力未配置或未启用,跳过语音识别") logger.warning("音频输入能力未配置或未启用,跳过语音识别")
return None return None
@@ -1460,7 +1502,7 @@ class MessageChain(ChainBase):
) )
continue continue
transcript = AgentCapabilityManager.transcribe_audio( transcript = transcribe_audio(
content=content, filename=filename content=content, filename=filename
) )
if transcript: if transcript:
@@ -1850,4 +1892,4 @@ class MessageChain(ChainBase):
return base64.b64decode(payload) return base64.b64decode(payload)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return None return None
+8 -4
View File
@@ -509,10 +509,10 @@ class SearchChain(ChainBase):
""" """
通过统一后台提示词机制执行资源推荐 通过统一后台提示词机制执行资源推荐
""" """
from app.agent.orchestrator import ReplyMode, agent_manager from app.application.agent import get_prompt_manager, get_running_agent_manager
from app.agent.prompt import 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", "search_recommend",
template_context={"search_results": search_results_text}, template_context={"search_results": search_results_text},
) )
@@ -521,7 +521,11 @@ class SearchChain(ChainBase):
def on_output(text: str): def on_output(text: str):
full_output[0] = text full_output[0] = text
await agent_manager.run_background_prompt( manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过搜索结果 AI 推荐")
raise RuntimeError("智能助手服务未运行")
await manager.run_background_prompt(
message=prompt, message=prompt,
session_prefix="__agent_search_recommend", session_prefix="__agent_search_recommend",
output_callback=on_output, output_callback=on_output,
+6 -67
View File
@@ -1,13 +1,14 @@
import base64 import base64
import re import re
from datetime import datetime 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 urllib.parse import urljoin
from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module from app.application.site.sites import SitesHelper # pylint: disable=no-name-in-module
from lxml import etree from lxml import etree
from app.chain import ChainBase from app.chain import ChainBase
from app.chain._interaction import InteractionChainMixin
from app.runtime.config import global_vars, settings from app.runtime.config import global_vars, settings
from app.runtime.events import Event, eventmanager from app.runtime.events import Event, eventmanager
from app.db.models.site import Site 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.adapters.network.cloudflare import under_challenge
from app.application.security.cookie import CookieHelper from app.application.security.cookie import CookieHelper
from app.adapters.external.cookiecloud import CookieCloudHelper from app.adapters.external.cookiecloud import CookieCloudHelper
from app.application.messaging.site import ( from app.application.messaging.site import SiteInteractionHandler
SiteInteractionHandler,
site_interaction_manager,
)
from app.application.rss import RssHelper from app.application.rss import RssHelper
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas import NotificationChannel, Message, SiteUserData from app.schemas import NotificationChannel, Message, SiteUserData
@@ -33,12 +31,13 @@ from app.foundation import url as url_tools
from app.foundation.dom import DomUtils from app.foundation.dom import DomUtils
class SiteChain(InteractionChainMixin, ChainBase):
class SiteChain(ChainBase):
""" """
站点管理处理链 站点管理处理链
""" """
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
_interaction_handler_type = SiteInteractionHandler
def __init__(self): def __init__(self):
"""初始化站点管理处理链及特殊站点测试器""" """初始化站点管理处理链及特殊站点测试器"""
@@ -752,66 +751,6 @@ class SiteChain(ChainBase):
"""构造 /sites 交互处理器,Cookie 更新动作由本链提供。""" """构造 /sites 交互处理器,Cookie 更新动作由本链提供。"""
return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie) return SiteInteractionHandler(messenger=self, cookie_updater=self.update_cookie)
def remote_list(
self,
arg_str: str = "",
channel: NotificationChannel = 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: NotificationChannel,
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: NotificationChannel,
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: NotificationChannel, def remote_disable(self, arg_str: str, channel: NotificationChannel,
userid: Union[str, int] = None, source: Optional[str] = None): userid: Union[str, int] = None, source: Optional[str] = None):
""" """
+8 -451
View File
@@ -1,7 +1,6 @@
import copy import copy
import json import json
import random import random
import re
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
@@ -9,6 +8,8 @@ from typing import Any, Callable, Dict, List, Optional, Union, Tuple
from app import schemas from app import schemas
from app.chain import ChainBase 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.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain from app.chain.mediaserver import MediaServerChain
@@ -19,7 +20,6 @@ from app.runtime.config import settings, global_vars
from app.domain.context import ( from app.domain.context import (
Context, Context,
MediaInfo, MediaInfo,
MusicInfo,
TorrentInfo, TorrentInfo,
) )
from app.runtime.events import eventmanager, Event 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.site import SiteOper
from app.db.oper.subscribe import SubscribeOper from app.db.oper.subscribe import SubscribeOper
from app.db.oper.systemconfig import SystemConfigOper from app.db.oper.systemconfig import SystemConfigOper
from app.application.messaging.subscribe import ( from app.application.messaging.subscribe import SubscribeInteractionHandler
SubscribeInteractionHandler,
subscribe_interaction_manager,
)
from app.application.mediaserver import MediaServerHelper from app.application.mediaserver import MediaServerHelper
from app.application.subscribe import add_subscribe, async_add_subscribe from app.application.subscribe import add_subscribe, async_add_subscribe
from app.adapters.external.server import MoviePilotServerHelper 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.runtime.log import logger
from app.schemas import (SubscribeEpisodesRefreshEventData, from app.schemas import (SubscribeEpisodesRefreshEventData,
SubscribeCompletionCheckEventData) SubscribeCompletionCheckEventData)
from app.schemas.types import MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaType, SystemConfigKey, NotificationChannel, MessageType, EventType, ChainEventType, \ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType, SystemConfigKey, NotificationChannel, MessageType, EventType, ChainEventType, \
ContentType ContentType
from app.domain.media import MUSIC_SUBSCRIBABLE_TYPES
from app.schemas.media import build_media_key, normalize_media_source, resolve_media_identity 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: 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, "")] 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 单独维护 电影下载优先级 writer 单独维护
""" """
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
_interaction_handler_type = SubscribeInteractionHandler
_rlock = threading.RLock() _rlock = threading.RLock()
# 避免莫名原因导致长时间持有锁 # 避免莫名原因导致长时间持有锁
_LOCK_TIMOUT = 3600 * 2 _LOCK_TIMOUT = 3600 * 2
@@ -1261,378 +1250,6 @@ class SubscribeChain(ChainBase):
return True return True
return False 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( def search(
self, self,
sid: Optional[int] = None, sid: Optional[int] = None,
@@ -3247,66 +2864,6 @@ class SubscribeChain(ChainBase):
"""构造 /subscribes 交互处理器,业务动作由本链提供。""" """构造 /subscribes 交互处理器,业务动作由本链提供。"""
return SubscribeInteractionHandler(messenger=self, actions=self) return SubscribeInteractionHandler(messenger=self, actions=self)
def remote_list(
self,
arg_str: str = "",
channel: NotificationChannel = 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: NotificationChannel,
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: NotificationChannel,
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: NotificationChannel, def remote_delete(self, arg_str: str, channel: NotificationChannel,
userid: Union[str, int] = None, source: Optional[str] = None): userid: Union[str, int] = None, source: Optional[str] = None):
""" """
+73 -2436
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1490,6 +1490,11 @@ class MediaInfo:
meta = MetaInfo(self.title) meta = MetaInfo(self.title)
season = meta.begin_season if meta.begin_season is not None else 1 season = meta.begin_season if meta.begin_season is not None else 1
episodes_count = info.get("total_episodes") or info.get("eps") 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: if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1)) self.seasons[season] = list(range(1, episodes_count + 1))
self.number_of_episodes = episodes_count self.number_of_episodes = episodes_count
+5
View File
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from app.api.response import ResponseAPIRoute from app.api.response import ResponseAPIRoute
from app.application.plugins import register_api_app
from app.runtime.config import settings from app.runtime.config import settings
from app.runtime.localization import LocaleHelper from app.runtime.localization import LocaleHelper
from app.runtime.log import logger from app.runtime.log import logger
@@ -326,3 +327,7 @@ def create_app() -> FastAPI:
# 创建 FastAPI 应用实例 # 创建 FastAPI 应用实例
app = create_app() app = create_app()
# 向 application 层插件路由服务注入应用实例,插件 API 的动态注册/移除
# 统一经服务完成,避免 api.endpoints 反向依赖本模块。
register_api_app(app)
+10 -6
View File
@@ -9,6 +9,7 @@ class Singleton(abc.ABCMeta, type):
""" """
_instances: dict = {} _instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls, *args, **kwargs): def get_existing_instance(cls, *args, **kwargs):
"""按相同参数返回已创建实例,不触发初始化""" """按相同参数返回已创建实例,不触发初始化"""
@@ -18,9 +19,10 @@ class Singleton(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
"""按类和构造参数创建或复用实例。""" """按类和构造参数创建或复用实例。"""
key = (cls, args, frozenset(kwargs.items())) key = (cls, args, frozenset(kwargs.items()))
if key not in cls._instances: with cls._lock:
cls._instances[key] = super().__call__(*args, **kwargs) if key not in cls._instances:
return cls._instances[key] cls._instances[key] = super().__call__(*args, **kwargs)
return cls._instances[key]
class AbstractSingleton(abc.ABC, metaclass=Singleton): class AbstractSingleton(abc.ABC, metaclass=Singleton):
@@ -36,6 +38,7 @@ class SingletonClass(abc.ABCMeta, type):
""" """
_instances: dict = {} _instances: dict = {}
_lock = threading.RLock()
def get_existing_instance(cls): def get_existing_instance(cls):
"""返回已创建实例,不触发初始化""" """返回已创建实例,不触发初始化"""
@@ -43,9 +46,10 @@ class SingletonClass(abc.ABCMeta, type):
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
"""按类创建或复用唯一实例。""" """按类创建或复用唯一实例。"""
if cls not in cls._instances: with cls._lock:
cls._instances[cls] = super().__call__(*args, **kwargs) if cls not in cls._instances:
return cls._instances[cls] cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass): class AbstractSingletonClass(abc.ABC, metaclass=SingletonClass):
+4 -1
View File
@@ -3,7 +3,7 @@ from abc import abstractmethod, ABCMeta
from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable from typing import Generic, Tuple, Union, TypeVar, Type, Dict, Optional, Callable
from pathlib import Path from pathlib import Path
from app.runtime.extensions.service_registry import ServiceConfigHelper from app.runtime.extensions.service_config import ServiceConfigHelper
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas import Message, NotificationConf, MediaServerConf, DownloaderConf from app.schemas import Message, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, NotificationChannel, StorageSchema, \ from app.schemas.types import ModuleType, DownloaderType, MediaServerType, NotificationChannel, StorageSchema, \
@@ -17,6 +17,9 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
输入参数与输出参数一致的或没有输出的可以被多个模块重复实现 输入参数与输出参数一致的或没有输出的可以被多个模块重复实现
""" """
# Host Module 的配置事件由统一 Adapter 协调,避免同一 generation 被双重重载。
CONFIG_RELOAD_MANAGED_EXTERNALLY = True
def __init__(self) -> None: def __init__(self) -> None:
"""初始化模块生命周期锁""" """初始化模块生命周期锁"""
super().__init__() super().__init__()
+15
View File
@@ -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",
]
+109
View File
@@ -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
+192
View File
@@ -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
+149
View File
@@ -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)
+19
View File
@@ -0,0 +1,19 @@
schema_version = 1
id = "AcoustIdModule"
kind = "host_module"
entrypoint = "app.modules.acoustid:AcoustIdModule"
depends_on = []
[metadata]
name = "AcoustID"
type = "other"
subtype = "AcoustId"
priority = 0
[activation]
policy = "when_configured"
watch = ["ACOUSTID_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "ACOUSTID_API_KEY"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "AniListModule"
kind = "host_module"
entrypoint = "app.modules.anilist:AniListModule"
depends_on = []
[metadata]
name = "AniList"
type = "mediarecognize"
subtype = "AniList"
priority = 4
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "BangumiModule"
kind = "host_module"
entrypoint = "app.modules.bangumi:BangumiModule"
depends_on = []
[metadata]
name = "Bangumi"
type = "mediarecognize"
subtype = "Bangumi"
priority = 3
[activation]
policy = "bootstrap"
watch = ["PROXY_HOST"]
+5 -96
View File
@@ -1,17 +1,15 @@
import copy
import json import json
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.runtime.events import eventmanager
from app.application.messaging.agent import ( from app.application.messaging.agent import (
matches_channel_admin, matches_channel_admin,
register_channel_admin_resolver, register_channel_admin_resolver,
resolve_config_principal_ids, resolve_config_principal_ids,
) )
from app.runtime.log import logger from app.runtime.log import logger
from app.modules import _ModuleBase, _MessageBase from app.modules._base import _MessageChannelModuleBase
from app.schemas import ( from app.schemas import (
CommandRegisterEventData, CommandRegisterEventData,
IncomingMessage, IncomingMessage,
@@ -19,9 +17,8 @@ from app.schemas import (
MessageResponse, MessageResponse,
Message, Message,
) )
from app.schemas.types import ChainEventType, ModuleType from app.schemas.types import ModuleType
from app.adapters.network.http import RequestUtils from app.adapters.network.http import RequestUtils
from app.foundation.collections import DictUtils
try: try:
from app.modules.discord.discord import Discord from app.modules.discord.discord import Discord
@@ -36,7 +33,9 @@ register_channel_admin_resolver(
) )
class DiscordModule(_ModuleBase, _MessageBase[Discord]): class DiscordModule(_MessageChannelModuleBase[Discord]):
# 管理员配置键,与渠道 resolver 保持一致
_admin_config_key = "DISCORD_ADMINS"
_IMAGE_SUFFIXES = ( _IMAGE_SUFFIXES = (
".png", ".png",
".jpg", ".jpg",
@@ -107,51 +106,9 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
except Exception as err: except Exception as err:
logger.error(f"停止Discord模块实例失败:{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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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 @staticmethod
def _send_admin_denied( def _send_admin_denied(
client: Optional[Discord], client: Optional[Discord],
@@ -556,54 +513,6 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
return True return True
return False 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( def mark_message_processing_started(
self, self,
channel: NotificationChannel, channel: NotificationChannel,
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "DiscordModule"
kind = "host_module"
entrypoint = "app.modules.discord:DiscordModule"
depends_on = []
[metadata]
name = "Discord"
type = "notification"
subtype = "Discord"
priority = 4
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "discord"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "DoubanModule"
kind = "host_module"
entrypoint = "app.modules.douban:DoubanModule"
depends_on = []
[metadata]
name = "豆瓣"
type = "mediarecognize"
subtype = "Douban"
priority = 2
[activation]
policy = "bootstrap"
watch = []
+6 -140
View File
@@ -1,16 +1,16 @@
from typing import Any, Dict, Generator, List, Optional, Tuple, Union from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas 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.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.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: def init_module(self) -> None:
""" """
@@ -47,70 +47,9 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
def stop(self): def stop(self):
pass 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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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]: def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
""" """
解析Webhook报文体 解析Webhook报文体
@@ -136,79 +75,6 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
return result return result
return None 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]]: def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:
""" """
媒体数量统计 媒体数量统计
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "EmbyModule"
kind = "host_module"
entrypoint = "app.modules.emby:EmbyModule"
depends_on = []
[metadata]
name = "Emby"
type = "mediaserver"
subtype = "Emby"
priority = 1
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "emby"
enabled_field = "enabled"
+19
View File
@@ -0,0 +1,19 @@
schema_version = 1
id = "FanartModule"
kind = "host_module"
entrypoint = "app.modules.fanart:FanartModule"
depends_on = []
[metadata]
name = "Fanart"
type = "other"
subtype = "Fanart"
priority = 0
[activation]
policy = "when_configured"
watch = ["FANART_API_KEY"]
[activation.selector]
kind = "setting_truthy"
key = "FANART_API_KEY"
+9 -11
View File
@@ -3,7 +3,7 @@ from typing import Any, List, Optional, Tuple, Union
from app.domain.context import Context, MediaInfo from app.domain.context import Context, MediaInfo
from app.application.messaging.agent import register_channel_admin_resolver, resolve_config_principal_ids from app.application.messaging.agent import register_channel_admin_resolver, resolve_config_principal_ids
from app.runtime.log import logger 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.modules.feishu.feishu import Feishu
from app.schemas import IncomingMessage, NotificationChannel, MessageResponse, Message from app.schemas import IncomingMessage, NotificationChannel, MessageResponse, Message
from app.schemas.types import ModuleType 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: def init_module(self) -> None:
super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu) super().init_service(service_name=Feishu.__name__.lower(), service_type=Feishu)
self._channel = NotificationChannel.Feishu self._channel = NotificationChannel.Feishu
@@ -38,6 +38,13 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
def get_priority() -> int: def get_priority() -> int:
return 2 return 2
def _commands_enabled(self, config: Optional[dict]) -> bool:
"""
飞书机器人无斜杠命令概念lark_oapi Client 也不提供命令注册/删除 API
跳过命令注册避免基类默认钩子调用不存在的 client.register_commands
"""
return False
def stop(self) -> None: def stop(self) -> None:
"""停止模块""" """停止模块"""
for client in self.get_instances().values(): for client in self.get_instances().values():
@@ -46,15 +53,6 @@ class FeishuModule(_ModuleBase, _MessageBase[Feishu]):
except Exception as err: except Exception as err:
logger.error(f"停止飞书模块实例失败:{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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。""" """通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。"""
return None return None
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "FeishuModule"
kind = "host_module"
entrypoint = "app.modules.feishu:FeishuModule"
depends_on = []
[metadata]
name = "飞书"
type = "notification"
subtype = "Feishu"
priority = 2
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "feishu"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "FileManagerModule"
kind = "host_module"
entrypoint = "app.modules.filemanager:FileManagerModule"
depends_on = []
[metadata]
name = "文件整理"
type = "other"
subtype = "FileManager"
priority = 4
[activation]
policy = "bootstrap"
watch = []
+3 -3
View File
@@ -5,11 +5,11 @@ from typing import List, Tuple, Union, Dict, Optional
from app.domain.context import TorrentInfo, MediaInfo from app.domain.context import TorrentInfo, MediaInfo
from app.domain.metainfo import MetaInfo, clear_rust_parse_options_cache, _rust_parse_options from app.domain.metainfo import MetaInfo, clear_rust_parse_options_cache, _rust_parse_options
from app.application.filter import RuleHelper from app.application.rules import RuleHelper
from app.runtime.log import logger from app.runtime.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.application.filter_rules import RuleParser from app.application.rules import RuleParser
from app.application.filter_rules import BUILTIN_RULE_SET from app.application.rules import BUILTIN_RULE_SET
from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey from app.schemas.types import ModuleType, OtherModulesType, SystemConfigKey
from app.adapters.system import rust as rust_accel from app.adapters.system import rust as rust_accel
from app.foundation import size as size_tools from app.foundation import size as size_tools
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "FilterModule"
kind = "host_module"
entrypoint = "app.modules.filter:FilterModule"
depends_on = []
[metadata]
name = "过滤器"
type = "other"
subtype = "Filter"
priority = 4
[activation]
policy = "bootstrap"
watch = ["CustomFilterRules", "CustomIdentifiers", "CustomReleaseGroups", "Customization"]
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "IndexerModule"
kind = "host_module"
entrypoint = "app.modules.indexer:IndexerModule"
depends_on = []
[metadata]
name = "站点索引"
type = "indexer"
subtype = "Indexer"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+6 -141
View File
@@ -1,17 +1,16 @@
from typing import Any, Dict, Generator, List, Optional, Tuple, Union from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from app import schemas 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.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.modules.jellyfin.jellyfin import Jellyfin
from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas.types import ModuleType, MediaServerType
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]): class JellyfinModule(_MediaServerModuleBase[Jellyfin]):
# 媒体库标识(ExistMediaInfo.server_type
_server_type_value = "jellyfin"
def init_module(self) -> None: def init_module(self) -> None:
""" """
@@ -48,70 +47,9 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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): def stop(self):
pass 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]: def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
""" """
解析Webhook报文体 解析Webhook报文体
@@ -137,79 +75,6 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
return result return result
return None 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]]: def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:
""" """
媒体数量统计 媒体数量统计
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "JellyfinModule"
kind = "host_module"
entrypoint = "app.modules.jellyfin:JellyfinModule"
depends_on = []
[metadata]
name = "Jellyfin"
type = "mediaserver"
subtype = "Jellyfin"
priority = 2
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "jellyfin"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "ListenBrainzModule"
kind = "host_module"
entrypoint = "app.modules.listenbrainz:ListenBrainzModule"
depends_on = []
[metadata]
name = "ListenBrainz"
type = "other"
subtype = "ListenBrainz"
priority = 5
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "LrclibModule"
kind = "host_module"
entrypoint = "app.modules.lrclib:LrclibModule"
depends_on = []
[metadata]
name = "LRCLIB"
type = "other"
subtype = "Lrclib"
priority = 5
[activation]
policy = "bootstrap"
watch = []
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "MusicBrainzModule"
kind = "host_module"
entrypoint = "app.modules.musicbrainz:MusicBrainzModule"
depends_on = []
[metadata]
name = "MusicBrainz"
type = "mediarecognize"
subtype = "MusicBrainz"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "NavidromeModule"
kind = "host_module"
entrypoint = "app.modules.navidrome:NavidromeModule"
depends_on = []
[metadata]
name = "Navidrome"
type = "mediaserver"
subtype = "Navidrome"
priority = 7
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "navidrome"
enabled_field = "enabled"
+12 -24
View File
@@ -5,13 +5,16 @@ from app.domain.context import MediaInfo
from app.runtime.events import eventmanager from app.runtime.events import eventmanager
from app.application.mediaserver import MusicMediaServerHelper from app.application.mediaserver import MusicMediaServerHelper
from app.runtime.log import logger 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.modules.plex.plex import Plex
from app.schemas import AuthCredentials, AuthInterceptCredentials from app.schemas import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType 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: def init_module(self) -> None:
""" """
@@ -54,32 +57,17 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
except Exception as err: except Exception as err:
logger.error(f"停止Plex模块实例失败:{err}") logger.error(f"停止Plex模块实例失败:{err}")
def test(self) -> Optional[Tuple[bool, str]]: def _test_server(self, server, name: str) -> Optional[str]:
""" """Plex 用媒体库列表探测连接状态。"""
测试模块连接性 if server.is_inactive():
""" server.reconnect()
if not self.get_instances(): if not server.get_librarys():
return None return f"无法连接Plex服务器:{name}"
for name, server in self.get_instances().items(): return None
if server.is_inactive():
server.reconnect()
if not server.get_librarys():
return False, f"无法连接Plex服务器:{name}"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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) \ def user_authenticate(self, credentials: AuthCredentials, service_name: Optional[str] = None) \
-> Optional[AuthCredentials]: -> Optional[AuthCredentials]:
""" """
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "PlexModule"
kind = "host_module"
entrypoint = "app.modules.plex:PlexModule"
depends_on = []
[metadata]
name = "Plex"
type = "mediaserver"
subtype = "Plex"
priority = 3
[activation]
policy = "when_configured"
watch = ["MediaServers"]
[activation.selector]
kind = "system_config_item"
key = "MediaServers"
match_field = "type"
match_value = "plex"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "PostgreSQLModule"
kind = "host_module"
entrypoint = "app.modules.postgresql:PostgreSQLModule"
depends_on = []
[metadata]
name = "PostgreSQL"
type = "other"
subtype = "PostgreSQL"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+4 -92
View File
@@ -2,14 +2,12 @@ from pathlib import Path
from typing import Set, Tuple, Optional, Union, List, Dict from typing import Set, Tuple, Optional, Union, List, Dict
from qbittorrentapi import TorrentFilesList from qbittorrentapi import TorrentFilesList
from torrentool.torrent import Torrent
from app import schemas from app import schemas
from app.runtime.cache import FileCache
from app.runtime.config import settings from app.runtime.config import settings
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger 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.modules.qbittorrent.qbittorrent import Qbittorrent
from app.schemas import DownloaderTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import ( from app.schemas.types import (
@@ -19,7 +17,6 @@ from app.schemas.types import (
TorrentQueryStatus, TorrentQueryStatus,
TorrentStatus, TorrentStatus,
) )
from app.domain import torrent as torrent_rules
from app.foundation import size as size_tools from app.foundation import size as size_tools
from app.foundation import temporal as time_tools from app.foundation import temporal as time_tools
from app.foundation import text as text_tools from app.foundation import text as text_tools
@@ -44,7 +41,7 @@ _TORRENT_FILES_RETRY_TIMES = 5
_TORRENT_FILES_RETRY_INTERVAL = 1 _TORRENT_FILES_RETRY_INTERVAL = 1
class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]): class QbittorrentModule(_DownloaderModuleBase[Qbittorrent]):
""" """
qBittorrent 下载器模块负责下载任务添加文件选择和任务管理 qBittorrent 下载器模块负责下载任务添加文件选择和任务管理
""" """
@@ -90,34 +87,12 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
""" """
pass 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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
""" """
返回控制模块启用状态的配置项 返回控制模块启用状态的配置项
""" """
pass 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, 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, 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]]: downloader: Optional[str] = None) -> Optional[Tuple[Optional[str], Optional[str], Optional[str], str]]:
@@ -132,39 +107,11 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
:param downloader: 下载器 :param downloader: 下载器
:return: 下载器名称种子Hash种子文件布局错误原因 :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: if not content:
return None, None, None, "下载内容为空" 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, is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content,
bytes) and content.startswith( bytes) and content.startswith(
@@ -302,7 +249,7 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
else: else:
servers: Dict[str, Qbittorrent] = self.get_instances() servers: Dict[str, Qbittorrent] = self.get_instances()
ret_torrents = [] 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 query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path: def __get_torrent_path(torrent_data: dict) -> Path:
@@ -408,41 +355,6 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return None return None
return ret_torrents # noqa 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 @staticmethod
def __normalize_torrent_state(state: Optional[Union[str, int]]) -> str: def __normalize_torrent_state(state: Optional[Union[str, int]]) -> str:
""" """
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "QbittorrentModule"
kind = "host_module"
entrypoint = "app.modules.qbittorrent:QbittorrentModule"
depends_on = []
[metadata]
name = "Qbittorrent"
type = "downloader"
subtype = "Qbittorrent"
priority = 1
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "qbittorrent"
enabled_field = "enabled"
+12 -39
View File
@@ -15,7 +15,7 @@ from app.application.messaging.agent import (
resolve_config_principal_ids, resolve_config_principal_ids,
) )
from app.runtime.log import logger 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.modules.qqbot.qqbot import QQBot
from app.schemas import IncomingMessage, NotificationChannel, Message from app.schemas import IncomingMessage, NotificationChannel, Message
from app.schemas.types import ModuleType 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 通知模块""" """QQ Bot 通知模块"""
# 管理员配置键,与渠道 resolver 保持一致
_admin_config_key = "QQBOT_ADMINS"
_IMAGE_SUFFIXES = ( _IMAGE_SUFFIXES = (
".png", ".png",
".jpg", ".jpg",
@@ -78,6 +81,13 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
def get_priority() -> int: def get_priority() -> int:
return 10 return 10
def _commands_enabled(self, config: Optional[dict]) -> bool:
"""
QQ 机器人客户端未提供命令注册/删除 API跳过命令注册
避免基类默认钩子调用不存在的 client.register_commands
"""
return False
def stop(self) -> None: def stop(self) -> None:
"""停止模块""" """停止模块"""
for client in self.get_instances().values(): for client in self.get_instances().values():
@@ -86,46 +96,9 @@ class QQBotModule(_ModuleBase, _MessageBase[QQBot]):
except Exception as err: except Exception as err:
logger.error(f"停止QQ Bot模块实例失败:{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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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(
NotificationChannel.QQ,
config,
*user_ids,
)
@staticmethod @staticmethod
def _send_admin_denied( def _send_admin_denied(
client: Optional[QQBot], userid: Optional[Union[str, int]] client: Optional[QQBot], userid: Optional[Union[str, int]]
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "QQBotModule"
kind = "host_module"
entrypoint = "app.modules.qqbot:QQBotModule"
depends_on = []
[metadata]
name = "QQ"
type = "notification"
subtype = "QQ"
priority = 10
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "qqbot"
enabled_field = "enabled"
+15
View File
@@ -0,0 +1,15 @@
schema_version = 1
id = "RedisModule"
kind = "host_module"
entrypoint = "app.modules.redis:RedisModule"
depends_on = []
[metadata]
name = "Redis缓存"
type = "other"
subtype = "Redis"
priority = 0
[activation]
policy = "bootstrap"
watch = []
+4 -92
View File
@@ -1,14 +1,11 @@
from pathlib import Path from pathlib import Path
from typing import Set, Tuple, Optional, Union, List, Dict from typing import Set, Tuple, Optional, Union, List, Dict
from torrentool.torrent import Torrent
from app import schemas from app import schemas
from app.runtime.cache import FileCache
from app.runtime.config import settings from app.runtime.config import settings
from app.domain.metainfo import MetaInfo from app.domain.metainfo import MetaInfo
from app.runtime.log import logger 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.modules.rtorrent.rtorrent import Rtorrent
from app.schemas import DownloaderTorrent from app.schemas import DownloaderTorrent
from app.schemas.types import ( from app.schemas.types import (
@@ -18,13 +15,12 @@ from app.schemas.types import (
TorrentQueryStatus, TorrentQueryStatus,
TorrentStatus, TorrentStatus,
) )
from app.domain import torrent as torrent_rules
from app.foundation import size as size_tools from app.foundation import size as size_tools
from app.foundation import temporal as time_tools from app.foundation import temporal as time_tools
from app.foundation import text as text_tools from app.foundation import text as text_tools
class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]): class RtorrentModule(_DownloaderModuleBase[Rtorrent]):
def init_module(self) -> None: def init_module(self) -> None:
""" """
初始化模块 初始化模块
@@ -61,31 +57,9 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
def stop(self): def stop(self):
pass 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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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( def download(
self, self,
content: Union[Path, str, bytes], content: Union[Path, str, bytes],
@@ -108,38 +82,11 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
:return: 下载器名称种子Hash种子文件布局错误原因 :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: if not content:
return None, None, None, "下载内容为空" return None, None, None, "下载内容为空"
# 读取种子的名称 # 读取种子的名称
torrent_from_file, content = __get_torrent_info() torrent_from_file, content = self._get_torrent_info(content)
# 检查是否为磁力链接 # 检查是否为磁力链接
is_magnet = ( is_magnet = (
isinstance(content, str) isinstance(content, str)
@@ -311,7 +258,7 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
else: else:
servers: Dict[str, Rtorrent] = self.get_instances() servers: Dict[str, Rtorrent] = self.get_instances()
ret_torrents = [] 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 query_tags = None if include_all_tags else settings.TORRENT_TAG
def __get_torrent_path(torrent_data: dict) -> Path: def __get_torrent_path(torrent_data: dict) -> Path:
@@ -424,41 +371,6 @@ class RtorrentModule(_ModuleBase, _DownloaderBase[Rtorrent]):
return None return None
return ret_torrents # noqa 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 @staticmethod
def __normalize_torrent_state( def __normalize_torrent_state(
state: Optional[Union[int, str]], state: Optional[Union[int, str]],
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "RtorrentModule"
kind = "host_module"
entrypoint = "app.modules.rtorrent:RtorrentModule"
depends_on = []
[metadata]
name = "Rtorrent"
type = "downloader"
subtype = "Rtorrent"
priority = 3
[activation]
policy = "when_configured"
watch = ["Downloaders"]
[activation.selector]
kind = "system_config_item"
key = "Downloaders"
match_field = "type"
match_value = "rtorrent"
enabled_field = "enabled"
+5 -96
View File
@@ -1,18 +1,16 @@
import copy
import json import json
import re import re
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
from app.domain.context import MediaInfo, Context from app.domain.context import MediaInfo, Context
from app.runtime.events import eventmanager
from app.application.messaging.agent import ( from app.application.messaging.agent import (
matches_channel_admin, matches_channel_admin,
register_channel_admin_resolver, register_channel_admin_resolver,
resolve_config_principal_ids, resolve_config_principal_ids,
) )
from app.runtime.log import logger 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.modules.slack.slack import Slack
from app.schemas import ( from app.schemas import (
CommandRegisterEventData, CommandRegisterEventData,
@@ -21,8 +19,7 @@ from app.schemas import (
MessageResponse, MessageResponse,
Message, Message,
) )
from app.schemas.types import ChainEventType, ModuleType from app.schemas.types import ModuleType
from app.foundation.collections import DictUtils
register_channel_admin_resolver( register_channel_admin_resolver(
@@ -31,7 +28,9 @@ register_channel_admin_resolver(
) )
class SlackModule(_ModuleBase, _MessageBase[Slack]): class SlackModule(_MessageChannelModuleBase[Slack]):
# 管理员配置键,与渠道 resolver 保持一致
_admin_config_key = "SLACK_ADMINS"
PROCESSING_REACTION = "eyes" PROCESSING_REACTION = "eyes"
_AUDIO_SUFFIXES = ( _AUDIO_SUFFIXES = (
".mp3", ".mp3",
@@ -88,51 +87,9 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
except Exception as err: except Exception as err:
logger.error(f"停止Slack模块实例失败:{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]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass 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 @staticmethod
def _send_admin_denied(client: Optional[Slack], userid: Optional[Union[str, int]]) -> None: def _send_admin_denied(client: Optional[Slack], userid: Optional[Union[str, int]]) -> None:
""" """
@@ -688,54 +645,6 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
return True return True
return False 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( def mark_message_processing_started(
self, self,
channel: NotificationChannel, channel: NotificationChannel,
+22
View File
@@ -0,0 +1,22 @@
schema_version = 1
id = "SlackModule"
kind = "host_module"
entrypoint = "app.modules.slack:SlackModule"
depends_on = []
[metadata]
name = "Slack"
type = "notification"
subtype = "Slack"
priority = 3
[activation]
policy = "when_configured"
watch = ["Notifications"]
[activation.selector]
kind = "system_config_item"
key = "Notifications"
match_field = "type"
match_value = "slack"
enabled_field = "enabled"

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