mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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*
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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("虚拟显示已停止")
|
|
||||||
@@ -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 = []
|
||||||
@@ -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("虚拟显示已停止")
|
||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 "当前没有可用的命令"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}。"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)}")
|
|
||||||
# 文件夹处理失败不影响插件卸载的整体流程
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Agent 编排服务门面。
|
||||||
|
|
||||||
|
chain 层需要触发 Agent 后台任务、渲染提示词、查询模型能力时统一经本模块调用。
|
||||||
|
具体实现由 startup 组合根注册,形成依赖倒置:
|
||||||
|
|
||||||
|
chain -> application.agent <- startup -> agent
|
||||||
|
|
||||||
|
门面保存 provider 而非重量级实现对象,注册本身不会物化 Agent、LLM 或工具树。
|
||||||
|
本模块禁止静态或函数内导入 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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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] = {
|
||||||
@@ -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
@@ -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,
|
||||||
|
# 不能复用 MediaInfo(year 为 str),否则触发 pydantic 校验异常
|
||||||
|
return schemas.MusicInfo(
|
||||||
|
title=meta.name,
|
||||||
|
artists=list(meta.artists or []),
|
||||||
|
artist=meta.artist,
|
||||||
|
album=meta.album,
|
||||||
|
album_artist=meta.album_artist,
|
||||||
|
year=meta.year,
|
||||||
|
title_year=f"{meta.name} ({meta.year})" if meta.year else meta.name,
|
||||||
|
media_source=meta.media_source,
|
||||||
|
media_id=meta.media_id,
|
||||||
|
)
|
||||||
|
return schemas.MediaInfo(
|
||||||
|
title=meta.name,
|
||||||
|
year=meta.year,
|
||||||
|
title_year=f"{meta.name} ({meta.year})",
|
||||||
|
type=meta.type.value if meta.type else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_meta(task: TransferTask) -> schemas.MetaInfo:
|
||||||
|
"""
|
||||||
|
获取元数据
|
||||||
|
"""
|
||||||
|
if isinstance(task.meta, MetaMusic):
|
||||||
|
return schemas.MusicMeta(**task.meta.to_dict())
|
||||||
|
return schemas.MetaInfo(**task.meta.to_dict())
|
||||||
|
|
||||||
|
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
|
||||||
|
"""
|
||||||
|
添加整理任务,自动分组到对应的作业中
|
||||||
|
:return: True表示任务已添加,False表示任务无效或已存在(重复)
|
||||||
|
"""
|
||||||
|
if not all([task, task.meta, task.fileitem]):
|
||||||
|
return False
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if not file_key:
|
||||||
|
return False
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_id(task)
|
||||||
|
# 同一个源文件可能在识别前后落入不同作业,必须跨作业去重。
|
||||||
|
if any(
|
||||||
|
self.__get_file_key(t.fileitem) == file_key
|
||||||
|
for job in self._job_view.values()
|
||||||
|
for t in job.tasks
|
||||||
|
):
|
||||||
|
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
|
||||||
|
return False
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
self._job_view[__mediaid__] = TransferJob(
|
||||||
|
media=self.__get_media(task),
|
||||||
|
season=task.meta.begin_season,
|
||||||
|
tasks=[
|
||||||
|
TransferJobTask(
|
||||||
|
fileitem=task.fileitem,
|
||||||
|
meta=self.__get_meta(task),
|
||||||
|
downloader=task.downloader,
|
||||||
|
download_hash=task.download_hash,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 不重复添加任务
|
||||||
|
if any(
|
||||||
|
[
|
||||||
|
self.__get_file_key(t.fileitem) == file_key
|
||||||
|
for t in self._job_view[__mediaid__].tasks
|
||||||
|
]
|
||||||
|
):
|
||||||
|
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
|
||||||
|
return False
|
||||||
|
self._job_view[__mediaid__].tasks.append(
|
||||||
|
TransferJobTask(
|
||||||
|
fileitem=task.fileitem,
|
||||||
|
meta=self.__get_meta(task),
|
||||||
|
downloader=task.downloader,
|
||||||
|
download_hash=task.download_hash,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._task_state_changed_at[file_key] = monotonic()
|
||||||
|
# 添加季集信息
|
||||||
|
if self._season_episodes.get(__mediaid__):
|
||||||
|
self._season_episodes[__mediaid__].extend(task.meta.episode_list)
|
||||||
|
self._season_episodes[__mediaid__] = list(
|
||||||
|
set(self._season_episodes[__mediaid__])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._season_episodes[__mediaid__] = task.meta.episode_list
|
||||||
|
return True
|
||||||
|
|
||||||
|
def migrate_task(self, task: TransferTask) -> bool:
|
||||||
|
"""
|
||||||
|
将任务从 meta 作业迁移到 media 作业
|
||||||
|
"""
|
||||||
|
curr_task, source_job_id = self.__remove_task_with_job_id(
|
||||||
|
task.fileitem, preserve_execution=True
|
||||||
|
)
|
||||||
|
if not self.add_task(task, state=curr_task.state if curr_task else "waiting"):
|
||||||
|
return False
|
||||||
|
if curr_task and task.mediainfo:
|
||||||
|
metaid = self.__get_meta_id(
|
||||||
|
meta=task.meta, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
mediaid = self.__get_id(task)
|
||||||
|
if source_job_id == metaid and mediaid != metaid:
|
||||||
|
with job_lock:
|
||||||
|
self._meta_to_media_ids.setdefault(metaid, set()).add(mediaid)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __is_job_done(self, job_id: Tuple) -> bool:
|
||||||
|
"""
|
||||||
|
检查指定作业是否已完成
|
||||||
|
"""
|
||||||
|
if job_id not in self._job_view:
|
||||||
|
return True
|
||||||
|
return all(
|
||||||
|
task.state in ["completed", "failed"]
|
||||||
|
for task in self._job_view[job_id].tasks
|
||||||
|
)
|
||||||
|
|
||||||
|
def __pop_job(self, job_id: Tuple):
|
||||||
|
"""
|
||||||
|
移除指定作业和对应季集缓存
|
||||||
|
"""
|
||||||
|
job = self._job_view.pop(job_id, None)
|
||||||
|
self._season_episodes.pop(job_id, None)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
for task in job.tasks:
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if file_key:
|
||||||
|
self._task_state_changed_at.pop(file_key, None)
|
||||||
|
self._active_executions.discard(file_key)
|
||||||
|
|
||||||
|
def __remove_done_job_groups(self, job_ids: set[Tuple]):
|
||||||
|
"""
|
||||||
|
清理已进入终态的独立作业或关联作业组。
|
||||||
|
"""
|
||||||
|
candidates = set(job_ids)
|
||||||
|
for metaid, mediaids in list(self._meta_to_media_ids.items()):
|
||||||
|
related_ids = {metaid, *mediaids}
|
||||||
|
if not related_ids.intersection(candidates):
|
||||||
|
continue
|
||||||
|
if all(self.__is_job_done(job_id) for job_id in related_ids):
|
||||||
|
for job_id in related_ids:
|
||||||
|
self.__pop_job(job_id)
|
||||||
|
self._meta_to_media_ids.pop(metaid, None)
|
||||||
|
candidates.difference_update(related_ids)
|
||||||
|
|
||||||
|
referenced_ids = {
|
||||||
|
job_id
|
||||||
|
for metaid, mediaids in self._meta_to_media_ids.items()
|
||||||
|
for job_id in {metaid, *mediaids}
|
||||||
|
}
|
||||||
|
for job_id in candidates - referenced_ids:
|
||||||
|
if self.__is_job_done(job_id):
|
||||||
|
self.__pop_job(job_id)
|
||||||
|
|
||||||
|
def start_execution(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
标记任务仍由主程序整理线程直接执行。
|
||||||
|
|
||||||
|
:param task: 整理任务
|
||||||
|
"""
|
||||||
|
if not task or not task.fileitem:
|
||||||
|
return
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if not file_key:
|
||||||
|
return
|
||||||
|
with job_lock:
|
||||||
|
self._active_executions.add(file_key)
|
||||||
|
|
||||||
|
def finish_execution(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
结束主程序整理线程对任务的直接执行标记。
|
||||||
|
|
||||||
|
:param task: 整理任务
|
||||||
|
"""
|
||||||
|
if not task or not task.fileitem:
|
||||||
|
return
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if not file_key:
|
||||||
|
return
|
||||||
|
with job_lock:
|
||||||
|
self._active_executions.discard(file_key)
|
||||||
|
|
||||||
|
def expire_stale_running_tasks(
|
||||||
|
self, timeout_seconds: int
|
||||||
|
) -> List[Tuple[FileItem, int]]:
|
||||||
|
"""
|
||||||
|
将外部接管后长期无心跳的运行中任务标记失败并清理作业视图。
|
||||||
|
|
||||||
|
主程序整理线程仍在直接执行的任务不会被清理,以免把阻塞中的真实任务
|
||||||
|
误报为已终止。外部接管方可重复调用 ``running_task`` 刷新状态心跳。
|
||||||
|
|
||||||
|
:param timeout_seconds: 失活超时秒数,小于等于 0 时禁用
|
||||||
|
:return: 已失活任务及其无心跳秒数
|
||||||
|
"""
|
||||||
|
if timeout_seconds <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
current_time = monotonic()
|
||||||
|
expired: List[Tuple[FileItem, int]] = []
|
||||||
|
affected_job_ids: set[Tuple] = set()
|
||||||
|
with job_lock:
|
||||||
|
for mediaid, job in self._job_view.items():
|
||||||
|
for task in job.tasks:
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if (
|
||||||
|
not file_key
|
||||||
|
or task.state != "running"
|
||||||
|
or file_key in self._active_executions
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
updated_at = self._task_state_changed_at.get(file_key, current_time)
|
||||||
|
inactive_seconds = current_time - updated_at
|
||||||
|
if inactive_seconds < timeout_seconds:
|
||||||
|
continue
|
||||||
|
task.state = "failed"
|
||||||
|
self._task_state_changed_at[file_key] = current_time
|
||||||
|
episodes = getattr(task.meta, "episode_list", None) or []
|
||||||
|
if mediaid in self._season_episodes:
|
||||||
|
self._season_episodes[mediaid] = list(
|
||||||
|
set(self._season_episodes[mediaid]) - set(episodes)
|
||||||
|
)
|
||||||
|
expired.append((task.fileitem, int(inactive_seconds)))
|
||||||
|
affected_job_ids.add(mediaid)
|
||||||
|
|
||||||
|
self.__remove_done_job_groups(affected_job_ids)
|
||||||
|
return expired
|
||||||
|
|
||||||
|
def running_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
设置任务为运行中,并刷新外部异步任务的状态心跳。
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_id(task)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return
|
||||||
|
# 更新状态
|
||||||
|
for t in self._job_view[__mediaid__].tasks:
|
||||||
|
if t.fileitem == task.fileitem:
|
||||||
|
t.state = "running"
|
||||||
|
file_key = self.__get_file_key(t.fileitem)
|
||||||
|
if file_key:
|
||||||
|
self._task_state_changed_at[file_key] = monotonic()
|
||||||
|
break
|
||||||
|
|
||||||
|
def finish_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
设置任务为完成/成功
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_id(task)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return
|
||||||
|
# 更新状态
|
||||||
|
for t in self._job_view[__mediaid__].tasks:
|
||||||
|
if t.fileitem == task.fileitem:
|
||||||
|
t.state = "completed"
|
||||||
|
file_key = self.__get_file_key(t.fileitem)
|
||||||
|
if file_key:
|
||||||
|
self._task_state_changed_at[file_key] = monotonic()
|
||||||
|
break
|
||||||
|
|
||||||
|
def fail_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
设置任务为失败
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_id(task)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return
|
||||||
|
# 更新状态
|
||||||
|
for t in self._job_view[__mediaid__].tasks:
|
||||||
|
if t.fileitem == task.fileitem:
|
||||||
|
t.state = "failed"
|
||||||
|
file_key = self.__get_file_key(t.fileitem)
|
||||||
|
if file_key:
|
||||||
|
self._task_state_changed_at[file_key] = monotonic()
|
||||||
|
break
|
||||||
|
# 移除剧集信息
|
||||||
|
if __mediaid__ in self._season_episodes:
|
||||||
|
self._season_episodes[__mediaid__] = list(
|
||||||
|
set(self._season_episodes[__mediaid__])
|
||||||
|
- set(task.meta.episode_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_unfinished_task(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
将指定任务视图中的非终态任务标记为失败
|
||||||
|
"""
|
||||||
|
if not task or not task.fileitem:
|
||||||
|
return
|
||||||
|
file_key = self.__get_file_key(task.fileitem)
|
||||||
|
if not file_key:
|
||||||
|
return
|
||||||
|
with job_lock:
|
||||||
|
for mediaid, job in self._job_view.items():
|
||||||
|
for job_task in job.tasks:
|
||||||
|
if self.__get_file_key(job_task.fileitem) != file_key:
|
||||||
|
continue
|
||||||
|
if job_task.state not in ["completed", "failed"]:
|
||||||
|
job_task.state = "failed"
|
||||||
|
self._task_state_changed_at[file_key] = monotonic()
|
||||||
|
if mediaid in self._season_episodes:
|
||||||
|
self._season_episodes[mediaid] = list(
|
||||||
|
set(self._season_episodes[mediaid])
|
||||||
|
- set(task.meta.episode_list)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def remove_task(self, fileitem: FileItem) -> Optional[TransferJobTask]:
|
||||||
|
"""
|
||||||
|
根据文件项移除任务
|
||||||
|
"""
|
||||||
|
task, _ = self.__remove_task_with_job_id(fileitem)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def __remove_task_with_job_id(
|
||||||
|
self,
|
||||||
|
fileitem: FileItem,
|
||||||
|
preserve_execution: bool = False,
|
||||||
|
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
|
||||||
|
"""
|
||||||
|
根据文件项移除任务,并返回任务所在的作业ID
|
||||||
|
"""
|
||||||
|
file_key = self.__get_file_key(fileitem)
|
||||||
|
if not file_key:
|
||||||
|
return None, None
|
||||||
|
with job_lock:
|
||||||
|
for mediaid in list(self._job_view):
|
||||||
|
job = self._job_view[mediaid]
|
||||||
|
for task in job.tasks:
|
||||||
|
if self.__get_file_key(task.fileitem) == file_key:
|
||||||
|
job.tasks.remove(task)
|
||||||
|
self._task_state_changed_at.pop(file_key, None)
|
||||||
|
if not preserve_execution:
|
||||||
|
self._active_executions.discard(file_key)
|
||||||
|
# 如果没有作业了,则移除作业
|
||||||
|
if not job.tasks:
|
||||||
|
self._job_view.pop(mediaid)
|
||||||
|
# 移除季集信息
|
||||||
|
if mediaid in self._season_episodes:
|
||||||
|
episodes = getattr(task.meta, "episode_list", None) or []
|
||||||
|
self._season_episodes[mediaid] = list(
|
||||||
|
set(self._season_episodes[mediaid])
|
||||||
|
- set(episodes)
|
||||||
|
)
|
||||||
|
return task, mediaid
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def remove_job(self, task: TransferTask) -> Optional[TransferJob]:
|
||||||
|
"""
|
||||||
|
移除任务对应的作业(强制,线程不安全)
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_id(task)
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
job = self._job_view[__mediaid__]
|
||||||
|
self.__pop_job(__mediaid__)
|
||||||
|
return job
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_remove_job(self, task: TransferTask):
|
||||||
|
"""
|
||||||
|
尝试移除任务对应的作业(严格检查未完成作业,线程安全)
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__metaid__ = self.__get_meta_id(
|
||||||
|
meta=task.meta, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
__mediaid__ = self.__get_media_id(
|
||||||
|
media=task.mediainfo, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
|
||||||
|
related_media_ids = set(self._meta_to_media_ids.get(__metaid__, set()))
|
||||||
|
if task.mediainfo:
|
||||||
|
related_media_ids.add(__mediaid__)
|
||||||
|
|
||||||
|
meta_done = self.__is_job_done(__metaid__)
|
||||||
|
media_done = all(
|
||||||
|
self.__is_job_done(mediaid) for mediaid in related_media_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
if meta_done and media_done:
|
||||||
|
remove_ids = {__metaid__, self.__get_id(task), *related_media_ids}
|
||||||
|
for job_id in remove_ids:
|
||||||
|
self.__pop_job(job_id)
|
||||||
|
self._meta_to_media_ids.pop(__metaid__, None)
|
||||||
|
|
||||||
|
def is_done(self, task: TransferTask) -> bool:
|
||||||
|
"""
|
||||||
|
检查任务对应的作业是否整理完成(不管成功还是失败)
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__metaid__ = self.__get_meta_id(
|
||||||
|
meta=task.meta, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
__mediaid__ = self.__get_media_id(
|
||||||
|
media=task.mediainfo, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
if __metaid__ in self._job_view:
|
||||||
|
meta_done = all(
|
||||||
|
task.state in ["completed", "failed"]
|
||||||
|
for task in self._job_view[__metaid__].tasks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
meta_done = True
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
media_done = all(
|
||||||
|
task.state in ["completed", "failed"]
|
||||||
|
for task in self._job_view[__mediaid__].tasks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
media_done = True
|
||||||
|
return meta_done and media_done
|
||||||
|
|
||||||
|
def is_finished(self, task: TransferTask) -> bool:
|
||||||
|
"""
|
||||||
|
检查任务对应的作业是否已完成且有成功的记录
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__metaid__ = self.__get_meta_id(
|
||||||
|
meta=task.meta, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
__mediaid__ = self.__get_media_id(
|
||||||
|
media=task.mediainfo, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
if __metaid__ in self._job_view:
|
||||||
|
meta_finished = all(
|
||||||
|
task.state in ["completed", "failed"]
|
||||||
|
for task in self._job_view[__metaid__].tasks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
meta_finished = True
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
tasks = self._job_view[__mediaid__].tasks
|
||||||
|
media_finished = all(
|
||||||
|
task.state in ["completed", "failed"] for task in tasks
|
||||||
|
) and any(task.state == "completed" for task in tasks)
|
||||||
|
else:
|
||||||
|
media_finished = True
|
||||||
|
return meta_finished and media_finished
|
||||||
|
|
||||||
|
def is_success(self, task: TransferTask) -> bool:
|
||||||
|
"""
|
||||||
|
检查任务对应的作业是否全部成功
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__metaid__ = self.__get_meta_id(
|
||||||
|
meta=task.meta, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
__mediaid__ = self.__get_media_id(
|
||||||
|
media=task.mediainfo, season=task.meta.begin_season
|
||||||
|
)
|
||||||
|
if __metaid__ in self._job_view:
|
||||||
|
meta_success = all(
|
||||||
|
task.state in ["completed"]
|
||||||
|
for task in self._job_view[__metaid__].tasks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
meta_success = True
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
media_success = all(
|
||||||
|
task.state in ["completed"]
|
||||||
|
for task in self._job_view[__mediaid__].tasks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
media_success = True
|
||||||
|
return meta_success and media_success
|
||||||
|
|
||||||
|
def get_all_torrent_hashes(self) -> set[str]:
|
||||||
|
"""
|
||||||
|
获取所有种子的哈希值集合
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
return {
|
||||||
|
task.download_hash
|
||||||
|
for job in self._job_view.values()
|
||||||
|
for task in job.tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_torrent_done(self, download_hash: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查指定种子的所有任务是否都已完成
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
if any(
|
||||||
|
task.state not in {"completed", "failed"}
|
||||||
|
for job in self._job_view.values()
|
||||||
|
for task in job.tasks
|
||||||
|
if task.download_hash == download_hash
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_torrent_success(self, download_hash: str) -> bool:
|
||||||
|
"""
|
||||||
|
检查指定种子的所有任务是否都已成功
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
if any(
|
||||||
|
task.state != "completed"
|
||||||
|
for job in self._job_view.values()
|
||||||
|
for task in job.tasks
|
||||||
|
if task.download_hash == download_hash
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_tasks(
|
||||||
|
self,
|
||||||
|
meta: MetaBase,
|
||||||
|
mediainfo: Optional[MediaInfo] = None,
|
||||||
|
season: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
判断作业是否还有任务正在处理
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
if mediainfo:
|
||||||
|
__mediaid__ = self.__get_media_id(media=mediainfo, season=season)
|
||||||
|
if __mediaid__ in self._job_view:
|
||||||
|
return True
|
||||||
|
|
||||||
|
__metaid__ = self.__get_meta_id(meta=meta, season=season)
|
||||||
|
return (
|
||||||
|
__metaid__ in self._job_view
|
||||||
|
and len(self._job_view[__metaid__].tasks) > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def success_tasks(
|
||||||
|
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
|
||||||
|
) -> List[TransferJobTask]:
|
||||||
|
"""
|
||||||
|
获取作业中所有成功的任务
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
task
|
||||||
|
for task in self._job_view[__mediaid__].tasks
|
||||||
|
if task.state == "completed"
|
||||||
|
]
|
||||||
|
|
||||||
|
def all_tasks(
|
||||||
|
self, media: MediaInfo, season: Optional[int] = None
|
||||||
|
) -> List[TransferJobTask]:
|
||||||
|
"""
|
||||||
|
获取作业中全部任务
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return []
|
||||||
|
return self._job_view[__mediaid__].tasks
|
||||||
|
|
||||||
|
def count(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
|
||||||
|
"""
|
||||||
|
获取作业中成功总数
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return 0
|
||||||
|
return len(
|
||||||
|
[
|
||||||
|
task
|
||||||
|
for task in self._job_view[__mediaid__].tasks
|
||||||
|
if task.state == "completed"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def size(self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None) -> int:
|
||||||
|
"""
|
||||||
|
获取作业中所有成功文件总大小
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
if __mediaid__ not in self._job_view:
|
||||||
|
return 0
|
||||||
|
return sum(
|
||||||
|
[
|
||||||
|
task.fileitem.size
|
||||||
|
if task.fileitem.size is not None
|
||||||
|
else (
|
||||||
|
SystemUtils.get_directory_size(Path(task.fileitem.path))
|
||||||
|
if task.fileitem.storage == "local"
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
for task in self._job_view[__mediaid__].tasks
|
||||||
|
if task.state == "completed"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def total(self) -> int:
|
||||||
|
"""
|
||||||
|
获取所有任务总数
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
return sum([len(job.tasks) for job in self._job_view.values()])
|
||||||
|
|
||||||
|
def pending_total(self) -> int:
|
||||||
|
"""
|
||||||
|
获取未到终态的任务总数。
|
||||||
|
|
||||||
|
作业要等关联任务全部终态才整体移除,追更/分批场景下已完成任务会
|
||||||
|
跨批次残留在视图中;批次统计若用全量 total() 会把历史任务计入
|
||||||
|
「当前共 N 个文件」并压低进度百分比,因此只数未终态任务。
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for job in self._job_view.values()
|
||||||
|
for task in job.tasks
|
||||||
|
if task.state not in ("completed", "failed")
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_jobs(self) -> List[TransferJob]:
|
||||||
|
"""
|
||||||
|
获取所有作业的任务列表
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
return list(self._job_view.values())
|
||||||
|
|
||||||
|
def season_episodes(
|
||||||
|
self, media: Union[MediaInfo, MusicInfo], season: Optional[int] = None
|
||||||
|
) -> List[int]:
|
||||||
|
"""
|
||||||
|
获取作业的季集清单
|
||||||
|
"""
|
||||||
|
with job_lock:
|
||||||
|
__mediaid__ = self.__get_media_id(media=media, season=season)
|
||||||
|
return self._season_episodes.get(__mediaid__) or []
|
||||||
|
|
||||||
|
|
||||||
|
class FailedRetryScheduler:
|
||||||
|
"""
|
||||||
|
负责失败整理记录的 debounce 聚合与 AI 重试调度。
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._retry_transfer_buffer: dict[str, list[int]] = {}
|
||||||
|
self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {}
|
||||||
|
self._retry_transfer_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
timers = list(self._retry_transfer_timers.values())
|
||||||
|
self._retry_transfer_timers.clear()
|
||||||
|
self._retry_transfer_buffer.clear()
|
||||||
|
|
||||||
|
for timer in timers:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_retry_transfer_template_context(
|
||||||
|
history_ids: list[int],
|
||||||
|
) -> tuple[str, dict[str, int | str]]:
|
||||||
|
"""仅负责把失败重试任务的动态数据映射成模板变量。"""
|
||||||
|
is_batch = len(history_ids) > 1
|
||||||
|
task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry"
|
||||||
|
template_context: dict[str, int | str] = {
|
||||||
|
"history_ids_csv": ", ".join(str(item) for item in history_ids),
|
||||||
|
"history_count": len(history_ids),
|
||||||
|
}
|
||||||
|
if not is_batch:
|
||||||
|
template_context["history_id"] = history_ids[0]
|
||||||
|
return task_type, template_context
|
||||||
|
|
||||||
|
def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str:
|
||||||
|
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
|
||||||
|
task_type, template_context = self._build_retry_transfer_template_context(history_ids)
|
||||||
|
return get_prompt_manager().render_system_task_message(
|
||||||
|
task_type,
|
||||||
|
template_context=template_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def schedule_retry(self, history_id: int, group_key: str = ""):
|
||||||
|
"""
|
||||||
|
同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。
|
||||||
|
"""
|
||||||
|
if not group_key:
|
||||||
|
group_key = f"_default_{history_id}"
|
||||||
|
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
if group_key not in self._retry_transfer_buffer:
|
||||||
|
self._retry_transfer_buffer[group_key] = []
|
||||||
|
if history_id not in self._retry_transfer_buffer[group_key]:
|
||||||
|
self._retry_transfer_buffer[group_key].append(history_id)
|
||||||
|
logger.info(
|
||||||
|
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
|
||||||
|
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_key in self._retry_transfer_timers:
|
||||||
|
self._retry_transfer_timers[group_key].cancel()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._retry_transfer_timers[group_key] = loop.call_later(
|
||||||
|
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
|
||||||
|
lambda gk=group_key: asyncio.create_task(self._flush_retry_transfer(gk)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _flush_retry_transfer(self, group_key: str):
|
||||||
|
"""
|
||||||
|
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。
|
||||||
|
"""
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
history_ids = self._retry_transfer_buffer.pop(group_key, [])
|
||||||
|
self._retry_transfer_timers.pop(group_key, None)
|
||||||
|
|
||||||
|
if not history_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
ids_str = ", ".join(str(item) for item in history_ids)
|
||||||
|
logger.info(
|
||||||
|
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
File diff suppressed because it is too large
Load Diff
@@ -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。
|
||||||
|
|
||||||
|
subscribe、site 等业务链的交互入口完全同构,唯一差异是各自的
|
||||||
|
交互处理器构造参数。本 mixin 将四件套委托提取为公共实现,
|
||||||
|
子类只需注入处理器类并实现 _interaction_handler 构造器。
|
||||||
|
|
||||||
|
子类注入约定:
|
||||||
|
- `_interaction_handler_type`:交互处理器类,提供静态 parse_callback;
|
||||||
|
- `_interaction_handler()`:按各链业务动作构造处理器实例。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 交互处理器类,子类注入(如 SubscribeInteractionHandler / SiteInteractionHandler)
|
||||||
|
_interaction_handler_type: type = None
|
||||||
|
|
||||||
|
def _interaction_handler(self):
|
||||||
|
"""
|
||||||
|
构造交互处理器实例,由子类按各自业务动作注入实现。
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def remote_list(
|
||||||
|
self,
|
||||||
|
arg_str: str = "",
|
||||||
|
channel: 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""消息处理与通知发送 mixin。
|
||||||
|
|
||||||
|
从 ChainBase 拆出的消息域:渠道输入状态机、通知派发规范化、消息渲染、
|
||||||
|
隔离路由与队列发送。方法经 MRO 解析,依赖 ChainBase 实例的 run_module、
|
||||||
|
eventmanager、messageoper、messagequeue 等协作对象。
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from app.db.oper.user import UserOper
|
||||||
|
from app.domain.context import Context, MediaInfo, MusicInfo, TorrentInfo
|
||||||
|
from app.domain.meta.metabase import MetaBase
|
||||||
|
from app.foundation.identity import normalize_internal_user_id
|
||||||
|
from app.application.messaging.message import MessageTemplateHelper
|
||||||
|
from app.runtime.config import settings
|
||||||
|
from app.runtime.extensions.service_registry import ServiceConfigHelper
|
||||||
|
from app.runtime.log import logger
|
||||||
|
from app.schemas import MessageResponse, 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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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__()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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]]:
|
||||||
"""
|
"""
|
||||||
媒体数量统计
|
媒体数量统计
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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 = []
|
||||||
@@ -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]]:
|
||||||
"""
|
"""
|
||||||
媒体数量统计
|
媒体数量统计
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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 = []
|
||||||
@@ -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 = []
|
||||||
@@ -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"
|
||||||
@@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 = []
|
||||||
@@ -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]],
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user