mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-03 14:16:03 +08:00
feat(4.0-P2): 下载器/通知/LLM/搜索/定时任务扩展点与插件管理界面
- ab_sdk 0.2:新增 points、downloader(自 module/downloader/base.py 迁入)、 llm(自 providers/base.py 迁入)、notify、search、tasks 契约与 secret_field();原路径保留重新导出 - module/plugin/host:进程级扩展注册表,内置 qB/aria2/mock 与各通知渠道以 core 身份登记;DownloadClient、NotificationManager、LLM 注册表、搜索站点 均改为按 id 从注册表解析 - 插件定时任务随插件加载/重载/停用同步进调度器,失败计入熔断 - 插件变更后重建通知渠道、清理 LLM 解析器缓存;下载器客户端缓存键包含 Provider 登记项,插件重载后自动换新 - /api/v1/plugins:总览(schema + 掩码配置)、启停、配置(按模型校验)、 allow_unsigned、Provider 列表;/config 读写同样掩码/还原插件秘密字段 - WebUI:设置页「插件」卡片与 JSON Schema 表单;下载器与通知下拉框并入插件 Provider;插件保存后刷新 config store 的 plugins 段,避免全局保存覆盖 - 修复:/search/provider 读取导入时的站点快照,新站点需重启才出现 - 文档:开发者指南 → 插件开发 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoBUPFw7Ty1epuPESdELs1
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
4.0 是插件化重构的大版本(设计见 `docs/plans/2026-10-08-plugin-architecture-4.0-design.md`)。本节随各阶段合入持续更新。
|
||||
|
||||
- **新增(插件运行时)**:新增插件 SDK `ab_sdk`,包括 `Plugin` 基类、`@hook` / `@provider` / `@subscribe`、`PluginContext`,以及供插件作者写单元测试的 `ab_sdk.testing`;新增宿主运行时 `module/plugin`,负责清单解析、内置 / 本地目录 / pip entry point 三种来源的发现与加载、扩展点注册表、钩子超时与熔断、事件总线和插件私有 KV 存储(数据库迁移 v25)。新增 `plugins` 配置段和 `GET /api/v1/plugins`。本地与 pip 插件未签名,需开启 `plugins.allow_unsigned` 才会加载
|
||||
- **新增(插件扩展点)**:插件可以提供下载器、通知渠道、LLM 解析提供商、搜索站点和定时任务(`ab_sdk.points`)。内置的 qB / aria2 和各通知渠道走同一套扩展注册表。设置页新增「插件」卡片,可查看状态、启停插件,并通过插件配置模型自动生成的表单修改配置;`secret_field` 声明的秘密字段在读取接口中一律掩码。下载器类型和通知渠道下拉框会列出插件提供的选项。新增插件开发文档(开发者指南 → 插件开发)
|
||||
- **修复(搜索)**:`GET /api/v1/search/provider` 读取的是导入时的站点快照,新保存的搜索站点要重启后才会出现在列表中;现在实时读取
|
||||
- **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移
|
||||
- **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST
|
||||
- **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none`
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
允许不兼容调整,4.1 冻结 1.0)。
|
||||
"""
|
||||
|
||||
from . import points
|
||||
from .config import secret_field
|
||||
from .context import KeyValueStore, PluginContext
|
||||
from .events import Event, PluginDisabled, PluginLoaded
|
||||
from .hooks import Verdict, hook, provider, subscribe
|
||||
from .plugin import Plugin
|
||||
|
||||
SDK_VERSION = "0.1.0"
|
||||
SDK_VERSION = "0.2.0"
|
||||
|
||||
__all__ = [
|
||||
"SDK_VERSION",
|
||||
@@ -22,6 +24,8 @@ __all__ = [
|
||||
"PluginLoaded",
|
||||
"Verdict",
|
||||
"hook",
|
||||
"points",
|
||||
"provider",
|
||||
"secret_field",
|
||||
"subscribe",
|
||||
]
|
||||
|
||||
16
backend/src/ab_sdk/config.py
Normal file
16
backend/src/ab_sdk/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""插件配置模型的辅助函数。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
def secret_field(default: str = "", *, description: str = "", **kwargs: Any) -> Any:
|
||||
"""声明秘密字段(密码、令牌、Cookie 等)。
|
||||
|
||||
WebUI 以密码框渲染;读取配置的接口只返回掩码,保存时收到掩码会保留原值。
|
||||
用法:``cookie: str = secret_field(description="站点 Cookie")``。
|
||||
"""
|
||||
return Field(
|
||||
default, description=description, json_schema_extra={"secret": True}, **kwargs
|
||||
)
|
||||
149
backend/src/ab_sdk/downloader.py
Normal file
149
backend/src/ab_sdk/downloader.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""下载器契约:宿主的下载门面(DownloadClient)通过它驱动具体下载器。
|
||||
|
||||
第三方下载器插件用 ``@provider(points.DOWNLOADER, id="...")`` 返回一个
|
||||
:data:`DownloaderFactory`:接收连接参数,返回实现 :class:`DownloaderClient`
|
||||
(至少 :class:`CoreDownloaderClient`)的对象。不同下载器能力不同,通过
|
||||
``capabilities`` 类属性声明,门面会跳过不支持的操作而不是报错。
|
||||
用户在设置里把 ``downloader.type`` 设为该 id 即可启用。
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import ClassVar, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class AddResult(Enum):
|
||||
"""Torrent-add result shared by concrete clients and the facade."""
|
||||
|
||||
ADDED = "added"
|
||||
DUPLICATE = "duplicate"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RenameOutcome(str, Enum):
|
||||
"""Downloader-independent outcome of a single file rename."""
|
||||
|
||||
RENAMED = "renamed"
|
||||
ALREADY_APPLIED = "already_applied"
|
||||
DESTINATION_EXISTS = "destination_exists"
|
||||
RETRYABLE_FAILURE = "retryable_failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenameResult:
|
||||
"""Structured rename result preserved across the downloader facade.
|
||||
|
||||
``bool(result)`` deliberately keeps the old success/failure behaviour while
|
||||
callers migrate to inspecting :attr:`outcome`. It must never make a
|
||||
collision or retryable failure truthy.
|
||||
"""
|
||||
|
||||
outcome: RenameOutcome
|
||||
detail: str | None = None
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.outcome in {
|
||||
RenameOutcome.RENAMED,
|
||||
RenameOutcome.ALREADY_APPLIED,
|
||||
}
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.succeeded
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloaderCapabilities:
|
||||
"""What a concrete download client can do.
|
||||
|
||||
can_query -- torrents_info / torrents_files
|
||||
can_rename -- torrents_rename_file
|
||||
can_manage -- delete / pause / resume / move / category / tags
|
||||
can_rss_rules -- qB-native RSS feeds + auto-download rules + prefs
|
||||
"""
|
||||
|
||||
can_query: bool
|
||||
can_rename: bool
|
||||
can_manage: bool
|
||||
can_rss_rules: bool
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CoreDownloaderClient(Protocol):
|
||||
"""The minimum every backend must implement: authenticate and add torrents."""
|
||||
|
||||
capabilities: ClassVar[DownloaderCapabilities]
|
||||
|
||||
async def auth(self, retry: int = 3) -> bool: ...
|
||||
|
||||
async def logout(self) -> None: ...
|
||||
|
||||
async def add_torrents(
|
||||
self, torrent_urls, torrent_files, save_path, category, tags=None
|
||||
) -> AddResult: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DownloaderClient(Protocol):
|
||||
"""The full async surface `DownloadClient` delegates to.
|
||||
|
||||
A backend that satisfies this protocol supports every facade operation. A
|
||||
backend that only satisfies `CoreDownloaderClient` is limited to auth and
|
||||
adding torrents; the facade guards the rest with `capabilities`.
|
||||
"""
|
||||
|
||||
capabilities: ClassVar[DownloaderCapabilities]
|
||||
|
||||
# Session lifecycle / connectivity
|
||||
async def auth(self, retry: int = 3) -> bool: ...
|
||||
|
||||
async def logout(self) -> None: ...
|
||||
|
||||
# Preferences / setup
|
||||
|
||||
async def add_category(self, category: str) -> None: ...
|
||||
|
||||
# Torrent lifecycle
|
||||
async def add_torrents(
|
||||
self, torrent_urls, torrent_files, save_path, category, tags=None
|
||||
) -> AddResult: ...
|
||||
|
||||
async def torrents_info(self, status_filter, category, tag=None) -> list[dict]: ...
|
||||
|
||||
async def torrent_exists(self, torrent_hash: str) -> bool | None: ...
|
||||
|
||||
async def torrents_files(self, torrent_hash: str) -> list[dict]: ...
|
||||
|
||||
async def torrents_delete(self, hash, delete_files: bool = True) -> bool: ...
|
||||
|
||||
async def torrents_pause(self, hashes: str) -> None: ...
|
||||
|
||||
async def torrents_resume(self, hashes: str) -> None: ...
|
||||
|
||||
async def torrents_rename_file(
|
||||
self, torrent_hash, old_path, new_path, verify: bool = True
|
||||
) -> RenameResult: ...
|
||||
|
||||
async def move_torrent(self, hashes, new_location) -> None: ...
|
||||
|
||||
async def set_category(self, _hash, category) -> None: ...
|
||||
|
||||
# Tagging
|
||||
async def add_tag(self, _hash, tag) -> None: ...
|
||||
|
||||
# RSS auto-download rules
|
||||
async def rss_set_rule(self, rule_name, rule_def) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloaderConnection:
|
||||
"""来自设置页「下载器」一节的连接参数。"""
|
||||
|
||||
host: str
|
||||
username: str
|
||||
password: str
|
||||
ssl: bool
|
||||
|
||||
|
||||
DownloaderFactory = Callable[[DownloaderConnection], CoreDownloaderClient]
|
||||
114
backend/src/ab_sdk/llm.py
Normal file
114
backend/src/ab_sdk/llm.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""LLM 提供商适配器契约。
|
||||
|
||||
第三方 LLM 插件用 ``@provider(points.LLM_PROVIDER, id=...)`` 返回一个
|
||||
:class:`LLMProviderAdapter` 子类(provider id 须与 ``info.id`` 一致);用户在
|
||||
LLM 设置里选择该 id 即可使用。
|
||||
|
||||
一个提供商 = 一个 `LLMProviderAdapter` 子类:负责客户端构造、
|
||||
`parse`/`list_models`,以及(订阅类提供商)认证流程钩子。内置三家与
|
||||
国产预设走 api_key;订阅插件(device_code/oauth)另行实现认证钩子。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ClassVar, Literal, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""提供商静态描述,驱动前端表单与注册表列举。"""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
auth_kind: Literal["api_key", "oauth", "device_code"] = "api_key"
|
||||
builtin: bool = False
|
||||
needs_base_url: bool = False
|
||||
preset_base_url: str = ""
|
||||
default_model: str = ""
|
||||
# OpenAI 兼容端点是否支持 json_schema 结构化输出;False 时降级为
|
||||
# json_object + schema 写入提示词(DeepSeek/MiniMax)。
|
||||
supports_json_schema: bool = True
|
||||
plugin_version: Optional[str] = None
|
||||
|
||||
|
||||
class AuthChallenge(BaseModel):
|
||||
"""begin_auth 的产物:引导用户完成授权所需的全部信息。"""
|
||||
|
||||
method: Literal["redirect_paste", "device_code"]
|
||||
authorize_url: Optional[str] = None
|
||||
user_code: Optional[str] = None
|
||||
verification_uri: Optional[str] = None
|
||||
expires_in: int = 600
|
||||
state: str
|
||||
|
||||
|
||||
class TokenSet(BaseModel):
|
||||
"""一次成功认证得到的凭据集(服务端持久化,永不下发前端)。"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str = ""
|
||||
expires_at: Optional[float] = None # epoch 秒
|
||||
account_label: str = ""
|
||||
extra: dict = {}
|
||||
|
||||
|
||||
class AuthExpiredError(Exception):
|
||||
"""凭据失效且刷新失败——调用方应立即熔断并通知用户重新连接。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterContext:
|
||||
"""适配器可触达的全部依赖(除此之外不读全局配置)。"""
|
||||
|
||||
model: str
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
timeout: float = 20.0
|
||||
# llm._build_http_client——代理感知的 httpx 客户端工厂(保持其
|
||||
# patch 路径在 llm.py,因此以依赖注入方式传入)。
|
||||
build_http_client: Callable[[float], httpx.AsyncClient] = None # type: ignore[assignment]
|
||||
# M3 起为 CredentialStore;api_key 类适配器恒为 None。
|
||||
credentials: Any = None
|
||||
|
||||
|
||||
class LLMProviderAdapter(ABC):
|
||||
"""提供商适配器基类。"""
|
||||
|
||||
info: ClassVar[ProviderInfo]
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
self.ctx = ctx
|
||||
# 模型留空时回退到本提供商的默认型号(预设/插件各有自己的默认,
|
||||
# 不会误用 openai 的 gpt-5-mini)。
|
||||
self.model = ctx.model or self.info.default_model
|
||||
|
||||
@abstractmethod
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
"""解析种子标题为 Episode 形状 dict;失败返回 None。"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[str]:
|
||||
"""列出可用模型 id(升序)。"""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
|
||||
# ------------------------------------------------------------ auth hooks
|
||||
# 仅 oauth / device_code 适配器覆写;api_key 类默认不支持。
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support token refresh")
|
||||
|
||||
async def revoke(self, tokens: TokenSet) -> None:
|
||||
"""注销远端凭据,尽力而为;默认无操作。"""
|
||||
48
backend/src/ab_sdk/notify.py
Normal file
48
backend/src/ab_sdk/notify.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""通知渠道契约。
|
||||
|
||||
插件用 ``@provider(points.NOTIFIER, id="mychannel")`` 返回一个
|
||||
:data:`NotifierFactory`。用户在通知设置里添加 ``type`` 为该 id 的渠道后,
|
||||
宿主会用该渠道条目构造 :class:`Notifier`。渠道的凭据等配置建议放在插件自己的
|
||||
``config_model`` 里(通过 ``self.config`` 读取);渠道条目只携带模板和
|
||||
条目上的额外字段。
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotifierSettings:
|
||||
"""通知设置中的一条渠道配置。"""
|
||||
|
||||
# 单集通知模板({{title}}/{{season}}/{{episode}}/{{poster_url}}),可为空
|
||||
template: str | None = None
|
||||
# 渠道条目上除通用字段以外的其它字段
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationMessage:
|
||||
kind: Literal["episode", "event"]
|
||||
title: str
|
||||
body: str
|
||||
# 以下仅 kind == "episode" 时有值
|
||||
official_title: str | None = None
|
||||
season: int | None = None
|
||||
episode: int | float | None = None
|
||||
# 配置了 notification.base_url 时为可公开访问的海报地址
|
||||
poster_url: str | None = None
|
||||
|
||||
|
||||
class Notifier(Protocol):
|
||||
async def send(self, message: NotificationMessage) -> bool:
|
||||
"""投递一条消息,成功返回 True。"""
|
||||
...
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
"""发送测试消息,返回 (是否成功, 说明)。"""
|
||||
...
|
||||
|
||||
|
||||
NotifierFactory = Callable[[NotifierSettings], Notifier]
|
||||
18
backend/src/ab_sdk/points.py
Normal file
18
backend/src/ab_sdk/points.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""宿主声明的扩展点名称。插件用这些常量而不是手写字符串。
|
||||
|
||||
Provider 扩展点(``@provider``):
|
||||
|
||||
- ``DOWNLOADER``:返回 :data:`ab_sdk.downloader.DownloaderFactory`,id 即
|
||||
``downloader.type`` 的取值
|
||||
- ``NOTIFIER``:返回 :data:`ab_sdk.notify.NotifierFactory`,id 即通知渠道的
|
||||
``type``
|
||||
- ``LLM_PROVIDER``:返回 :class:`ab_sdk.llm.LLMProviderAdapter` 子类
|
||||
- ``SEARCH_SITE``:返回 :class:`ab_sdk.search.SearchSite`,id 即站点名
|
||||
- ``SCHEDULED_TASK``:返回 :class:`ab_sdk.tasks.ScheduledTask`
|
||||
"""
|
||||
|
||||
DOWNLOADER = "downloader"
|
||||
NOTIFIER = "notifier"
|
||||
LLM_PROVIDER = "llm_provider"
|
||||
SEARCH_SITE = "search_site"
|
||||
SCHEDULED_TASK = "scheduled_task"
|
||||
16
backend/src/ab_sdk/search.py
Normal file
16
backend/src/ab_sdk/search.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""搜索站点契约。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchSite:
|
||||
"""一个返回 RSS 的搜索站点。
|
||||
|
||||
``url`` 中的 ``%s`` 会被替换为关键词(以 ``+`` 连接)。``parser`` 指定用哪个
|
||||
元数据源补全搜索结果:``mikan`` 或 ``tmdb``。用户在搜索设置里配置的同名
|
||||
站点优先于插件提供的站点。
|
||||
"""
|
||||
|
||||
url: str
|
||||
parser: str = "tmdb"
|
||||
20
backend/src/ab_sdk/tasks.py
Normal file
20
backend/src/ab_sdk/tasks.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""定时任务契约。"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduledTask:
|
||||
"""由宿主调度器周期执行的任务。
|
||||
|
||||
``run`` 执行一次后等待 ``interval`` 秒再执行下一次;``interval`` 与
|
||||
``enabled`` 可以是可调用对象,每轮都会重新读取,以便跟随插件配置变化。
|
||||
``initial_delay`` 大于 0 时首次执行前先等待。任务只在程序运行时执行
|
||||
(与 RSS 刷新等内置任务一同启停),失败会计入插件熔断。
|
||||
"""
|
||||
|
||||
run: Callable[[], Awaitable[None]]
|
||||
interval: float | Callable[[], float]
|
||||
initial_delay: float = 0.0
|
||||
enabled: Callable[[], bool] | None = None
|
||||
@@ -9,6 +9,7 @@ from module.conf import settings
|
||||
from module.core import AppContext
|
||||
from module.models import APIResponse, Config
|
||||
from module.parser.analyser.llm import LLMParser
|
||||
from module.plugin.secrets import mask_options, restore_options
|
||||
from module.security.api import UNAUTHORIZED, get_current_user
|
||||
|
||||
from .deps import get_context
|
||||
@@ -132,10 +133,19 @@ def _restore_masked(incoming: dict, current: dict) -> dict:
|
||||
return incoming
|
||||
|
||||
|
||||
def _plugin_schemas(ctx: AppContext) -> dict[str, dict | None]:
|
||||
return {status.id: status.config_schema for status in ctx.plugins.statuses()}
|
||||
|
||||
|
||||
@router.get("/get", dependencies=[Depends(get_current_user)])
|
||||
async def get_config():
|
||||
async def get_config(ctx: AppContext = Depends(get_context)):
|
||||
"""Return the current configuration with sensitive fields masked."""
|
||||
return _sanitize_dict(settings.dict())
|
||||
config = settings.dict()
|
||||
schemas = _plugin_schemas(ctx)
|
||||
options = config["plugins"]["options"]
|
||||
for plugin_id, plugin_options in options.items():
|
||||
options[plugin_id] = mask_options(plugin_options, schemas.get(plugin_id))
|
||||
return _sanitize_dict(config)
|
||||
|
||||
|
||||
@router.patch(
|
||||
@@ -144,7 +154,17 @@ async def get_config():
|
||||
async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
|
||||
"""Persist and reload configuration from the supplied payload."""
|
||||
try:
|
||||
config_dict = _restore_masked(config.dict(), settings.dict())
|
||||
config_dict = config.dict()
|
||||
current = settings.dict()
|
||||
schemas = _plugin_schemas(ctx)
|
||||
current_options = current.get("plugins", {}).get("options", {})
|
||||
for plugin_id, plugin_options in config_dict["plugins"]["options"].items():
|
||||
restore_options(
|
||||
plugin_options,
|
||||
current_options.get(plugin_id, {}),
|
||||
schemas.get(plugin_id),
|
||||
)
|
||||
config_dict = _restore_masked(config_dict, current)
|
||||
# settings.save() does synchronous file I/O; keep it off the event loop.
|
||||
await asyncio.to_thread(settings.save, config_dict=config_dict)
|
||||
# reload_settings reloads from disk, resets the shared HTTP client,
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ab_sdk import points
|
||||
from module.conf import settings
|
||||
from module.core import AppContext
|
||||
from module.plugin.host import plugin_provider_ids
|
||||
from module.plugin.secrets import mask_options, restore_options
|
||||
from module.security.api import get_current_user
|
||||
|
||||
from .deps import get_context
|
||||
|
||||
router = APIRouter(prefix="/plugins", tags=["plugins"])
|
||||
router = APIRouter(
|
||||
prefix="/plugins", tags=["plugins"], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginInfo(BaseModel):
|
||||
@@ -16,14 +27,92 @@ class PluginInfo(BaseModel):
|
||||
source: str
|
||||
signed: bool
|
||||
state: str
|
||||
enabled: bool
|
||||
description: str
|
||||
permissions: list[str]
|
||||
error: str | None
|
||||
config_schema: dict[str, Any] | None
|
||||
options: dict[str, Any]
|
||||
|
||||
|
||||
@router.get(
|
||||
"", response_model=list[PluginInfo], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
class PluginsOverview(BaseModel):
|
||||
allow_unsigned: bool
|
||||
plugins: list[PluginInfo]
|
||||
|
||||
|
||||
class PluginUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
options: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PluginsSettingsUpdate(BaseModel):
|
||||
allow_unsigned: bool
|
||||
|
||||
|
||||
def _overview(ctx: AppContext) -> PluginsOverview:
|
||||
conf = settings.plugins
|
||||
plugins = []
|
||||
for status in ctx.plugins.statuses():
|
||||
info = vars(status) | {
|
||||
"options": mask_options(
|
||||
conf.options.get(status.id, {}), status.config_schema
|
||||
)
|
||||
}
|
||||
plugins.append(PluginInfo(**info))
|
||||
return PluginsOverview(allow_unsigned=conf.allow_unsigned, plugins=plugins)
|
||||
|
||||
|
||||
async def _save_and_apply(ctx: AppContext) -> None:
|
||||
# 与 /config/update 相同:落盘后走 reload_settings 统一重新应用
|
||||
await asyncio.to_thread(settings.save)
|
||||
await ctx.reload_settings()
|
||||
|
||||
|
||||
@router.get("", response_model=PluginsOverview)
|
||||
async def list_plugins(ctx: AppContext = Depends(get_context)):
|
||||
"""已发现的插件及其运行状态(active / disabled / error)。"""
|
||||
return [PluginInfo(**vars(status)) for status in ctx.plugins.statuses()]
|
||||
"""已发现的插件、运行状态、配置表单 schema 与(掩码后的)当前配置。"""
|
||||
return _overview(ctx)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=PluginsOverview)
|
||||
async def update_plugins_settings(
|
||||
body: PluginsSettingsUpdate, ctx: AppContext = Depends(get_context)
|
||||
):
|
||||
settings.plugins.allow_unsigned = body.allow_unsigned
|
||||
await _save_and_apply(ctx)
|
||||
return _overview(ctx)
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", response_model=PluginsOverview)
|
||||
async def update_plugin(
|
||||
plugin_id: str, body: PluginUpdate, ctx: AppContext = Depends(get_context)
|
||||
):
|
||||
"""启用/停用插件或修改其配置;保存后立即重新应用。"""
|
||||
status = next((s for s in ctx.plugins.statuses() if s.id == plugin_id), None)
|
||||
if status is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown plugin: {plugin_id}")
|
||||
conf = settings.plugins
|
||||
if body.options is not None:
|
||||
options = restore_options(
|
||||
dict(body.options), conf.options.get(plugin_id, {}), status.config_schema
|
||||
)
|
||||
try:
|
||||
ctx.plugins.validate_options(plugin_id, options)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=e.errors(include_url=False)
|
||||
) from None
|
||||
conf.options[plugin_id] = options
|
||||
if body.enabled is not None:
|
||||
conf.enabled[plugin_id] = body.enabled
|
||||
await _save_and_apply(ctx)
|
||||
return _overview(ctx)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=dict[str, list[str]])
|
||||
async def list_plugin_providers():
|
||||
"""插件提供的 Provider id(按扩展点),设置页把它们并入下拉候选。"""
|
||||
return {
|
||||
point: plugin_provider_ids(point)
|
||||
for point in (points.DOWNLOADER, points.NOTIFIER, points.SEARCH_SITE)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from module.conf.search_provider import get_provider
|
||||
from module.database import Database, get_db
|
||||
from module.downloader import DownloadClient
|
||||
from module.manager import SeasonCollector
|
||||
from module.models import APIResponse, Bangumi, Movie, RSSItem, RSSUpdate, Torrent
|
||||
from module.rss import RSSAnalyser, RSSEngine
|
||||
from module.searcher import available_sites
|
||||
from module.security.api import get_current_user
|
||||
|
||||
from .response import u_response
|
||||
@@ -216,7 +216,7 @@ async def subscribe(data: Bangumi, rss: RSSItem):
|
||||
# 且不参与站点映射——避免 "mikan" 这类与站点同名的解析器被配置改写。
|
||||
parser = rss.parser
|
||||
if parser not in PARSER_TYPES:
|
||||
providers = get_provider()
|
||||
providers = available_sites()
|
||||
if parser in providers:
|
||||
parser = providers[parser]["parser"]
|
||||
resp = await SeasonCollector.subscribe_season(data, parser=parser)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from module.conf.search_provider import get_provider, save_provider
|
||||
from module.searcher import SEARCH_CONFIG, SearchTorrent
|
||||
from module.searcher import SearchTorrent, available_sites
|
||||
from module.security.api import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/search", tags=["search"])
|
||||
@@ -29,7 +29,7 @@ async def search_torrents(site: str = "mikan", keywords: str = Query(None)):
|
||||
"/provider", response_model=list[str], dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
async def search_provider():
|
||||
return list(SEARCH_CONFIG.keys())
|
||||
return list(available_sites())
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
|
||||
from .config import IMAGE_VERSION, VERSION, settings
|
||||
from .log import LOG_PATH, setup_logger
|
||||
from .search_provider import SEARCH_CONFIG
|
||||
|
||||
TMDB_API = "32b19d6a05b512190a056fa4e747cbbc"
|
||||
DATA_PATH = "sqlite:///data/data.db"
|
||||
|
||||
@@ -20,6 +20,7 @@ from module.parser.analyser.mikan_parser import reset_cache as reset_mikan_cache
|
||||
from module.parser.analyser.tmdb_parser import reset_cache as reset_tmdb_cache
|
||||
from module.parser.title_parser import reset_cache as reset_llm_parser
|
||||
from module.plugin import PluginManager
|
||||
from module.plugin.host import get_registry
|
||||
from module.rss import RSSAnalyser
|
||||
from module.searcher.searcher import reset_cache as reset_poster_cache
|
||||
from module.update import (
|
||||
@@ -36,6 +37,7 @@ from .loops import (
|
||||
rss_tick,
|
||||
update_check_tick,
|
||||
)
|
||||
from .plugin_tasks import PluginTasks
|
||||
from .scheduler import PeriodicTask, Scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -83,7 +85,11 @@ class AppContext:
|
||||
self.scheduler = scheduler
|
||||
self.analyser = analyser
|
||||
# 插件随进程生命周期启停(lifespan),不随 /start、/stop 程序控制启停
|
||||
self.plugins = plugins or PluginManager(settings_obj)
|
||||
self.plugins = plugins or PluginManager(settings_obj, registry=get_registry())
|
||||
self._plugin_tasks = PluginTasks(
|
||||
scheduler, self.plugins.registry, self.plugins.breaker
|
||||
)
|
||||
self.plugins.on_change = self._on_plugins_changed
|
||||
# Downloader-status TTL cache (was ProgramStatus.check_downloader_status).
|
||||
self._downloader_status = False
|
||||
self._downloader_reason: str | None = None
|
||||
@@ -99,6 +105,14 @@ class AppContext:
|
||||
# Program.startup() early-return.
|
||||
self.first_run_boot = False
|
||||
|
||||
async def _on_plugins_changed(self) -> None:
|
||||
"""插件加载/重载/停用后,重建依赖插件 Provider 的派生状态。"""
|
||||
await self._plugin_tasks.sync()
|
||||
# 插件提供的通知渠道在插件加载后才可解析;LLM 解析器可能缓存了旧的
|
||||
# 插件适配器。下载器客户端缓存键包含 Provider 登记项,会自动换新。
|
||||
self.notifier.rebuild()
|
||||
reset_llm_parser()
|
||||
|
||||
# ------------------------------------------------------------------ build
|
||||
|
||||
@classmethod
|
||||
@@ -110,7 +124,9 @@ class AppContext:
|
||||
reflected without re-wiring the tasks; ``interval`` reads settings live.
|
||||
"""
|
||||
analyser = RSSAnalyser()
|
||||
notifier = NotificationManager()
|
||||
# 渠道可能由插件提供,插件启动后(plugins.on_change)才首次加载,
|
||||
# 避免启动时对插件渠道误报「未知类型」
|
||||
notifier = NotificationManager(load_providers=False)
|
||||
scheduler = Scheduler(
|
||||
[
|
||||
PeriodicTask(
|
||||
|
||||
73
backend/src/module/core/plugin_tasks.py
Normal file
73
backend/src/module/core/plugin_tasks.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""把插件以 points.SCHEDULED_TASK 登记的任务同步进调度器。"""
|
||||
|
||||
import logging
|
||||
|
||||
from ab_sdk import points
|
||||
from ab_sdk.tasks import ScheduledTask
|
||||
from module.plugin.registry import ExtensionRegistry, ProviderEntry
|
||||
from module.plugin.runner import CircuitBreaker
|
||||
|
||||
from .scheduler import PeriodicTask, Scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginTasks:
|
||||
def __init__(
|
||||
self,
|
||||
scheduler: Scheduler,
|
||||
registry: ExtensionRegistry,
|
||||
breaker: CircuitBreaker,
|
||||
) -> None:
|
||||
self._scheduler = scheduler
|
||||
self._registry = registry
|
||||
self._breaker = breaker
|
||||
self._current: dict[str, tuple[ProviderEntry, PeriodicTask]] = {}
|
||||
|
||||
async def sync(self) -> None:
|
||||
"""插件加载/重载/停用后调用:移除已失效的任务,登记新任务。
|
||||
|
||||
以 ProviderEntry 对象身份判断变化——插件因配置变更重载后会登记新的
|
||||
entry,旧任务随之替换为绑定新插件实例的任务。
|
||||
"""
|
||||
entries = self._registry.providers(points.SCHEDULED_TASK)
|
||||
for task_id, (entry, task) in list(self._current.items()):
|
||||
if entries.get(task_id) is not entry:
|
||||
del self._current[task_id]
|
||||
await self._scheduler.remove(task)
|
||||
for task_id, entry in entries.items():
|
||||
if task_id in self._current:
|
||||
continue
|
||||
new_task = self._build(task_id, entry)
|
||||
if new_task is not None:
|
||||
self._current[task_id] = (entry, new_task)
|
||||
self._scheduler.add(new_task)
|
||||
|
||||
def _build(self, task_id: str, entry: ProviderEntry) -> PeriodicTask | None:
|
||||
plugin_id = entry.plugin_id
|
||||
try:
|
||||
spec: ScheduledTask = entry.factory()
|
||||
except Exception as e:
|
||||
reason = f"{points.SCHEDULED_TASK} {task_id}: {type(e).__name__}: {e}"
|
||||
logger.warning("[Plugin:%s] 定时任务创建失败:%s", plugin_id, reason)
|
||||
self._breaker.record_failure(plugin_id, reason)
|
||||
return None
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await spec.run()
|
||||
except Exception as e:
|
||||
reason = f"定时任务 {task_id}: {type(e).__name__}: {e}"
|
||||
logger.warning("[Plugin:%s] %s", plugin_id, reason)
|
||||
self._breaker.record_failure(plugin_id, reason)
|
||||
return
|
||||
self._breaker.record_success(plugin_id)
|
||||
|
||||
interval = spec.interval
|
||||
return PeriodicTask(
|
||||
name=f"plugin:{plugin_id}:{task_id}",
|
||||
run=run,
|
||||
interval=interval if callable(interval) else (lambda: float(interval)),
|
||||
initial_delay=spec.initial_delay,
|
||||
enabled=spec.enabled or (lambda: True),
|
||||
)
|
||||
@@ -99,10 +99,14 @@ class PeriodicTask:
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""Owns a fixed set of :class:`PeriodicTask` and starts/stops them together."""
|
||||
"""Owns a set of :class:`PeriodicTask` and starts/stops them together.
|
||||
|
||||
The built-in tasks are fixed at construction; plugin tasks are added and
|
||||
removed at runtime via :meth:`add` / :meth:`remove`.
|
||||
"""
|
||||
|
||||
def __init__(self, tasks: list[PeriodicTask]) -> None:
|
||||
self._tasks = tasks
|
||||
self._tasks = list(tasks)
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
@@ -123,3 +127,14 @@ class Scheduler:
|
||||
async def stop_all(self) -> None:
|
||||
await asyncio.gather(*(task.stop() for task in self._tasks))
|
||||
self._running = False
|
||||
|
||||
def add(self, task: PeriodicTask) -> None:
|
||||
"""Register a task; it starts immediately if the scheduler is running."""
|
||||
self._tasks.append(task)
|
||||
if self._running and task.enabled:
|
||||
task.start()
|
||||
|
||||
async def remove(self, task: PeriodicTask) -> None:
|
||||
if task in self._tasks:
|
||||
self._tasks.remove(task)
|
||||
await task.stop()
|
||||
|
||||
@@ -1,135 +1,23 @@
|
||||
"""Download-client protocol and capability declarations.
|
||||
"""下载器契约的宿主侧入口:定义已移至 ``ab_sdk.downloader``,此处重新导出。"""
|
||||
|
||||
`DownloadClient` (the facade) delegates every concrete operation to an object
|
||||
that implements `DownloaderClient`. Different backends support different subsets
|
||||
of the qBittorrent surface, so each concrete client advertises what it can do
|
||||
via a `DownloaderCapabilities` class attribute; the facade consults it and skips
|
||||
unsupported operations instead of blowing up on a missing method.
|
||||
"""
|
||||
from ab_sdk.downloader import (
|
||||
AddResult,
|
||||
CoreDownloaderClient,
|
||||
DownloaderCapabilities,
|
||||
DownloaderClient,
|
||||
DownloaderConnection,
|
||||
DownloaderFactory,
|
||||
RenameOutcome,
|
||||
RenameResult,
|
||||
)
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import ClassVar, Protocol, runtime_checkable
|
||||
|
||||
|
||||
class AddResult(Enum):
|
||||
"""Torrent-add result shared by concrete clients and the facade."""
|
||||
|
||||
ADDED = "added"
|
||||
DUPLICATE = "duplicate"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RenameOutcome(str, Enum):
|
||||
"""Downloader-independent outcome of a single file rename."""
|
||||
|
||||
RENAMED = "renamed"
|
||||
ALREADY_APPLIED = "already_applied"
|
||||
DESTINATION_EXISTS = "destination_exists"
|
||||
RETRYABLE_FAILURE = "retryable_failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenameResult:
|
||||
"""Structured rename result preserved across the downloader facade.
|
||||
|
||||
``bool(result)`` deliberately keeps the old success/failure behaviour while
|
||||
callers migrate to inspecting :attr:`outcome`. It must never make a
|
||||
collision or retryable failure truthy.
|
||||
"""
|
||||
|
||||
outcome: RenameOutcome
|
||||
detail: str | None = None
|
||||
|
||||
@property
|
||||
def succeeded(self) -> bool:
|
||||
return self.outcome in {
|
||||
RenameOutcome.RENAMED,
|
||||
RenameOutcome.ALREADY_APPLIED,
|
||||
}
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.succeeded
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloaderCapabilities:
|
||||
"""What a concrete download client can do.
|
||||
|
||||
can_query -- torrents_info / torrents_files
|
||||
can_rename -- torrents_rename_file
|
||||
can_manage -- delete / pause / resume / move / category / tags
|
||||
can_rss_rules -- qB-native RSS feeds + auto-download rules + prefs
|
||||
"""
|
||||
|
||||
can_query: bool
|
||||
can_rename: bool
|
||||
can_manage: bool
|
||||
can_rss_rules: bool
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CoreDownloaderClient(Protocol):
|
||||
"""The minimum every backend must implement: authenticate and add torrents."""
|
||||
|
||||
capabilities: ClassVar[DownloaderCapabilities]
|
||||
|
||||
async def auth(self, retry: int = 3) -> bool: ...
|
||||
|
||||
async def logout(self) -> None: ...
|
||||
|
||||
async def add_torrents(
|
||||
self, torrent_urls, torrent_files, save_path, category, tags=None
|
||||
) -> AddResult: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DownloaderClient(Protocol):
|
||||
"""The full async surface `DownloadClient` delegates to.
|
||||
|
||||
A backend that satisfies this protocol supports every facade operation. A
|
||||
backend that only satisfies `CoreDownloaderClient` is limited to auth and
|
||||
adding torrents; the facade guards the rest with `capabilities`.
|
||||
"""
|
||||
|
||||
capabilities: ClassVar[DownloaderCapabilities]
|
||||
|
||||
# Session lifecycle / connectivity
|
||||
async def auth(self, retry: int = 3) -> bool: ...
|
||||
|
||||
async def logout(self) -> None: ...
|
||||
|
||||
# Preferences / setup
|
||||
|
||||
async def add_category(self, category: str) -> None: ...
|
||||
|
||||
# Torrent lifecycle
|
||||
async def add_torrents(
|
||||
self, torrent_urls, torrent_files, save_path, category, tags=None
|
||||
) -> AddResult: ...
|
||||
|
||||
async def torrents_info(self, status_filter, category, tag=None) -> list[dict]: ...
|
||||
|
||||
async def torrent_exists(self, torrent_hash: str) -> bool | None: ...
|
||||
|
||||
async def torrents_files(self, torrent_hash: str) -> list[dict]: ...
|
||||
|
||||
async def torrents_delete(self, hash, delete_files: bool = True) -> bool: ...
|
||||
|
||||
async def torrents_pause(self, hashes: str) -> None: ...
|
||||
|
||||
async def torrents_resume(self, hashes: str) -> None: ...
|
||||
|
||||
async def torrents_rename_file(
|
||||
self, torrent_hash, old_path, new_path, verify: bool = True
|
||||
) -> RenameResult: ...
|
||||
|
||||
async def move_torrent(self, hashes, new_location) -> None: ...
|
||||
|
||||
async def set_category(self, _hash, category) -> None: ...
|
||||
|
||||
# Tagging
|
||||
async def add_tag(self, _hash, tag) -> None: ...
|
||||
|
||||
# RSS auto-download rules
|
||||
async def rss_set_rule(self, rule_name, rule_def) -> None: ...
|
||||
__all__ = [
|
||||
"AddResult",
|
||||
"CoreDownloaderClient",
|
||||
"DownloaderCapabilities",
|
||||
"DownloaderClient",
|
||||
"DownloaderConnection",
|
||||
"DownloaderFactory",
|
||||
"RenameOutcome",
|
||||
"RenameResult",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,19 @@ import logging
|
||||
from collections import defaultdict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ab_sdk import points
|
||||
from module.conf import settings
|
||||
from module.models import Bangumi, Torrent
|
||||
from module.network import RequestContent
|
||||
from module.plugin import host as plugin_host
|
||||
|
||||
from .base import AddResult, DownloaderClient, RenameOutcome, RenameResult
|
||||
from .base import (
|
||||
AddResult,
|
||||
DownloaderClient,
|
||||
DownloaderConnection,
|
||||
RenameOutcome,
|
||||
RenameResult,
|
||||
)
|
||||
from .path import gen_save_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -78,7 +86,9 @@ _warned_unsupported: set[tuple[str, str]] = set()
|
||||
|
||||
def _settings_key() -> tuple:
|
||||
d = settings.downloader
|
||||
return (d.type, d.host, d.username, d.password, d.ssl)
|
||||
# 带上 Provider 登记项:插件重载后登记项换新,旧客户端随之退役
|
||||
entry = plugin_host.get_registry().providers(points.DOWNLOADER).get(d.type)
|
||||
return (d.type, d.host, d.username, d.password, d.ssl, entry)
|
||||
|
||||
|
||||
def _reset_client_cache() -> None:
|
||||
@@ -148,33 +158,26 @@ class DownloadClient:
|
||||
|
||||
@staticmethod
|
||||
def __getClient() -> DownloaderClient:
|
||||
"""Instantiate the configured downloader client (qbittorrent | aria2 | mock)."""
|
||||
"""按 ``downloader.type`` 从扩展注册表取下载器工厂并实例化。
|
||||
|
||||
内置的 qbittorrent / aria2 / mock 与插件提供的下载器走同一条路径。
|
||||
"""
|
||||
downloader_type = settings.downloader.type
|
||||
host = settings.downloader.host
|
||||
username = settings.downloader.username
|
||||
password = settings.downloader.password
|
||||
ssl = settings.downloader.ssl
|
||||
if downloader_type == "qbittorrent":
|
||||
from .client.qb_downloader import QbDownloader
|
||||
|
||||
return QbDownloader(host, username, password, ssl)
|
||||
elif downloader_type == "aria2":
|
||||
from .client.aria2_downloader import Aria2Downloader
|
||||
|
||||
# Aria2Downloader implements query/rename/manage for real (see its
|
||||
# `capabilities`), but has no qB-native RSS-rule/prefs surface
|
||||
# (can_rss_rules=False), so it stays structurally narrower than
|
||||
# the full `DownloaderClient` protocol -- the facade skips the
|
||||
# rss/prefs methods it never calls on this backend.
|
||||
return Aria2Downloader(host, username, password) # type: ignore[return-value]
|
||||
elif downloader_type == "mock":
|
||||
from .client.mock_downloader import MockDownloader
|
||||
|
||||
logger.debug("Using MockDownloader for local development")
|
||||
return MockDownloader()
|
||||
else:
|
||||
factory = plugin_host.provider(points.DOWNLOADER, downloader_type)
|
||||
if factory is None:
|
||||
logger.error("Unsupported downloader type: %s", downloader_type)
|
||||
raise Exception(f"Unsupported downloader type: {downloader_type}")
|
||||
if downloader_type == "mock":
|
||||
logger.debug("Using MockDownloader for local development")
|
||||
conn = DownloaderConnection(
|
||||
host=settings.downloader.host,
|
||||
username=settings.downloader.username,
|
||||
password=settings.downloader.password,
|
||||
ssl=settings.downloader.ssl,
|
||||
)
|
||||
# 只实现 CoreDownloaderClient 的后端(如 aria2 没有 qB 的 RSS 规则)
|
||||
# 由 _supports() 按 capabilities 跳过不支持的操作
|
||||
return factory(conn) # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def last_auth_error(self) -> str | None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from os.path import expandvars
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def _expand(value: str | None) -> str:
|
||||
@@ -127,6 +127,10 @@ class Proxy(BaseModel):
|
||||
class NotificationProvider(BaseModel):
|
||||
"""Configuration for a single notification provider."""
|
||||
|
||||
# 插件提供的渠道可以携带自己的字段(作为 NotifierSettings.extra 传给插件),
|
||||
# 保留未知字段以免保存配置时被丢弃
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
type: str = Field(..., description="Provider type (telegram, discord, bark, etc.)")
|
||||
enabled: bool = Field(default=True, description="Whether this provider is enabled")
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ logger = logging.getLogger(__name__)
|
||||
class NotificationManager:
|
||||
"""Manager for handling notifications across multiple providers."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, load_providers: bool = True):
|
||||
self.providers: list["NotificationProvider"] = []
|
||||
self._load_providers()
|
||||
if load_providers:
|
||||
self._load_providers()
|
||||
|
||||
def rebuild(self):
|
||||
"""Reload providers from current settings, mutating in place.
|
||||
@@ -35,22 +36,21 @@ class NotificationManager:
|
||||
|
||||
def _load_providers(self):
|
||||
"""Initialize providers from configuration."""
|
||||
from module.notification.providers import PROVIDER_REGISTRY
|
||||
from module.notification.resolve import build_provider
|
||||
|
||||
for cfg in settings.notification.providers:
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
|
||||
provider_cls = PROVIDER_REGISTRY.get(cfg.type.lower())
|
||||
if provider_cls:
|
||||
try:
|
||||
provider = provider_cls(cfg)
|
||||
self.providers.append(provider)
|
||||
logger.debug("Loaded notification provider: %s", cfg.type)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load provider {cfg.type}: {e}")
|
||||
else:
|
||||
try:
|
||||
provider = build_provider(cfg)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load provider {cfg.type}: {e}")
|
||||
continue
|
||||
if provider is None:
|
||||
logger.warning(f"Unknown notification provider type: {cfg.type}")
|
||||
continue
|
||||
self.providers.append(provider)
|
||||
logger.debug("Loaded notification provider: %s", cfg.type)
|
||||
|
||||
async def _get_poster(self, notification: Notification):
|
||||
"""Fetch poster path from database if not already set."""
|
||||
@@ -172,14 +172,12 @@ class NotificationManager:
|
||||
Returns:
|
||||
A tuple of (success, message).
|
||||
"""
|
||||
from module.notification.providers import PROVIDER_REGISTRY
|
||||
|
||||
provider_cls = PROVIDER_REGISTRY.get(config.type.lower())
|
||||
if not provider_cls:
|
||||
return False, f"Unknown provider type: {config.type}"
|
||||
from module.notification.resolve import build_provider
|
||||
|
||||
try:
|
||||
provider = provider_cls(config)
|
||||
provider = build_provider(config)
|
||||
if provider is None:
|
||||
return False, f"Unknown provider type: {config.type}"
|
||||
async with provider:
|
||||
return await provider.test()
|
||||
except Exception as e:
|
||||
|
||||
60
backend/src/module/notification/resolve.py
Normal file
60
backend/src/module/notification/resolve.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""按渠道 ``type`` 从扩展注册表构造通知渠道(内置与插件统一入口)。"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ab_sdk import points
|
||||
from ab_sdk.notify import NotificationMessage, Notifier, NotifierSettings
|
||||
from module.models.bangumi import Notification
|
||||
from module.plugin.host import CORE, get_registry
|
||||
|
||||
from .base import NotificationProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
|
||||
|
||||
class PluginNotifier(NotificationProvider):
|
||||
"""把插件的 :class:`ab_sdk.notify.Notifier` 适配为宿主的渠道接口。
|
||||
|
||||
模板渲染与海报地址仍由宿主负责,插件拿到的是渲染好的消息。
|
||||
"""
|
||||
|
||||
def __init__(self, config: "ProviderConfig", notifier: Notifier) -> None:
|
||||
super().__init__(config)
|
||||
self._notifier = notifier
|
||||
|
||||
async def send(self, notification: Notification) -> bool:
|
||||
return await self._notifier.send(
|
||||
NotificationMessage(
|
||||
kind="episode",
|
||||
title=notification.official_title,
|
||||
body=self._format_message(notification),
|
||||
official_title=notification.official_title,
|
||||
season=notification.season,
|
||||
episode=notification.episode,
|
||||
poster_url=self._poster_url(notification),
|
||||
)
|
||||
)
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
return await self._notifier.send(
|
||||
NotificationMessage(kind="event", title=title, body=body)
|
||||
)
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
return await self._notifier.test()
|
||||
|
||||
|
||||
def build_provider(config: "ProviderConfig") -> NotificationProvider | None:
|
||||
"""构造渠道实例;``type`` 未登记时返回 None。"""
|
||||
entry = get_registry().providers(points.NOTIFIER).get(config.type.lower())
|
||||
if entry is None:
|
||||
return None
|
||||
impl = entry.factory()
|
||||
if entry.plugin_id == CORE:
|
||||
# 内置渠道:impl 是 NotificationProvider 子类,直接接收整条配置
|
||||
return impl(config)
|
||||
settings = NotifierSettings(
|
||||
template=config.template, extra=dict(config.model_extra or {})
|
||||
)
|
||||
return PluginNotifier(config, impl(settings))
|
||||
@@ -1,110 +1,19 @@
|
||||
"""LLM 提供商适配器契约。
|
||||
"""LLM 适配器契约的宿主侧入口:定义已移至 ``ab_sdk.llm``,此处重新导出。"""
|
||||
|
||||
一个提供商 = 一个 `LLMProviderAdapter` 子类:负责客户端构造、
|
||||
`parse`/`list_models`,以及(订阅类提供商)认证流程钩子。内置三家与
|
||||
国产预设走 api_key;订阅插件(device_code/oauth)另行实现认证钩子。
|
||||
"""
|
||||
from ab_sdk.llm import (
|
||||
AdapterContext,
|
||||
AuthChallenge,
|
||||
AuthExpiredError,
|
||||
LLMProviderAdapter,
|
||||
ProviderInfo,
|
||||
TokenSet,
|
||||
)
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ClassVar, Literal, Optional
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProviderInfo(BaseModel):
|
||||
"""提供商静态描述,驱动前端表单与注册表列举。"""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
auth_kind: Literal["api_key", "oauth", "device_code"] = "api_key"
|
||||
builtin: bool = False
|
||||
needs_base_url: bool = False
|
||||
preset_base_url: str = ""
|
||||
default_model: str = ""
|
||||
# OpenAI 兼容端点是否支持 json_schema 结构化输出;False 时降级为
|
||||
# json_object + schema 写入提示词(DeepSeek/MiniMax)。
|
||||
supports_json_schema: bool = True
|
||||
plugin_version: Optional[str] = None
|
||||
|
||||
|
||||
class AuthChallenge(BaseModel):
|
||||
"""begin_auth 的产物:引导用户完成授权所需的全部信息。"""
|
||||
|
||||
method: Literal["redirect_paste", "device_code"]
|
||||
authorize_url: Optional[str] = None
|
||||
user_code: Optional[str] = None
|
||||
verification_uri: Optional[str] = None
|
||||
expires_in: int = 600
|
||||
state: str
|
||||
|
||||
|
||||
class TokenSet(BaseModel):
|
||||
"""一次成功认证得到的凭据集(服务端持久化,永不下发前端)。"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str = ""
|
||||
expires_at: Optional[float] = None # epoch 秒
|
||||
account_label: str = ""
|
||||
extra: dict = {}
|
||||
|
||||
|
||||
class AuthExpiredError(Exception):
|
||||
"""凭据失效且刷新失败——调用方应立即熔断并通知用户重新连接。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdapterContext:
|
||||
"""适配器可触达的全部依赖(除此之外不读全局配置)。"""
|
||||
|
||||
model: str
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
timeout: float = 20.0
|
||||
# llm._build_http_client——代理感知的 httpx 客户端工厂(保持其
|
||||
# patch 路径在 llm.py,因此以依赖注入方式传入)。
|
||||
build_http_client: Callable[[float], httpx.AsyncClient] = None # type: ignore[assignment]
|
||||
# M3 起为 CredentialStore;api_key 类适配器恒为 None。
|
||||
credentials: Any = None
|
||||
|
||||
|
||||
class LLMProviderAdapter(ABC):
|
||||
"""提供商适配器基类。"""
|
||||
|
||||
info: ClassVar[ProviderInfo]
|
||||
|
||||
_http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
def __init__(self, ctx: AdapterContext) -> None:
|
||||
self.ctx = ctx
|
||||
# 模型留空时回退到本提供商的默认型号(预设/插件各有自己的默认,
|
||||
# 不会误用 openai 的 gpt-5-mini)。
|
||||
self.model = ctx.model or self.info.default_model
|
||||
|
||||
@abstractmethod
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
"""解析种子标题为 Episode 形状 dict;失败返回 None。"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_models(self) -> list[str]:
|
||||
"""列出可用模型 id(升序)。"""
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._http_client is not None:
|
||||
await self._http_client.aclose()
|
||||
|
||||
# ------------------------------------------------------------ auth hooks
|
||||
# 仅 oauth / device_code 适配器覆写;api_key 类默认不支持。
|
||||
|
||||
async def begin_auth(self) -> AuthChallenge:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def complete_auth(self, state: str, user_input: str = "") -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support interactive auth")
|
||||
|
||||
async def refresh(self, tokens: TokenSet) -> TokenSet:
|
||||
raise NotImplementedError(f"{self.info.id} does not support token refresh")
|
||||
|
||||
async def revoke(self, tokens: TokenSet) -> None:
|
||||
"""注销远端凭据,尽力而为;默认无操作。"""
|
||||
__all__ = [
|
||||
"AdapterContext",
|
||||
"AuthChallenge",
|
||||
"AuthExpiredError",
|
||||
"LLMProviderAdapter",
|
||||
"ProviderInfo",
|
||||
"TokenSet",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件的统一入口。
|
||||
"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件 + 扩展插件的统一入口。
|
||||
|
||||
扩展插件指通过 ``ab_sdk`` 以 ``points.LLM_PROVIDER`` 登记的适配器(随插件启停,
|
||||
不缓存);已安装插件指旧的签名 LLM 插件目录(config/plugins/<id>/<版本>/)。
|
||||
|
||||
- ``list_infos()`` 只读静态描述(内置/预设直接读 info;插件读 manifest,
|
||||
首次不触发插件 Python 导入);
|
||||
@@ -11,6 +14,8 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ab_sdk import points
|
||||
|
||||
from .base import LLMProviderAdapter, ProviderInfo
|
||||
from .builtin import BUILTIN
|
||||
from .presets import PRESET_ADAPTERS
|
||||
@@ -69,8 +74,31 @@ class ProviderRegistry:
|
||||
plugin_version=manifest.get("version"),
|
||||
)
|
||||
)
|
||||
known = {info.id for info in infos}
|
||||
for adapter_cls in self._extension_adapters().values():
|
||||
if adapter_cls.info.id not in known:
|
||||
infos.append(adapter_cls.info.model_copy(update={"builtin": False}))
|
||||
return infos
|
||||
|
||||
@staticmethod
|
||||
def _extension_adapters() -> dict[str, type[LLMProviderAdapter]]:
|
||||
"""插件以 points.LLM_PROVIDER 登记的适配器类,按 id 索引。"""
|
||||
from module.plugin.host import get_registry
|
||||
|
||||
result: dict[str, type[LLMProviderAdapter]] = {}
|
||||
for provider_id, entry in get_registry().providers(points.LLM_PROVIDER).items():
|
||||
adapter_cls = entry.factory()
|
||||
if adapter_cls.info.id != provider_id:
|
||||
logger.warning(
|
||||
"LLM provider %s from plugin %s declares info.id=%s; skipped",
|
||||
provider_id,
|
||||
entry.plugin_id,
|
||||
adapter_cls.info.id,
|
||||
)
|
||||
continue
|
||||
result[provider_id] = adapter_cls
|
||||
return result
|
||||
|
||||
def resolve(self, provider_id: str) -> type[LLMProviderAdapter]:
|
||||
"""按 id 解析适配器类;未知 id 抛 ValueError。"""
|
||||
adapter_cls = BUILTIN.get(provider_id) or PRESET_ADAPTERS.get(provider_id)
|
||||
@@ -86,6 +114,9 @@ class ProviderRegistry:
|
||||
loaded = load_adapter_class(version_dir, manifest)
|
||||
self._plugin_cache[provider_id] = loaded
|
||||
return loaded
|
||||
extension = self._extension_adapters().get(provider_id)
|
||||
if extension is not None:
|
||||
return extension
|
||||
raise ValueError(f"Unsupported LLM provider: {provider_id}")
|
||||
|
||||
def invalidate(self, provider_id: str | None = None) -> None:
|
||||
|
||||
90
backend/src/module/plugin/host.py
Normal file
90
backend/src/module/plugin/host.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""进程级扩展注册表,以及宿主自带实现的登记。
|
||||
|
||||
AB 自己的下载器、通知渠道与搜索站点和第三方插件走同一个注册表、同一套
|
||||
``ab_sdk`` 契约,只是以 ``core`` 身份登记:它们随进程存在,不经插件管理器
|
||||
加载,因此不能被禁用,也不会因插件故障被熔断。
|
||||
|
||||
注册表在首次访问时初始化,不依赖 AppContext——下载门面等模块级代码在
|
||||
任何上下文里都能解析 Provider。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ab_sdk import points
|
||||
from ab_sdk.downloader import DownloaderConnection
|
||||
|
||||
from .registry import ExtensionPoint, ExtensionRegistry, ProviderEntry
|
||||
|
||||
CORE = "core"
|
||||
|
||||
POINTS = (
|
||||
ExtensionPoint(points.DOWNLOADER, "provider", "下载器后端(downloader.type)"),
|
||||
ExtensionPoint(
|
||||
points.NOTIFIER, "provider", "通知渠道(notification.providers[].type)"
|
||||
),
|
||||
ExtensionPoint(points.LLM_PROVIDER, "provider", "LLM 解析提供商(llm.provider)"),
|
||||
ExtensionPoint(points.SEARCH_SITE, "provider", "搜索站点"),
|
||||
ExtensionPoint(points.SCHEDULED_TASK, "provider", "定时任务"),
|
||||
)
|
||||
|
||||
_registry: ExtensionRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> ExtensionRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
registry = ExtensionRegistry()
|
||||
for point in POINTS:
|
||||
registry.declare(point)
|
||||
_register_core(registry)
|
||||
_registry = registry
|
||||
return _registry
|
||||
|
||||
|
||||
def provider(point: str, provider_id: str) -> Any | None:
|
||||
"""调用 Provider 工厂取得实现;未登记时返回 None。"""
|
||||
entry = get_registry().providers(point).get(provider_id)
|
||||
return entry.factory() if entry is not None else None
|
||||
|
||||
|
||||
def plugin_provider_ids(point: str) -> list[str]:
|
||||
"""由插件(而非 core)提供的 Provider id,供设置页合并到候选列表。"""
|
||||
return sorted(
|
||||
pid
|
||||
for pid, entry in get_registry().providers(point).items()
|
||||
if entry.plugin_id != CORE
|
||||
)
|
||||
|
||||
|
||||
def _core(registry: ExtensionRegistry, point: str, provider_id: str, impl: Any) -> None:
|
||||
registry.add_provider(point, ProviderEntry(CORE, provider_id, lambda: impl))
|
||||
|
||||
|
||||
def _register_core(registry: ExtensionRegistry) -> None:
|
||||
# 具体下载器延迟到实际使用时再 import,与原先 if/elif 的懒加载一致
|
||||
def qbittorrent(conn: DownloaderConnection):
|
||||
from module.downloader.client.qb_downloader import QbDownloader
|
||||
|
||||
return QbDownloader(conn.host, conn.username, conn.password, conn.ssl)
|
||||
|
||||
def aria2(conn: DownloaderConnection):
|
||||
from module.downloader.client.aria2_downloader import Aria2Downloader
|
||||
|
||||
return Aria2Downloader(conn.host, conn.username, conn.password)
|
||||
|
||||
def mock(conn: DownloaderConnection):
|
||||
from module.downloader.client.mock_downloader import MockDownloader
|
||||
|
||||
return MockDownloader()
|
||||
|
||||
for provider_id, factory in (
|
||||
("qbittorrent", qbittorrent),
|
||||
("aria2", aria2),
|
||||
("mock", mock),
|
||||
):
|
||||
_core(registry, points.DOWNLOADER, provider_id, factory)
|
||||
|
||||
from module.notification.providers import PROVIDER_REGISTRY
|
||||
|
||||
for provider_id, provider_cls in PROVIDER_REGISTRY.items():
|
||||
_core(registry, points.NOTIFIER, provider_id, provider_cls)
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@@ -39,6 +39,10 @@ class PluginStatus:
|
||||
description: str = ""
|
||||
permissions: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
enabled: bool = False
|
||||
# config_model 的 JSON Schema;插件代码加载过一次后才有(不为取 schema
|
||||
# 去执行未启用插件的代码),未声明 config_model 时为 None
|
||||
config_schema: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -53,6 +57,7 @@ class PluginManager:
|
||||
self,
|
||||
settings_obj,
|
||||
*,
|
||||
registry: ExtensionRegistry | None = None,
|
||||
discover_fn: Discover = discover,
|
||||
setup_timeout: float = DEFAULT_SETUP_TIMEOUT,
|
||||
data_root: Path = PLUGIN_DATA_ROOT,
|
||||
@@ -61,7 +66,7 @@ class PluginManager:
|
||||
self._discover = discover_fn
|
||||
self._setup_timeout = setup_timeout
|
||||
self._data_root = data_root
|
||||
self.registry = ExtensionRegistry()
|
||||
self.registry = registry if registry is not None else ExtensionRegistry()
|
||||
self.breaker = CircuitBreaker(on_trip=self._on_trip)
|
||||
self.bus = EventBus(on_error=self.breaker.record_failure)
|
||||
self.runner = HookRunner(
|
||||
@@ -74,8 +79,11 @@ class PluginManager:
|
||||
# 失败记录:(失败时的配置快照, 原因)。配置未变时不重试,避免每次保存
|
||||
# 设置都把同一个坏插件重新加载一遍。
|
||||
self._failed: dict[str, tuple[str, str]] = {}
|
||||
self._models: dict[str, type[BaseModel] | None] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._pending: set[asyncio.Task] = set()
|
||||
# 插件集合变化后的回调(宿主据此同步插件定时任务等派生状态)
|
||||
self.on_change: Callable[[], Awaitable[None]] | None = None
|
||||
|
||||
# ------------------------------------------------------------ lifecycle
|
||||
|
||||
@@ -90,6 +98,7 @@ class PluginManager:
|
||||
len(self._active),
|
||||
len(self._candidates),
|
||||
)
|
||||
await self._notify_change()
|
||||
|
||||
async def stop(self) -> None:
|
||||
for task in list(self._pending):
|
||||
@@ -119,6 +128,7 @@ class PluginManager:
|
||||
if failed is not None and failed[0] == self._snapshot(plugin_id):
|
||||
continue
|
||||
await self._activate(candidate)
|
||||
await self._notify_change()
|
||||
|
||||
# ------------------------------------------------------------ status
|
||||
|
||||
@@ -145,12 +155,28 @@ class PluginManager:
|
||||
description=manifest.description,
|
||||
permissions=list(manifest.permissions),
|
||||
error=error,
|
||||
enabled=self._enabled(candidate),
|
||||
config_schema=self._schema(plugin_id),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def validate_options(self, plugin_id: str, options: dict[str, Any]) -> None:
|
||||
"""按插件的 config_model 校验配置(插件代码未加载过时不校验)。
|
||||
|
||||
Raises:
|
||||
pydantic.ValidationError: 配置不合法。
|
||||
"""
|
||||
model = self._models.get(plugin_id)
|
||||
if model is not None:
|
||||
model.model_validate(options)
|
||||
|
||||
# ------------------------------------------------------------ internals
|
||||
|
||||
def _schema(self, plugin_id: str) -> dict[str, Any] | None:
|
||||
model = self._models.get(plugin_id)
|
||||
return model.model_json_schema() if model is not None else None
|
||||
|
||||
def _rediscover(self) -> None:
|
||||
# 清单错误已在 discover() 中记录日志
|
||||
candidates, _ = self._discover()
|
||||
@@ -161,13 +187,17 @@ class PluginManager:
|
||||
def _blocked_reason(self, candidate: PluginCandidate) -> str | None:
|
||||
"""插件不应运行的原因;None 表示应启用。"""
|
||||
conf = self._settings.plugins
|
||||
default = candidate.source == "builtin"
|
||||
if not conf.enabled.get(candidate.manifest.id, default):
|
||||
if not self._enabled(candidate):
|
||||
return "未启用"
|
||||
if not candidate.signed and not conf.allow_unsigned:
|
||||
return "未签名插件需要开启 plugins.allow_unsigned"
|
||||
return None
|
||||
|
||||
def _enabled(self, candidate: PluginCandidate) -> bool:
|
||||
"""启用开关;未设置时内置插件默认启用,其它来源默认禁用。"""
|
||||
default = candidate.source == "builtin"
|
||||
return self._settings.plugins.enabled.get(candidate.manifest.id, default)
|
||||
|
||||
def _snapshot(self, plugin_id: str) -> str:
|
||||
conf = self._settings.plugins
|
||||
return json.dumps(
|
||||
@@ -189,6 +219,7 @@ class PluginManager:
|
||||
f"需要 ab_sdk {manifest.sdk},当前为 {SDK_VERSION}"
|
||||
)
|
||||
cls = candidate.load()
|
||||
self._models[plugin_id] = cls.config_model
|
||||
config = self._validate_config(cls, plugin_id)
|
||||
ctx = HostPluginContext(plugin_id, config, self.bus, self._data_root)
|
||||
instance = cls(ctx)
|
||||
@@ -277,3 +308,12 @@ class PluginManager:
|
||||
self._failed[plugin_id] = (active.snapshot, reason)
|
||||
logger.error("[Plugin:%s] %s", plugin_id, reason)
|
||||
self.bus.publish(PluginDisabled(plugin_id=plugin_id, reason=reason))
|
||||
await self._notify_change()
|
||||
|
||||
async def _notify_change(self) -> None:
|
||||
if self.on_change is None:
|
||||
return
|
||||
try:
|
||||
await self.on_change()
|
||||
except Exception:
|
||||
logger.exception("[Plugin] 插件变更回调失败")
|
||||
|
||||
43
backend/src/module/plugin/secrets.py
Normal file
43
backend/src/module/plugin/secrets.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""插件配置中秘密字段的掩码与还原。
|
||||
|
||||
插件用 ``ab_sdk.secret_field()`` 声明秘密字段(JSON Schema 中带 ``secret``
|
||||
标记,或 ``format: password`` / ``writeOnly``)。读接口返回掩码,写接口收到
|
||||
掩码时还原为已保存的值,与 /config 对密码类字段的处理一致。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
MASK = "********"
|
||||
|
||||
|
||||
def secret_keys(schema: dict[str, Any] | None) -> set[str]:
|
||||
props = (schema or {}).get("properties", {})
|
||||
return {
|
||||
key
|
||||
for key, prop in props.items()
|
||||
if prop.get("secret")
|
||||
or prop.get("writeOnly")
|
||||
or prop.get("format") == "password"
|
||||
}
|
||||
|
||||
|
||||
def mask_options(
|
||||
options: dict[str, Any], schema: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
masked = dict(options)
|
||||
for key in secret_keys(schema):
|
||||
if isinstance(masked.get(key), str) and masked[key]:
|
||||
masked[key] = MASK
|
||||
return masked
|
||||
|
||||
|
||||
def restore_options(
|
||||
incoming: dict[str, Any], current: dict[str, Any], schema: dict[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
for key in secret_keys(schema):
|
||||
if incoming.get(key) == MASK:
|
||||
if key in current:
|
||||
incoming[key] = current[key]
|
||||
else:
|
||||
incoming.pop(key)
|
||||
return incoming
|
||||
@@ -1,2 +1,2 @@
|
||||
from .provider import SEARCH_CONFIG
|
||||
from .provider import available_sites
|
||||
from .searcher import SearchTorrent
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import re
|
||||
|
||||
# SEARCH_CONFIG is re-exported for existing importers (module.searcher.__init__);
|
||||
# search_url itself reads get_provider() below so provider updates saved via
|
||||
# save_provider() take effect immediately instead of only after a restart.
|
||||
from module.conf.search_provider import SEARCH_CONFIG, get_provider # noqa: F401
|
||||
from ab_sdk import points
|
||||
from ab_sdk.search import SearchSite
|
||||
from module.conf.search_provider import ProviderConfig, get_provider
|
||||
from module.models import RSSItem
|
||||
from module.plugin.host import get_registry
|
||||
|
||||
|
||||
def available_sites() -> dict[str, ProviderConfig]:
|
||||
"""可用搜索站点:插件提供的站点 + 用户配置的站点(同名时用户配置优先)。
|
||||
|
||||
每次调用都重新读取,用户保存的站点与插件启停都能立即生效。
|
||||
"""
|
||||
sites: dict[str, ProviderConfig] = {}
|
||||
for site_id, entry in get_registry().providers(points.SEARCH_SITE).items():
|
||||
site: SearchSite = entry.factory()
|
||||
sites[site_id] = {"url": site.url, "parser": site.parser}
|
||||
sites.update(get_provider())
|
||||
return sites
|
||||
|
||||
|
||||
def search_url(site: str, keywords: list[str]) -> RSSItem:
|
||||
keyword = "+".join(keywords)
|
||||
search_str = re.sub(r"[\W_ ]", "+", keyword)
|
||||
providers = get_provider()
|
||||
providers = available_sites()
|
||||
if site in providers:
|
||||
# str.replace, not re.sub: the template is a literal "%s" placeholder,
|
||||
# and search_str may itself contain regex-special characters.
|
||||
|
||||
@@ -395,7 +395,7 @@ class TestSubscribe:
|
||||
new_callable=AsyncMock,
|
||||
return_value=resp_model,
|
||||
) as mock_subscribe,
|
||||
patch("module.api.rss.get_provider", return_value=self._PROVIDERS),
|
||||
patch("module.api.rss.available_sites", return_value=self._PROVIDERS),
|
||||
):
|
||||
response = authed_client.post(
|
||||
"/api/v1/rss/subscribe",
|
||||
@@ -420,7 +420,7 @@ class TestSubscribe:
|
||||
new_callable=AsyncMock,
|
||||
return_value=resp_model,
|
||||
) as mock_subscribe,
|
||||
patch("module.api.rss.get_provider", return_value=self._PROVIDERS),
|
||||
patch("module.api.rss.available_sites", return_value=self._PROVIDERS),
|
||||
):
|
||||
response = authed_client.post(
|
||||
"/api/v1/rss/subscribe",
|
||||
@@ -452,7 +452,7 @@ class TestSubscribe:
|
||||
new_callable=AsyncMock,
|
||||
return_value=resp_model,
|
||||
) as mock_subscribe,
|
||||
patch("module.api.rss.get_provider", return_value=customized),
|
||||
patch("module.api.rss.available_sites", return_value=customized),
|
||||
):
|
||||
response = authed_client.post(
|
||||
"/api/v1/rss/subscribe",
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestSearchProvider:
|
||||
def test_get_provider_list(self, authed_client):
|
||||
"""GET /search/provider returns list of available providers."""
|
||||
mock_config = {"mikan": "url1", "dmhy": "url2", "nyaa": "url3"}
|
||||
with patch("module.api.search.SEARCH_CONFIG", mock_config):
|
||||
with patch("module.api.search.available_sites", return_value=mock_config):
|
||||
response = authed_client.get("/api/v1/search/provider")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
440
backend/src/test/test_plugin_extensions.py
Normal file
440
backend/src/test/test_plugin_extensions.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""P2 扩展点:下载器、通知渠道、LLM、搜索站点、定时任务与插件管理 API。"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import ClassVar
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ab_sdk import points, secret_field
|
||||
from ab_sdk.downloader import (
|
||||
AddResult,
|
||||
DownloaderCapabilities,
|
||||
DownloaderConnection,
|
||||
)
|
||||
from ab_sdk.llm import LLMProviderAdapter, ProviderInfo
|
||||
from ab_sdk.notify import NotificationMessage, NotifierSettings
|
||||
from ab_sdk.search import SearchSite
|
||||
from ab_sdk.tasks import ScheduledTask
|
||||
from module.conf import settings
|
||||
from module.core.plugin_tasks import PluginTasks
|
||||
from module.core.scheduler import Scheduler
|
||||
from module.models.bangumi import Notification
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.models.config import Plugins
|
||||
from module.plugin import host
|
||||
from module.plugin.registry import ProviderEntry
|
||||
from module.plugin.runner import CircuitBreaker
|
||||
from module.plugin.secrets import MASK, mask_options, restore_options
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(monkeypatch):
|
||||
"""每个测试一个全新的进程级注册表(含 core 登记)。"""
|
||||
monkeypatch.setattr(host, "_registry", None)
|
||||
return host.get_registry()
|
||||
|
||||
|
||||
def add_plugin_provider(registry, point, provider_id, impl, plugin_id="ext"):
|
||||
registry.add_provider(point, ProviderEntry(plugin_id, provider_id, lambda: impl))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- core
|
||||
|
||||
|
||||
def test_core_providers_registered(registry):
|
||||
downloaders = registry.providers(points.DOWNLOADER)
|
||||
assert set(downloaders) == {"qbittorrent", "aria2", "mock"}
|
||||
assert all(e.plugin_id == host.CORE for e in downloaders.values())
|
||||
assert {"telegram", "bark", "webhook"} <= set(registry.providers(points.NOTIFIER))
|
||||
assert host.plugin_provider_ids(points.DOWNLOADER) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- downloader
|
||||
|
||||
|
||||
class FakeDownloader:
|
||||
capabilities: ClassVar = DownloaderCapabilities(False, False, False, False)
|
||||
|
||||
def __init__(self, conn: DownloaderConnection) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def auth(self, retry: int = 3) -> bool:
|
||||
return True
|
||||
|
||||
async def logout(self) -> None:
|
||||
return None
|
||||
|
||||
async def add_torrents(self, *args, **kwargs) -> AddResult:
|
||||
return AddResult.ADDED
|
||||
|
||||
|
||||
class TestDownloader:
|
||||
def test_plugin_downloader_is_used_by_facade(self, registry, monkeypatch):
|
||||
from module.downloader import DownloadClient
|
||||
|
||||
add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader)
|
||||
monkeypatch.setattr(settings.downloader, "type", "fake")
|
||||
monkeypatch.setattr(settings.downloader, "host_", "example:1234")
|
||||
client = DownloadClient()
|
||||
assert isinstance(client.client, FakeDownloader)
|
||||
assert client.client.conn.host == "example:1234"
|
||||
assert host.plugin_provider_ids(points.DOWNLOADER) == ["fake"]
|
||||
|
||||
def test_unknown_type_raises(self, registry, monkeypatch):
|
||||
from module.downloader import DownloadClient
|
||||
|
||||
monkeypatch.setattr(settings.downloader, "type", "nope")
|
||||
with pytest.raises(Exception, match="Unsupported downloader type"):
|
||||
DownloadClient()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- notifier
|
||||
|
||||
|
||||
class RecordingNotifier:
|
||||
def __init__(self, conf: NotifierSettings) -> None:
|
||||
self.conf = conf
|
||||
self.sent: list[NotificationMessage] = []
|
||||
|
||||
async def send(self, message: NotificationMessage) -> bool:
|
||||
self.sent.append(message)
|
||||
return True
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
return True, f"ok:{self.conf.extra.get('room')}"
|
||||
|
||||
|
||||
class TestNotifier:
|
||||
async def test_plugin_notifier_receives_rendered_messages(self, registry):
|
||||
from module.notification.resolve import PluginNotifier, build_provider
|
||||
|
||||
created: list[RecordingNotifier] = []
|
||||
|
||||
def factory(conf: NotifierSettings) -> RecordingNotifier:
|
||||
created.append(RecordingNotifier(conf))
|
||||
return created[-1]
|
||||
|
||||
add_plugin_provider(registry, points.NOTIFIER, "chat", factory)
|
||||
cfg = ProviderConfig.model_validate(
|
||||
{"type": "chat", "template": "{{title}} E{{episode}}", "room": "42"}
|
||||
)
|
||||
provider = build_provider(cfg)
|
||||
assert isinstance(provider, PluginNotifier)
|
||||
[notifier] = created
|
||||
assert notifier.conf.extra == {"room": "42"}
|
||||
|
||||
async with provider:
|
||||
await provider.send(
|
||||
Notification(official_title="Show", season=1, episode=3)
|
||||
)
|
||||
await provider._deliver_text("RSS 失败", "detail")
|
||||
assert await provider.test() == (True, "ok:42")
|
||||
episode, event = notifier.sent
|
||||
assert episode.kind == "episode" and episode.body == "Show E3"
|
||||
assert episode.episode == 3 and episode.poster_url is None
|
||||
assert (event.kind, event.title, event.body) == ("event", "RSS 失败", "detail")
|
||||
|
||||
def test_core_provider_still_built_from_class(self, registry):
|
||||
from module.notification.providers import TelegramProvider
|
||||
from module.notification.resolve import build_provider
|
||||
|
||||
provider = build_provider(ProviderConfig(type="telegram", token="t"))
|
||||
assert isinstance(provider, TelegramProvider)
|
||||
|
||||
def test_manager_skips_unknown_and_loads_plugin(self, registry, monkeypatch):
|
||||
from module.notification import NotificationManager
|
||||
|
||||
add_plugin_provider(registry, points.NOTIFIER, "chat", RecordingNotifier)
|
||||
monkeypatch.setattr(
|
||||
settings.notification,
|
||||
"providers",
|
||||
[ProviderConfig(type="chat"), ProviderConfig(type="missing")],
|
||||
)
|
||||
assert len(NotificationManager()) == 1
|
||||
|
||||
def test_extra_fields_survive_config_roundtrip(self):
|
||||
cfg = ProviderConfig.model_validate({"type": "chat", "room": "42"})
|
||||
assert cfg.model_dump(by_alias=True)["room"] == "42"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- LLM
|
||||
|
||||
|
||||
def make_adapter(adapter_id: str):
|
||||
class Adapter(LLMProviderAdapter):
|
||||
info = ProviderInfo(id=adapter_id, display_name="Ext LLM")
|
||||
|
||||
async def parse(self, raw: str) -> dict | None:
|
||||
return None
|
||||
|
||||
async def list_models(self) -> list[str]:
|
||||
return []
|
||||
|
||||
return Adapter
|
||||
|
||||
|
||||
class TestLLMProvider:
|
||||
def test_plugin_adapter_listed_and_resolvable(self, registry):
|
||||
from module.parser.analyser.providers.registry import ProviderRegistry
|
||||
|
||||
adapter = make_adapter("ext-llm")
|
||||
add_plugin_provider(registry, points.LLM_PROVIDER, "ext-llm", adapter)
|
||||
llm_registry = ProviderRegistry()
|
||||
assert llm_registry.resolve("ext-llm") is adapter
|
||||
info = next(i for i in llm_registry.list_infos() if i.id == "ext-llm")
|
||||
assert info.builtin is False
|
||||
|
||||
def test_mismatched_id_is_skipped(self, registry):
|
||||
from module.parser.analyser.providers.registry import ProviderRegistry
|
||||
|
||||
add_plugin_provider(
|
||||
registry, points.LLM_PROVIDER, "claimed", make_adapter("other")
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
ProviderRegistry().resolve("claimed")
|
||||
|
||||
def test_builtin_cannot_be_shadowed(self, registry):
|
||||
from module.parser.analyser.providers.builtin import BUILTIN
|
||||
from module.parser.analyser.providers.registry import ProviderRegistry
|
||||
|
||||
add_plugin_provider(
|
||||
registry, points.LLM_PROVIDER, "openai", make_adapter("openai")
|
||||
)
|
||||
assert ProviderRegistry().resolve("openai") is BUILTIN["openai"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- search
|
||||
|
||||
|
||||
class TestSearchSite:
|
||||
def test_plugin_site_merged_and_user_config_wins(self, registry):
|
||||
from module.searcher import available_sites
|
||||
from module.searcher.provider import search_url
|
||||
|
||||
add_plugin_provider(
|
||||
registry, points.SEARCH_SITE, "acg", SearchSite("https://acg/?q=%s")
|
||||
)
|
||||
add_plugin_provider(
|
||||
registry, points.SEARCH_SITE, "mikan", SearchSite("https://evil/%s")
|
||||
)
|
||||
user = {"mikan": {"url": "https://mikanani.me/?q=%s", "parser": "mikan"}}
|
||||
with patch("module.searcher.provider.get_provider", return_value=user):
|
||||
sites = available_sites()
|
||||
item = search_url("acg", ["Frieren", "S2"])
|
||||
assert sites["mikan"]["url"] == "https://mikanani.me/?q=%s"
|
||||
assert sites["acg"] == {"url": "https://acg/?q=%s", "parser": "tmdb"}
|
||||
assert item.url == "https://acg/?q=Frieren+S2"
|
||||
assert item.parser == "tmdb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- tasks
|
||||
|
||||
|
||||
class TestPluginTasks:
|
||||
def make(self, registry, threshold=5):
|
||||
scheduler = Scheduler([])
|
||||
tripped: list[str] = []
|
||||
breaker = CircuitBreaker(threshold, on_trip=lambda p, r: tripped.append(p))
|
||||
return scheduler, PluginTasks(scheduler, registry, breaker), tripped
|
||||
|
||||
async def test_task_added_started_and_removed(self, registry):
|
||||
scheduler, bridge, _ = self.make(registry)
|
||||
ran = asyncio.Event()
|
||||
|
||||
async def run():
|
||||
ran.set()
|
||||
|
||||
entry = ProviderEntry("ext", "sync", lambda: ScheduledTask(run, 3600))
|
||||
registry.add_provider(points.SCHEDULED_TASK, entry)
|
||||
scheduler.start_all()
|
||||
await bridge.sync()
|
||||
[task] = scheduler.tasks
|
||||
assert task.name == "plugin:ext:sync" and task.running
|
||||
await asyncio.wait_for(ran.wait(), 1)
|
||||
|
||||
registry.remove_plugin("ext")
|
||||
await bridge.sync()
|
||||
assert scheduler.tasks == [] and not task.running
|
||||
await scheduler.stop_all()
|
||||
|
||||
async def test_reloaded_plugin_replaces_task(self, registry):
|
||||
scheduler, bridge, _ = self.make(registry)
|
||||
spec = ScheduledTask(AsyncMock(), 60)
|
||||
registry.add_provider(
|
||||
points.SCHEDULED_TASK, ProviderEntry("ext", "sync", lambda: spec)
|
||||
)
|
||||
await bridge.sync()
|
||||
[first] = scheduler.tasks
|
||||
registry.remove_plugin("ext")
|
||||
registry.add_provider(
|
||||
points.SCHEDULED_TASK, ProviderEntry("ext", "sync", lambda: spec)
|
||||
)
|
||||
await bridge.sync()
|
||||
[second] = scheduler.tasks
|
||||
assert first is not second
|
||||
|
||||
async def test_failures_count_toward_breaker(self, registry):
|
||||
scheduler, bridge, tripped = self.make(registry, threshold=2)
|
||||
|
||||
async def boom():
|
||||
raise RuntimeError("bad")
|
||||
|
||||
registry.add_provider(
|
||||
points.SCHEDULED_TASK,
|
||||
ProviderEntry("ext", "sync", lambda: ScheduledTask(boom, 0.01)),
|
||||
)
|
||||
await bridge.sync()
|
||||
scheduler.start_all()
|
||||
for _ in range(100):
|
||||
if tripped:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
await scheduler.stop_all()
|
||||
assert tripped == ["ext"]
|
||||
|
||||
async def test_factory_failure_is_skipped(self, registry):
|
||||
scheduler, bridge, _ = self.make(registry, threshold=1)
|
||||
|
||||
def broken():
|
||||
raise RuntimeError("no")
|
||||
|
||||
registry.add_provider(
|
||||
points.SCHEDULED_TASK, ProviderEntry("ext", "sync", broken)
|
||||
)
|
||||
await bridge.sync()
|
||||
assert scheduler.tasks == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- secrets
|
||||
|
||||
|
||||
class SecretOptions(BaseModel):
|
||||
site: str = ""
|
||||
cookie: str = secret_field(description="站点 Cookie")
|
||||
|
||||
|
||||
SCHEMA = SecretOptions.model_json_schema()
|
||||
|
||||
|
||||
class TestSecrets:
|
||||
def test_mask_and_restore(self):
|
||||
masked = mask_options({"site": "a", "cookie": "c=1"}, SCHEMA)
|
||||
assert masked == {"site": "a", "cookie": MASK}
|
||||
restored = restore_options(dict(masked), {"cookie": "c=1"}, SCHEMA)
|
||||
assert restored == {"site": "a", "cookie": "c=1"}
|
||||
assert restore_options({"cookie": MASK}, {}, SCHEMA) == {}
|
||||
assert mask_options({"cookie": ""}, SCHEMA) == {"cookie": ""}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- API
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_ctx(app, monkeypatch):
|
||||
from module.api.deps import get_context
|
||||
from module.plugin.manager import PluginStatus
|
||||
|
||||
monkeypatch.setattr(settings, "plugins", Plugins())
|
||||
status = PluginStatus(
|
||||
id="demo",
|
||||
name="Demo",
|
||||
version="1.0.0",
|
||||
source="local",
|
||||
signed=False,
|
||||
state="active",
|
||||
enabled=True,
|
||||
config_schema=SCHEMA,
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager.statuses.return_value = [status]
|
||||
manager.validate_options.side_effect = lambda _pid, opts: (
|
||||
SecretOptions.model_validate(opts)
|
||||
)
|
||||
ctx = SimpleNamespace(plugins=manager)
|
||||
app.dependency_overrides[get_context] = lambda: ctx
|
||||
save = AsyncMock()
|
||||
monkeypatch.setattr("module.api.plugins._save_and_apply", save)
|
||||
return ctx, save
|
||||
|
||||
|
||||
class TestPluginsApi:
|
||||
def test_overview_masks_secret_options(self, authed_client, plugin_ctx):
|
||||
settings.plugins.options["demo"] = {"site": "a", "cookie": "c=1"}
|
||||
data = authed_client.get("/api/v1/plugins").json()
|
||||
assert data["allow_unsigned"] is False
|
||||
[plugin] = data["plugins"]
|
||||
assert plugin["options"] == {"site": "a", "cookie": MASK}
|
||||
assert plugin["config_schema"]["properties"]["cookie"]["secret"] is True
|
||||
|
||||
def test_update_restores_mask_and_saves(self, authed_client, plugin_ctx):
|
||||
_, save = plugin_ctx
|
||||
settings.plugins.options["demo"] = {"site": "a", "cookie": "c=1"}
|
||||
response = authed_client.put(
|
||||
"/api/v1/plugins/demo",
|
||||
json={"enabled": False, "options": {"site": "b", "cookie": MASK}},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert settings.plugins.options["demo"] == {"site": "b", "cookie": "c=1"}
|
||||
assert settings.plugins.enabled["demo"] is False
|
||||
save.assert_awaited_once()
|
||||
|
||||
def test_update_rejects_invalid_options(self, authed_client, plugin_ctx):
|
||||
_, save = plugin_ctx
|
||||
response = authed_client.put(
|
||||
"/api/v1/plugins/demo", json={"options": {"site": ["not", "str"]}}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
save.assert_not_awaited()
|
||||
assert "demo" not in settings.plugins.options
|
||||
|
||||
def test_update_unknown_plugin(self, authed_client, plugin_ctx):
|
||||
assert authed_client.put("/api/v1/plugins/x", json={}).status_code == 404
|
||||
|
||||
def test_allow_unsigned(self, authed_client, plugin_ctx):
|
||||
response = authed_client.put(
|
||||
"/api/v1/plugins/settings", json={"allow_unsigned": True}
|
||||
)
|
||||
assert response.json()["allow_unsigned"] is True
|
||||
assert settings.plugins.allow_unsigned is True
|
||||
|
||||
def test_providers(self, authed_client, plugin_ctx, registry):
|
||||
add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader)
|
||||
data = authed_client.get("/api/v1/plugins/providers").json()
|
||||
assert data == {"downloader": ["fake"], "notifier": [], "search_site": []}
|
||||
|
||||
def test_config_get_masks_plugin_secrets(self, authed_client, plugin_ctx):
|
||||
settings.plugins.options["demo"] = {"site": "a", "cookie": "c=1"}
|
||||
data = authed_client.get("/api/v1/config/get").json()
|
||||
assert data["plugins"]["options"]["demo"] == {"site": "a", "cookie": MASK}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- regressions
|
||||
|
||||
|
||||
class TestPluginChangeRebuildsState:
|
||||
async def test_on_change_rebuilds_notifier_and_syncs_tasks(self, registry):
|
||||
from module.core import AppContext
|
||||
|
||||
ctx = AppContext.build(settings)
|
||||
ctx.notifier = MagicMock()
|
||||
with patch("module.core.context.reset_llm_parser") as reset_llm:
|
||||
await ctx.plugins.on_change() # type: ignore[misc]
|
||||
ctx.notifier.rebuild.assert_called_once()
|
||||
reset_llm.assert_called_once()
|
||||
|
||||
def test_reloaded_plugin_downloader_retires_cached_client(
|
||||
self, registry, monkeypatch
|
||||
):
|
||||
from module.downloader import DownloadClient
|
||||
|
||||
add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader)
|
||||
monkeypatch.setattr(settings.downloader, "type", "fake")
|
||||
first = DownloadClient().client
|
||||
assert DownloadClient().client is first
|
||||
# 插件重载:同 id 的新登记项
|
||||
registry.remove_plugin("ext")
|
||||
add_plugin_provider(registry, points.DOWNLOADER, "fake", FakeDownloader)
|
||||
assert DownloadClient().client is not first
|
||||
@@ -702,8 +702,9 @@ class TestManager:
|
||||
|
||||
|
||||
class TestPluginsApi:
|
||||
def test_list_plugins(self, app, authed_client, tmp_path):
|
||||
def test_list_plugins(self, app, authed_client, monkeypatch):
|
||||
from module.api.deps import get_context
|
||||
from module.conf import settings
|
||||
from module.plugin.manager import PluginStatus
|
||||
|
||||
status = PluginStatus(
|
||||
@@ -716,10 +717,11 @@ class TestPluginsApi:
|
||||
error="未签名插件需要开启 plugins.allow_unsigned",
|
||||
)
|
||||
ctx = SimpleNamespace(plugins=SimpleNamespace(statuses=lambda: [status]))
|
||||
monkeypatch.setattr(settings, "plugins", Plugins())
|
||||
app.dependency_overrides[get_context] = lambda: ctx
|
||||
response = authed_client.get("/api/v1/plugins")
|
||||
assert response.status_code == 200
|
||||
[item] = response.json()
|
||||
[item] = response.json()["plugins"]
|
||||
assert item["id"] == "demo"
|
||||
assert item["state"] == "disabled"
|
||||
assert item["signed"] is False
|
||||
|
||||
@@ -15,16 +15,28 @@ from module.searcher.provider import search_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
MIKAN_SITE = {"url": "https://mikanani.me/RSS/Search?searchstr=%s", "parser": "mikan"}
|
||||
|
||||
|
||||
class TestSearchUrl:
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_search_config(self):
|
||||
"""Ensure SEARCH_CONFIG has default providers."""
|
||||
config = {
|
||||
"mikan": "https://mikanani.me/RSS/Search?searchstr=%s",
|
||||
"nyaa": "https://nyaa.si/?page=rss&q=%s&c=0_0&f=0",
|
||||
"dmhy": "http://dmhy.org/topics/rss/rss.xml?keyword=%s",
|
||||
"mikan": {
|
||||
"url": "https://mikanani.me/RSS/Search?searchstr=%s",
|
||||
"parser": "mikan",
|
||||
},
|
||||
"nyaa": {
|
||||
"url": "https://nyaa.si/?page=rss&q=%s&c=0_0&f=0",
|
||||
"parser": "tmdb",
|
||||
},
|
||||
"dmhy": {
|
||||
"url": "http://dmhy.org/topics/rss/rss.xml?keyword=%s",
|
||||
"parser": "tmdb",
|
||||
},
|
||||
}
|
||||
with patch("module.searcher.provider.SEARCH_CONFIG", config):
|
||||
with patch("module.searcher.provider.get_provider", return_value=config):
|
||||
yield
|
||||
|
||||
def test_mikan_url(self):
|
||||
@@ -128,10 +140,8 @@ class TestSpecialUrl:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"module.searcher.provider.SEARCH_CONFIG",
|
||||
{
|
||||
"mikan": "https://mikanani.me/RSS/Search?searchstr=%s",
|
||||
},
|
||||
"module.searcher.provider.get_provider",
|
||||
return_value={"mikan": MIKAN_SITE},
|
||||
):
|
||||
result = SearchTorrent.special_url(bangumi, "mikan")
|
||||
|
||||
@@ -155,10 +165,8 @@ class TestSpecialUrl:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"module.searcher.provider.SEARCH_CONFIG",
|
||||
{
|
||||
"mikan": "https://mikanani.me/RSS/Search?searchstr=%s",
|
||||
},
|
||||
"module.searcher.provider.get_provider",
|
||||
return_value={"mikan": MIKAN_SITE},
|
||||
):
|
||||
result = SearchTorrent.special_url(bangumi, "mikan")
|
||||
|
||||
@@ -176,8 +184,8 @@ class TestSpecialUrl:
|
||||
dpi="1080p",
|
||||
)
|
||||
with patch(
|
||||
"module.searcher.provider.SEARCH_CONFIG",
|
||||
{"mikan": "https://mikanani.me/RSS/Search?searchstr=%s"},
|
||||
"module.searcher.provider.get_provider",
|
||||
return_value={"mikan": MIKAN_SITE},
|
||||
):
|
||||
result = SearchTorrent.special_url(movie, "mikan")
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ const zhSidebar = [
|
||||
text: '开发者指南',
|
||||
items: [
|
||||
{ text: '参与贡献', link: '/dev/' },
|
||||
{ text: '插件开发', link: '/dev/plugins' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
199
docs/dev/plugins.md
Normal file
199
docs/dev/plugins.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# 插件开发
|
||||
|
||||
::: warning 预览版 SDK
|
||||
插件 SDK `ab_sdk` 在 4.0 期间为 0.x 版本,接口可能有不兼容调整;4.1 起冻结 1.0,之后遵守语义化版本。清单里的 `sdk` 字段声明你依赖的版本范围,AB 会拒绝加载不兼容的插件。
|
||||
:::
|
||||
|
||||
插件是一个 Python 包,可以给 AutoBangumi 增加:
|
||||
|
||||
| 扩展点 | 常量 | 返回值 | 用户如何启用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 下载器 | `points.DOWNLOADER` | `DownloaderFactory` | 设置 → 下载器 → 类型 |
|
||||
| 通知渠道 | `points.NOTIFIER` | `NotifierFactory` | 设置 → 通知 → 添加渠道 |
|
||||
| LLM 解析提供商 | `points.LLM_PROVIDER` | `LLMProviderAdapter` 子类 | 设置 → LLM → 提供商 |
|
||||
| 搜索站点 | `points.SEARCH_SITE` | `SearchSite` | 搜索框的站点列表 |
|
||||
| 定时任务 | `points.SCHEDULED_TASK` | `ScheduledTask` | 插件启用即生效 |
|
||||
|
||||
此外,插件可以订阅事件(`@subscribe`),并使用私有的键值存储和数据目录。RSS 过滤、重命名等流水线扩展点会在后续版本陆续开放。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
config/plugins/local/my-plugin/ # 目录名必须与清单中的 id 相同
|
||||
├── plugin.toml
|
||||
├── my_plugin.py # 也可以是包,插件内部可以用相对导入
|
||||
└── vendor/ # 可选:纯 Python 依赖(禁止 .so/.pyd)
|
||||
```
|
||||
|
||||
```toml
|
||||
# plugin.toml
|
||||
[plugin]
|
||||
id = "my-plugin" # 小写字母、数字、连字符
|
||||
name = "我的插件"
|
||||
version = "0.1.0"
|
||||
sdk = ">=0.2,<1" # 依赖的 ab_sdk 版本范围
|
||||
entry = "my_plugin:MyPlugin" # 模块:Plugin 子类,相对插件目录
|
||||
description = "一句话说明"
|
||||
permissions = ["network"] # 仅用于向用户展示,不做强制
|
||||
```
|
||||
|
||||
本地插件未经签名。要让 AB 加载它,需要在 **设置 → 插件** 中先打开「允许未签名插件」,再启用该插件。插件与 AB 在同一进程中运行,拥有完整权限。
|
||||
|
||||
## 第一个插件:Bark 风格的推送渠道
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ab_sdk import Plugin, points, provider, secret_field
|
||||
from ab_sdk.notify import NotificationMessage, NotifierSettings
|
||||
|
||||
|
||||
class Options(BaseModel):
|
||||
endpoint: str = Field("https://push.example.com", title="推送地址")
|
||||
key: str = secret_field(description="推送密钥")
|
||||
|
||||
|
||||
class Pusher:
|
||||
def __init__(self, plugin: "MyPlugin", settings: NotifierSettings) -> None:
|
||||
self.plugin = plugin
|
||||
self.settings = settings
|
||||
|
||||
async def send(self, message: NotificationMessage) -> bool:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self.plugin.config.endpoint}/{self.plugin.config.key}",
|
||||
json={"title": message.title, "body": message.body},
|
||||
)
|
||||
return resp.is_success
|
||||
|
||||
async def test(self) -> tuple[bool, str]:
|
||||
ok = await self.send(NotificationMessage(kind="event", title="AB", body="测试"))
|
||||
return ok, "已发送" if ok else "发送失败"
|
||||
|
||||
|
||||
class MyPlugin(Plugin[Options]):
|
||||
config_model = Options
|
||||
|
||||
@provider(points.NOTIFIER, id="my-push")
|
||||
def push(self):
|
||||
return lambda settings: Pusher(self, settings)
|
||||
```
|
||||
|
||||
启用插件后:
|
||||
|
||||
1. 设置 → 插件里会出现由 `Options` 自动生成的表单。`secret_field` 声明的字段以密码框显示,读取配置的接口只返回掩码。
|
||||
2. 设置 → 通知 → 添加渠道时,类型下拉框会多出 `my-push`。
|
||||
|
||||
## 核心概念
|
||||
|
||||
### Plugin 与配置
|
||||
|
||||
- 继承 `Plugin[配置模型]` 并设置 `config_model`。AB 会按模型校验用户配置,插件通过 `self.config` 拿到带类型的实例。在 WebUI 保存不合法的值会被直接拒绝(HTTP 422)。
|
||||
- `async setup()` 在加载后调用一次,`async teardown()` 在卸载前调用一次。配置变更时,插件会先 teardown,再重新构造并 setup。
|
||||
- `self.ctx` 提供:
|
||||
- `log`:带 `plugin.<id>` 前缀的 logger
|
||||
- `kv`:插件私有的持久化键值存储,值须可 JSON 序列化
|
||||
- `data_dir`:`config/plugin-data/<id>/`
|
||||
- `bus`:事件总线
|
||||
|
||||
### 三种扩展声明
|
||||
|
||||
| 装饰器 | 用途 |
|
||||
| --- | --- |
|
||||
| `@provider(point, id=...)` | 工厂方法,返回该扩展点约定的实现 |
|
||||
| `@hook(point, priority=...)` | 挂到宿主声明的 filter / transform 扩展点(后续版本开放) |
|
||||
| `@subscribe(kind)` | 订阅事件,`"*"` 表示全部事件 |
|
||||
|
||||
扩展点名写错时,插件会加载失败并在插件列表里显示原因,不会被悄悄忽略。
|
||||
|
||||
### 隔离与熔断
|
||||
|
||||
- 钩子、事件处理和定时任务都有超时。
|
||||
- 同一插件连续失败 5 次会被自动禁用,并在插件列表中显示原因。修改该插件的配置后会重新尝试加载。
|
||||
- 插件发布的事件,`kind` 必须以 `<插件 id>.` 开头。
|
||||
|
||||
## 各扩展点
|
||||
|
||||
### 下载器
|
||||
|
||||
```python
|
||||
from ab_sdk.downloader import AddResult, DownloaderCapabilities, DownloaderConnection
|
||||
|
||||
|
||||
class MyClient:
|
||||
capabilities = DownloaderCapabilities(
|
||||
can_query=False, can_rename=False, can_manage=False, can_rss_rules=False
|
||||
)
|
||||
|
||||
def __init__(self, conn: DownloaderConnection) -> None:
|
||||
self.conn = conn # 来自设置页「下载器」的 host / username / password / ssl
|
||||
|
||||
async def auth(self, retry: int = 3) -> bool: ...
|
||||
async def logout(self) -> None: ...
|
||||
async def add_torrents(self, torrent_urls, torrent_files, save_path, category, tags=None) -> AddResult: ...
|
||||
|
||||
|
||||
class MyPlugin(Plugin):
|
||||
@provider(points.DOWNLOADER, id="my-client")
|
||||
def client(self):
|
||||
return MyClient
|
||||
```
|
||||
|
||||
至少实现 `CoreDownloaderClient`(认证与添加种子)。需要重命名、整理等功能时,实现完整的 `DownloaderClient` 并在 `capabilities` 中声明,AB 会跳过未声明的操作。
|
||||
|
||||
### LLM 提供商
|
||||
|
||||
返回 `ab_sdk.llm.LLMProviderAdapter` 的子类,`@provider` 的 id 必须等于其 `info.id`。内置提供商的 id 不能被覆盖。
|
||||
|
||||
### 搜索站点
|
||||
|
||||
```python
|
||||
from ab_sdk.search import SearchSite
|
||||
|
||||
@provider(points.SEARCH_SITE, id="my-site")
|
||||
def site(self):
|
||||
return SearchSite(url="https://example.com/rss?q=%s", parser="tmdb")
|
||||
```
|
||||
|
||||
用户在搜索设置里配置的同名站点优先。
|
||||
|
||||
### 定时任务
|
||||
|
||||
```python
|
||||
from ab_sdk.tasks import ScheduledTask
|
||||
|
||||
@provider(points.SCHEDULED_TASK, id="sync")
|
||||
def sync(self):
|
||||
async def run():
|
||||
...
|
||||
return ScheduledTask(run, interval=lambda: self.config.interval, initial_delay=60)
|
||||
```
|
||||
|
||||
任务与 RSS 刷新等内置任务一同启停,失败计入熔断。
|
||||
|
||||
## 测试
|
||||
|
||||
`ab_sdk.testing` 可以在不启动 AB 的情况下构造并驱动插件:
|
||||
|
||||
```python
|
||||
from ab_sdk.testing import create_plugin
|
||||
|
||||
async def test_push(tmp_path):
|
||||
plugin, ctx = create_plugin(MyPlugin, {"key": "k"}, data_dir=tmp_path)
|
||||
await plugin.setup()
|
||||
factory = plugin.push()
|
||||
...
|
||||
```
|
||||
|
||||
## 以 pip 包发布
|
||||
|
||||
在包的 `pyproject.toml` 中声明 entry point,并在**顶层包内**附带 `plugin.toml`:
|
||||
|
||||
```toml
|
||||
[project.entry-points."autobangumi.plugins"]
|
||||
my-plugin = "my_plugin:MyPlugin"
|
||||
```
|
||||
|
||||
pip 安装的插件同样未签名,需要开启「允许未签名插件」。Docker 镜像无法 pip 安装,此时请使用本地目录,并把依赖放进 `vendor/`。
|
||||
@@ -14,6 +14,13 @@
|
||||
| pip entry point 来源 | **纳入 4.0**(第 2.4 节) |
|
||||
| 插件 REST 路由 / MCP 工具 | **纳入 4.0**(第 3.9 节) |
|
||||
|
||||
### 实施中的调整(P2)
|
||||
|
||||
- **内置实现以 `core` 身份登记**。qB / aria2 / mock 下载器和各通知渠道,与第三方插件使用同一个扩展注册表和同一套 `ab_sdk` 契约。区别是它们由宿主直接登记,不经插件管理器加载,因此不能被禁用,也不会因插件故障被熔断。LLM 内置适配器与预设仍由原 LLM 注册表管理,插件适配器作为第三个来源并入。
|
||||
- **内置实现的配置不迁移**。`downloader.type`、`notification.providers[].type`、`llm.provider` 直接作为 Provider id 查找。第 4.4 节的 `slots` / `instances` 与第 9 节的配置迁移器暂不实施,P2.5 多下载器时再统一设计。
|
||||
- **扩展点改名**:`search_provider` 改为 `search_site`,以区分用户在搜索设置里维护的站点列表。
|
||||
- 签名目录来源(LLM 安装器泛化)、`dev_mode` 文件监听、bark / wecom 旧字段别名清理,移到 P7(生态)。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
|
||||
@@ -557,7 +564,7 @@ organize: downloader.completed → media_files.classify → file_parser
|
||||
|---|---|---|
|
||||
| **P0 清理** | 第 8 节:死代码、3.x 兼容层;`renamer.py` 先做纯搬移式拆分(不改行为) | 生产代码行数减少;vulture CI;测试全绿 |
|
||||
| **P1 插件运行时 + SDK 骨架** | `ab_sdk` 包(含 `ab_sdk.testing`)、`module/plugin/`(清单、加载、注册表、runner、熔断、EventBus、插件 KV)、`plugins` 配置段、`GET /api/v1/plugins`;SDK 边界测试 | 本地插件可加载、配置、随配置变更重载;已完成。签名目录来源与 LLM 安装器泛化、`dev_mode` 文件监听移到 P2 |
|
||||
| **P2 迁移已有注册表** | LLM、通知、下载器、搜索、定时任务改为内置插件;`/api/v1/plugins` + 通用 JSON Schema 表单;vendor 加载 + pip entry point 发现 | 行为与 3.3.6 一致(e2e 回归);配置迁移器 |
|
||||
| **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 |
|
||||
| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 |
|
||||
| **P3 流水线插件化:ingest** | `feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`torrent.filter`、`ranker`、`metadata_provider` 链、`save_path`、`torrent.adding` | 新增 include / size 过滤;私有站 headers |
|
||||
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 模板重命名;硬链接示例插件 |
|
||||
|
||||
46
webui/src/api/__tests__/plugins.test.ts
Normal file
46
webui/src/api/__tests__/plugins.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Contract tests for apiPlugins against backend/src/module/api/plugins.py.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAxiosMock } from '@/test/mocks/axios';
|
||||
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
import { axios } from '@/utils/axios';
|
||||
|
||||
vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() }));
|
||||
|
||||
const overview = { allow_unsigned: false, plugins: [] };
|
||||
|
||||
describe('Plugins API contract (path + HTTP method)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should GET api/v1/plugins when listing', async () => {
|
||||
(axios.get as any).mockResolvedValue({ data: overview });
|
||||
expect(await apiPlugins.list()).toEqual(overview);
|
||||
expect(axios.get).toHaveBeenCalledWith('api/v1/plugins');
|
||||
});
|
||||
|
||||
it('should PUT api/v1/plugins/:id with an encoded id', async () => {
|
||||
(axios.put as any).mockResolvedValue({ data: overview });
|
||||
await apiPlugins.update('my plugin', { enabled: true });
|
||||
expect(axios.put).toHaveBeenCalledWith('api/v1/plugins/my%20plugin', {
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should PUT api/v1/plugins/settings with allow_unsigned', async () => {
|
||||
(axios.put as any).mockResolvedValue({ data: overview });
|
||||
await apiPlugins.updateSettings(true);
|
||||
expect(axios.put).toHaveBeenCalledWith('api/v1/plugins/settings', {
|
||||
allow_unsigned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should GET api/v1/plugins/providers', async () => {
|
||||
(axios.get as any).mockResolvedValue({ data: {} });
|
||||
await apiPlugins.providers();
|
||||
expect(axios.get).toHaveBeenCalledWith('api/v1/plugins/providers');
|
||||
});
|
||||
});
|
||||
37
webui/src/api/plugins.ts
Normal file
37
webui/src/api/plugins.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { PluginProviders, PluginsOverview } from '#/plugins';
|
||||
|
||||
export const apiPlugins = {
|
||||
/** 已发现的插件、状态、配置 schema 与(掩码后的)当前配置 */
|
||||
async list() {
|
||||
const { data } = await axios.get<PluginsOverview>('api/v1/plugins');
|
||||
return data;
|
||||
},
|
||||
|
||||
/** 启用/停用插件或修改配置;后端保存后立即重新应用 */
|
||||
async update(
|
||||
id: string,
|
||||
body: { enabled?: boolean; options?: Record<string, unknown> }
|
||||
) {
|
||||
const { data } = await axios.put<PluginsOverview>(
|
||||
`api/v1/plugins/${encodeURIComponent(id)}`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateSettings(allowUnsigned: boolean) {
|
||||
const { data } = await axios.put<PluginsOverview>(
|
||||
'api/v1/plugins/settings',
|
||||
{ allow_unsigned: allowUnsigned }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** 插件提供的下载器 / 通知渠道 / 搜索站点 id */
|
||||
async providers() {
|
||||
const { data } = await axios.get<PluginProviders>(
|
||||
'api/v1/plugins/providers'
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -2,21 +2,28 @@
|
||||
import { Info } from '@icon-park/vue-next';
|
||||
import type { Downloader, DownloaderType } from '#/config';
|
||||
import type { SettingItem } from '#/components';
|
||||
import { usePluginProviders } from '@/hooks/usePluginProviders';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { getSettingGroup } = useConfigStore();
|
||||
|
||||
const downloader = getSettingGroup('downloader');
|
||||
const downloaderType: DownloaderType = ['qbittorrent', 'aria2'];
|
||||
const builtinTypes: DownloaderType = ['qbittorrent', 'aria2'];
|
||||
const pluginProviders = usePluginProviders();
|
||||
// 插件提供的下载器 id 追加在内置选项之后
|
||||
const downloaderType = computed(() => [
|
||||
...builtinTypes,
|
||||
...pluginProviders.value.downloader,
|
||||
]);
|
||||
|
||||
const items: SettingItem<Downloader>[] = [
|
||||
const items = computed<SettingItem<Downloader>[]>(() => [
|
||||
{
|
||||
configKey: 'type',
|
||||
label: () => t('config.downloader_set.type'),
|
||||
type: 'select',
|
||||
css: 'w-115',
|
||||
prop: {
|
||||
items: downloaderType,
|
||||
items: downloaderType.value,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -61,7 +68,7 @@ const items: SettingItem<Downloader>[] = [
|
||||
label: () => t('config.downloader_set.ssl'),
|
||||
type: 'switch',
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,8 +100,7 @@ const items: SettingItem<Downloader>[] = [
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
transition:
|
||||
background-color var(--transition-normal),
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useConfirm } from '@/hooks/useConfirm';
|
||||
import type { NotificationProviderConfig, NotificationType } from '#/config';
|
||||
import type { TupleToUnion } from '#/utils';
|
||||
import { apiNotification } from '@/api/notification';
|
||||
import { usePluginProviders } from '@/hooks/usePluginProviders';
|
||||
|
||||
const { t, returnUserLangText } = useMyI18n();
|
||||
const { confirm } = useConfirm();
|
||||
@@ -12,8 +13,8 @@ const { getSettingGroup } = useConfigStore();
|
||||
const notificationRef = getSettingGroup('notification');
|
||||
|
||||
// Provider types with display names
|
||||
const providerTypes: {
|
||||
value: TupleToUnion<NotificationType>;
|
||||
const builtinProviderTypes: {
|
||||
value: TupleToUnion<NotificationType> | (string & {});
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
@@ -25,6 +26,15 @@ const providerTypes: {
|
||||
{ value: 'pushover', label: 'Pushover' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
];
|
||||
const pluginProviders = usePluginProviders();
|
||||
// 插件提供的渠道追加在内置渠道之后;其凭据在插件自己的配置里填写
|
||||
const providerTypes = computed(() => [
|
||||
...builtinProviderTypes,
|
||||
...pluginProviders.value.notifier.map((id) => ({
|
||||
value: id,
|
||||
label: `${id}(${t('config.plugins_set.plugin_provided')})`,
|
||||
})),
|
||||
]);
|
||||
|
||||
// Provider field configurations
|
||||
const providerFields: Record<
|
||||
@@ -119,7 +129,7 @@ if (!notificationRef.value.providers) {
|
||||
}
|
||||
|
||||
function getProviderLabel(type: string): string {
|
||||
return providerTypes.find((p) => p.value === type)?.label || type;
|
||||
return providerTypes.value.find((p) => p.value === type)?.label || type;
|
||||
}
|
||||
|
||||
function getProviderIcon(type: string): string {
|
||||
|
||||
224
webui/src/components/setting/config-plugins.vue
Normal file
224
webui/src/components/setting/config-plugins.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts" setup>
|
||||
import AbAlert from '../basic/ab-alert.vue';
|
||||
import AbButton from '../basic/ab-button.vue';
|
||||
import AbField from '../basic/ab-field.vue';
|
||||
import AbSwitch from '../basic/ab-switch.vue';
|
||||
import AbTag from '../basic/ab-tag.vue';
|
||||
import PluginSchemaForm from './plugin-schema-form.vue';
|
||||
import type { PluginInfo, PluginsOverview } from '#/plugins';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
|
||||
|
||||
// 插件卡片不参与全局保存:每次改动直接调 /plugins 接口落盘并应用,
|
||||
// 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。
|
||||
const { t } = useMyI18n();
|
||||
const message = useMessage();
|
||||
const { refreshGroup } = useConfigStore();
|
||||
|
||||
const overview = ref<PluginsOverview | null>(null);
|
||||
const drafts = ref<Record<string, Record<string, unknown>>>({});
|
||||
const busy = ref<string | null>(null);
|
||||
|
||||
const stateType = {
|
||||
active: 'success',
|
||||
disabled: 'neutral',
|
||||
error: 'danger',
|
||||
} as const;
|
||||
|
||||
function apply(data: PluginsOverview) {
|
||||
overview.value = data;
|
||||
drafts.value = Object.fromEntries(
|
||||
data.plugins.map((p) => [
|
||||
p.id,
|
||||
fillSchemaDefaults(schemaFields(p.config_schema), p.options),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
apply(await apiPlugins.list());
|
||||
} catch {
|
||||
message.error(t('config.plugins_set.load_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function run(key: string, action: () => Promise<PluginsOverview>) {
|
||||
busy.value = key;
|
||||
try {
|
||||
apply(await action());
|
||||
await refreshGroup('plugins');
|
||||
return true;
|
||||
} catch {
|
||||
message.error(t('config.plugins_set.save_failed'));
|
||||
return false;
|
||||
} finally {
|
||||
busy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setAllowUnsigned(value: boolean) {
|
||||
run('__settings', () => apiPlugins.updateSettings(value));
|
||||
}
|
||||
|
||||
function setEnabled(plugin: PluginInfo, enabled: boolean) {
|
||||
run(plugin.id, () => apiPlugins.update(plugin.id, { enabled }));
|
||||
}
|
||||
|
||||
async function saveOptions(plugin: PluginInfo) {
|
||||
const ok = await run(plugin.id, () =>
|
||||
apiPlugins.update(plugin.id, { options: drafts.value[plugin.id] })
|
||||
);
|
||||
if (ok) message.success(t('config.plugins_set.save_success'));
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-fold-panel :title="$t('config.plugins_set.title')">
|
||||
<div v-if="overview" class="plugins">
|
||||
<AbField
|
||||
:label="$t('config.plugins_set.allow_unsigned')"
|
||||
:description="$t('config.plugins_set.allow_unsigned_hint')"
|
||||
>
|
||||
<AbSwitch
|
||||
:model-value="overview.allow_unsigned"
|
||||
:loading="busy === '__settings'"
|
||||
:aria-label="$t('config.plugins_set.allow_unsigned')"
|
||||
@update:model-value="setAllowUnsigned"
|
||||
/>
|
||||
</AbField>
|
||||
|
||||
<p v-if="!overview.plugins.length" class="plugins__empty">
|
||||
{{ $t('config.plugins_set.empty') }}
|
||||
</p>
|
||||
|
||||
<section
|
||||
v-for="plugin in overview.plugins"
|
||||
:key="plugin.id"
|
||||
class="plugin"
|
||||
>
|
||||
<header class="plugin__header">
|
||||
<div class="plugin__title">
|
||||
<strong>{{ plugin.name }}</strong>
|
||||
<span class="plugin__meta"
|
||||
>{{ plugin.id }} · v{{ plugin.version }}</span
|
||||
>
|
||||
</div>
|
||||
<AbSwitch
|
||||
:model-value="plugin.enabled"
|
||||
:loading="busy === plugin.id"
|
||||
:aria-label="$t('config.plugins_set.enabled')"
|
||||
@update:model-value="setEnabled(plugin, $event)"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="plugin__tags">
|
||||
<AbTag :type="stateType[plugin.state]">
|
||||
{{ $t(`config.plugins_set.state_${plugin.state}`) }}
|
||||
</AbTag>
|
||||
<AbTag>{{ $t(`config.plugins_set.source_${plugin.source}`) }}</AbTag>
|
||||
<AbTag v-if="!plugin.signed" type="warning">
|
||||
{{ $t('config.plugins_set.unsigned') }}
|
||||
</AbTag>
|
||||
</div>
|
||||
|
||||
<p v-if="plugin.description" class="plugin__desc">
|
||||
{{ plugin.description }}
|
||||
</p>
|
||||
<AbAlert
|
||||
v-if="plugin.error"
|
||||
:type="plugin.state === 'error' ? 'danger' : 'info'"
|
||||
>
|
||||
{{ plugin.error }}
|
||||
</AbAlert>
|
||||
<p v-if="plugin.permissions.length" class="plugin__desc">
|
||||
{{
|
||||
$t('config.plugins_set.permissions', {
|
||||
list: plugin.permissions.join(', '),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<details class="plugin__options">
|
||||
<summary>{{ $t('config.plugins_set.options') }}</summary>
|
||||
<template v-if="plugin.config_schema">
|
||||
<PluginSchemaForm
|
||||
v-model="drafts[plugin.id]"
|
||||
:fields="schemaFields(plugin.config_schema)"
|
||||
/>
|
||||
<AbButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
class="plugin__save"
|
||||
:loading="busy === plugin.id"
|
||||
@click="saveOptions(plugin)"
|
||||
>
|
||||
{{ $t('config.plugins_set.save') }}
|
||||
</AbButton>
|
||||
</template>
|
||||
<p v-else-if="plugin.state === 'active'" class="plugin__desc">
|
||||
{{ $t('config.plugins_set.no_options') }}
|
||||
</p>
|
||||
<p v-else class="plugin__desc">
|
||||
{{ $t('config.plugins_set.options_unavailable') }}
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugins {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plugins__empty,
|
||||
.plugin__desc,
|
||||
.plugin__meta {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.plugin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.plugin__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin__title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.plugin__options summary {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plugin__save {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
83
webui/src/components/setting/plugin-schema-form.vue
Normal file
83
webui/src/components/setting/plugin-schema-form.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
import { NDynamicTags } from 'naive-ui';
|
||||
import AbField from '../basic/ab-field.vue';
|
||||
import AbInput from '../basic/ab-input.vue';
|
||||
import AbSelect from '../basic/ab-select.vue';
|
||||
import AbSwitch from '../basic/ab-switch.vue';
|
||||
import type { SchemaField } from '@/utils/plugin-schema';
|
||||
|
||||
// 由插件 config_model 的 JSON Schema 渲染的表单;字段描述由 schemaFields() 生成
|
||||
defineProps<{ fields: SchemaField[] }>();
|
||||
const model = defineModel<Record<string, unknown>>({ required: true });
|
||||
|
||||
function setNumber(field: SchemaField, value: string | number) {
|
||||
const parsed = Number(value);
|
||||
if (value === '' || Number.isNaN(parsed)) {
|
||||
delete model.value[field.key];
|
||||
return;
|
||||
}
|
||||
model.value[field.key] = field.integer ? Math.trunc(parsed) : parsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schema-form">
|
||||
<AbField
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
:description="field.description"
|
||||
>
|
||||
<AbSwitch
|
||||
v-if="field.kind === 'switch'"
|
||||
:model-value="Boolean(model[field.key])"
|
||||
:aria-label="field.label"
|
||||
@update:model-value="model[field.key] = $event"
|
||||
/>
|
||||
<AbSelect
|
||||
v-else-if="field.kind === 'select'"
|
||||
:model-value="(model[field.key] as string | number | null) ?? null"
|
||||
:items="field.options.map(String)"
|
||||
:aria-label="field.label"
|
||||
@update:model-value="model[field.key] = $event"
|
||||
/>
|
||||
<AbInput
|
||||
v-else-if="field.kind === 'number'"
|
||||
type="number"
|
||||
:model-value="(model[field.key] as number | undefined) ?? ''"
|
||||
:aria-label="field.label"
|
||||
@update:model-value="setNumber(field, $event)"
|
||||
/>
|
||||
<NDynamicTags
|
||||
v-else-if="field.kind === 'tags'"
|
||||
:value="(model[field.key] as string[] | undefined) ?? []"
|
||||
size="small"
|
||||
@update:value="model[field.key] = $event"
|
||||
/>
|
||||
<AbInput
|
||||
v-else-if="field.kind === 'text' || field.kind === 'password'"
|
||||
:type="field.kind === 'password' ? 'password' : 'text'"
|
||||
:autocomplete="field.kind === 'password' ? 'off' : undefined"
|
||||
:model-value="(model[field.key] as string | undefined) ?? ''"
|
||||
:aria-label="field.label"
|
||||
@update:model-value="model[field.key] = $event"
|
||||
/>
|
||||
<span v-else class="schema-form__unsupported">
|
||||
{{ $t('config.plugins_set.unsupported_field') }}
|
||||
</span>
|
||||
</AbField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.schema-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schema-form__unsupported {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
28
webui/src/hooks/usePluginProviders.ts
Normal file
28
webui/src/hooks/usePluginProviders.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PluginProviders } from '#/plugins';
|
||||
import { apiPlugins } from '@/api/plugins';
|
||||
|
||||
const providers = ref<PluginProviders>({
|
||||
downloader: [],
|
||||
notifier: [],
|
||||
search_site: [],
|
||||
});
|
||||
let loaded = false;
|
||||
|
||||
/**
|
||||
* 插件提供的下载器 / 通知渠道 / 搜索站点 id,供设置页下拉框合并候选。
|
||||
* 全局只请求一次;请求失败(如未登录)时保持空列表,不影响内置选项。
|
||||
*/
|
||||
export function usePluginProviders() {
|
||||
if (!loaded) {
|
||||
loaded = true;
|
||||
apiPlugins
|
||||
.providers()
|
||||
.then((data) => {
|
||||
providers.value = data;
|
||||
})
|
||||
.catch(() => {
|
||||
loaded = false;
|
||||
});
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
@@ -169,6 +169,30 @@
|
||||
"category_id_placeholder": "e.g., 402",
|
||||
"nexusphp_hint": "Builds a torrentrss.php search template from your passkey. Note: stock NexusPHP ignores the search parameter and always returns the latest torrents — verify on your site that RSS search actually filters results.",
|
||||
"category_id_invalid": "Category IDs must be numeric (comma-separated for multiple)"
|
||||
},
|
||||
"plugins_set": {
|
||||
"title": "Plugins",
|
||||
"empty": "No plugins found. Put a plugin directory (named after the plugin id) into config/plugins/local/, or install it with pip, then restart.",
|
||||
"allow_unsigned": "Allow unsigned plugins",
|
||||
"allow_unsigned_hint": "Plugins from the local directory or pip are unsigned and only load when this is on. Plugins run inside AutoBangumi with full privileges — only enable plugins you trust.",
|
||||
"enabled": "Enabled",
|
||||
"state_active": "Running",
|
||||
"state_disabled": "Disabled",
|
||||
"state_error": "Error",
|
||||
"source_builtin": "Built-in",
|
||||
"source_local": "Local",
|
||||
"source_pip": "pip",
|
||||
"unsigned": "Unsigned",
|
||||
"permissions": "Declared permissions: {list}",
|
||||
"options": "Options",
|
||||
"no_options": "This plugin has no options",
|
||||
"options_unavailable": "The options form appears after the plugin has been loaded once. Enable it first.",
|
||||
"unsupported_field": "This field type can't be edited here yet; edit config.json directly",
|
||||
"save": "Save options",
|
||||
"save_success": "Plugin options saved and applied",
|
||||
"save_failed": "Failed to save plugin options",
|
||||
"load_failed": "Failed to load plugins",
|
||||
"plugin_provided": "From plugin"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
|
||||
@@ -169,6 +169,30 @@
|
||||
"category_id_placeholder": "例如:402",
|
||||
"nexusphp_hint": "根据 Passkey 生成 torrentrss.php 搜索模板。注意:原生 NexusPHP 已停用 search 参数、对任何搜索词都返回最新种子,请先在站点上确认 RSS 搜索确实按词过滤。",
|
||||
"category_id_invalid": "分类 ID 必须是数字(多个用逗号分隔)"
|
||||
},
|
||||
"plugins_set": {
|
||||
"title": "插件",
|
||||
"empty": "未发现插件。把插件目录放进 config/plugins/local/(目录名与插件 id 相同)或用 pip 安装后重启,即可在此出现。",
|
||||
"allow_unsigned": "允许未签名插件",
|
||||
"allow_unsigned_hint": "本地目录与 pip 安装的插件未经签名,开启后才会加载。插件与 AutoBangumi 同进程运行,拥有完整权限,只启用你信任的插件。",
|
||||
"enabled": "启用",
|
||||
"state_active": "运行中",
|
||||
"state_disabled": "未启用",
|
||||
"state_error": "错误",
|
||||
"source_builtin": "内置",
|
||||
"source_local": "本地",
|
||||
"source_pip": "pip",
|
||||
"unsigned": "未签名",
|
||||
"permissions": "声明的权限:{list}",
|
||||
"options": "配置",
|
||||
"no_options": "该插件没有可配置项",
|
||||
"options_unavailable": "插件加载一次后才能显示配置表单,请先启用。",
|
||||
"unsupported_field": "该字段类型暂不支持在界面中编辑,请直接修改 config.json",
|
||||
"save": "保存配置",
|
||||
"save_success": "插件配置已保存并应用",
|
||||
"save_failed": "保存插件配置失败",
|
||||
"load_failed": "加载插件列表失败",
|
||||
"plugin_provided": "插件提供"
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
|
||||
@@ -16,6 +16,7 @@ import ConfigPasskey from '@/components/setting/config-passkey.vue';
|
||||
import ConfigSecurity from '@/components/setting/config-security.vue';
|
||||
import ConfigAccess from '@/components/setting/config-access.vue';
|
||||
import UpdateCard from '@/components/setting/update-card.vue';
|
||||
import ConfigPlugins from '@/components/setting/config-plugins.vue';
|
||||
import { configSectionMatches } from '@/utils/config-search';
|
||||
|
||||
definePage({
|
||||
@@ -191,6 +192,14 @@ const sections: ConfigSection[] = [
|
||||
groups: ['security'],
|
||||
keywords: ['security', 'whitelist', 'ip', 'token', 'mcp'],
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
titleKey: 'config.plugins_set.title',
|
||||
// 插件卡片经 /plugins 接口自行保存,不参与全局保存
|
||||
component: ConfigPlugins,
|
||||
groups: [],
|
||||
keywords: ['plugin', 'extension', 'sdk', 'unsigned', '插件'],
|
||||
},
|
||||
{
|
||||
id: 'update',
|
||||
titleKey: 'update.title',
|
||||
|
||||
44
webui/src/store/__tests__/config.test.ts
Normal file
44
webui/src/store/__tests__/config.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAxiosMock } from '@/test/mocks/axios';
|
||||
import { apiConfig } from '@/api/config';
|
||||
import { useConfigStore } from '@/store/config';
|
||||
import { type Config, initConfig } from '#/config';
|
||||
|
||||
vi.mock('@/utils/axios', () => ({ axios: createAxiosMock() }));
|
||||
|
||||
vi.mock('@/hooks/useApi', () => ({
|
||||
useApi: () => ({ execute: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/config', () => ({
|
||||
apiConfig: { getConfig: vi.fn(), updateConfig: vi.fn() },
|
||||
}));
|
||||
|
||||
function clone(config: Config): Config {
|
||||
return JSON.parse(JSON.stringify(config));
|
||||
}
|
||||
|
||||
describe('config store refreshGroup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('replaces one group in both the draft and the snapshot, keeping other edits', async () => {
|
||||
const server = clone(initConfig);
|
||||
vi.mocked(apiConfig.getConfig).mockResolvedValue(clone(server));
|
||||
const store = useConfigStore();
|
||||
await store.getConfig();
|
||||
|
||||
// 用户改了别的段(未保存),同时插件卡片直接保存了 plugins 段
|
||||
store.config.program.rss_time = 1234;
|
||||
server.plugins.enabled = { demo: true };
|
||||
vi.mocked(apiConfig.getConfig).mockResolvedValue(clone(server));
|
||||
await store.refreshGroup('plugins');
|
||||
|
||||
expect(store.config.plugins.enabled).toEqual({ demo: true });
|
||||
expect(store.config.program.rss_time).toBe(1234);
|
||||
// plugins 段不再算作未保存,program 段仍然是脏的
|
||||
expect(store.dirtyGroups).toContain('program');
|
||||
expect(store.dirtyGroups).not.toContain('plugins');
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,16 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 某个配置段在别处(如插件卡片走 /plugins 接口)被直接保存后,用服务端的值
|
||||
* 同时刷新编辑态与快照,避免之后的全局保存用旧值把它覆盖回去。
|
||||
*/
|
||||
async function refreshGroup<Tkey extends keyof Config>(key: Tkey) {
|
||||
const res = await apiConfig.getConfig();
|
||||
config.value[key] = res[key];
|
||||
savedConfig.value[key] = JSON.parse(JSON.stringify(res[key]));
|
||||
}
|
||||
|
||||
function getSettingGroup<Tkey extends keyof Config>(key: Tkey) {
|
||||
return computed<Config[Tkey]>({
|
||||
get() {
|
||||
@@ -59,5 +69,6 @@ export const useConfigStore = defineStore('config', () => {
|
||||
getConfig,
|
||||
setConfig,
|
||||
getSettingGroup,
|
||||
refreshGroup,
|
||||
};
|
||||
});
|
||||
|
||||
70
webui/src/utils/__tests__/plugin-schema.test.ts
Normal file
70
webui/src/utils/__tests__/plugin-schema.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fillSchemaDefaults, schemaFields } from '../plugin-schema';
|
||||
import type { JsonSchema } from '#/plugins';
|
||||
|
||||
// 与 pydantic v2 的 model_json_schema() 输出形状一致
|
||||
const schema: JsonSchema = {
|
||||
title: 'Options',
|
||||
properties: {
|
||||
site: { type: 'string', title: 'Site', default: 'https://a' },
|
||||
cookie: { type: 'string', title: 'Cookie', secret: true, default: '' },
|
||||
limit: { type: 'integer', title: 'Limit', default: 10 },
|
||||
ratio: { type: 'number', title: 'Ratio' },
|
||||
enabled: { type: 'boolean', title: 'Enabled', default: true },
|
||||
mode: { $ref: '#/$defs/Mode', default: 'copy' },
|
||||
quality: { enum: ['1080p', '720p'], type: 'string', title: 'Quality' },
|
||||
tags: { type: 'array', items: { type: 'string' }, default: [] },
|
||||
note: {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }],
|
||||
title: 'Note',
|
||||
default: null,
|
||||
},
|
||||
nested: { type: 'object', title: 'Nested' },
|
||||
},
|
||||
$defs: {
|
||||
Mode: { enum: ['copy', 'hardlink'], type: 'string', title: 'Mode' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('schemaFields', () => {
|
||||
it('maps pydantic schema types to form controls in declaration order', () => {
|
||||
const fields = schemaFields(schema);
|
||||
expect(fields.map((f) => [f.key, f.kind])).toEqual([
|
||||
['site', 'text'],
|
||||
['cookie', 'password'],
|
||||
['limit', 'number'],
|
||||
['ratio', 'number'],
|
||||
['enabled', 'switch'],
|
||||
['mode', 'select'],
|
||||
['quality', 'select'],
|
||||
['tags', 'tags'],
|
||||
['note', 'text'],
|
||||
['nested', 'unsupported'],
|
||||
]);
|
||||
const mode = fields.find((f) => f.key === 'mode')!;
|
||||
expect(mode.options).toEqual(['copy', 'hardlink']);
|
||||
expect(mode.default).toBe('copy');
|
||||
expect(fields.find((f) => f.key === 'limit')!.integer).toBe(true);
|
||||
expect(fields.find((f) => f.key === 'ratio')!.integer).toBe(false);
|
||||
});
|
||||
|
||||
it('returns no fields without a schema', () => {
|
||||
expect(schemaFields(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillSchemaDefaults', () => {
|
||||
it('fills only missing keys and copies mutable defaults', () => {
|
||||
const fields = schemaFields(schema);
|
||||
const result = fillSchemaDefaults(fields, {
|
||||
site: 'https://b',
|
||||
cookie: '********',
|
||||
});
|
||||
expect(result.site).toBe('https://b');
|
||||
expect(result.cookie).toBe('********');
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.note).toBeNull();
|
||||
(result.tags as string[]).push('x');
|
||||
expect(schema.properties!.tags.default).toEqual([]);
|
||||
});
|
||||
});
|
||||
89
webui/src/utils/plugin-schema.ts
Normal file
89
webui/src/utils/plugin-schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { JsonSchema, JsonSchemaProperty } from '#/plugins';
|
||||
|
||||
export type SchemaFieldKind =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'number'
|
||||
| 'switch'
|
||||
| 'select'
|
||||
| 'tags'
|
||||
| 'unsupported';
|
||||
|
||||
export interface SchemaField {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
kind: SchemaFieldKind;
|
||||
/** select 的候选值 */
|
||||
options: (string | number)[];
|
||||
/** integer 字段输入后取整 */
|
||||
integer: boolean;
|
||||
default: unknown;
|
||||
}
|
||||
|
||||
/** 解开 pydantic 生成的 $ref 与 Optional(anyOf: [T, null]) */
|
||||
function resolve(
|
||||
prop: JsonSchemaProperty,
|
||||
defs: Record<string, JsonSchemaProperty>
|
||||
): JsonSchemaProperty {
|
||||
if (prop.$ref) {
|
||||
const name = prop.$ref.split('/').pop() ?? '';
|
||||
return { ...defs[name], ...prop, $ref: undefined };
|
||||
}
|
||||
if (prop.anyOf) {
|
||||
const inner = prop.anyOf.find((p) => p.type !== 'null');
|
||||
if (inner) return { ...resolve(inner, defs), ...prop, anyOf: undefined };
|
||||
}
|
||||
return prop;
|
||||
}
|
||||
|
||||
function kindOf(prop: JsonSchemaProperty): SchemaFieldKind {
|
||||
if (prop.enum) return 'select';
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
return prop.secret || prop.writeOnly || prop.format === 'password'
|
||||
? 'password'
|
||||
: 'text';
|
||||
case 'integer':
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'boolean':
|
||||
return 'switch';
|
||||
case 'array':
|
||||
return prop.items?.type === 'string' ? 'tags' : 'unsupported';
|
||||
default:
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
|
||||
/** 把插件 config_model 的 JSON Schema 转成表单字段描述(保持声明顺序) */
|
||||
export function schemaFields(schema: JsonSchema | null): SchemaField[] {
|
||||
if (!schema?.properties) return [];
|
||||
const defs = schema.$defs ?? {};
|
||||
return Object.entries(schema.properties).map(([key, raw]) => {
|
||||
const prop = resolve(raw, defs);
|
||||
return {
|
||||
key,
|
||||
label: prop.title ?? key,
|
||||
description: prop.description ?? '',
|
||||
kind: kindOf(prop),
|
||||
options: prop.enum ?? [],
|
||||
integer: prop.type === 'integer',
|
||||
default: prop.default,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 未保存过的字段用 schema 默认值填充,供表单初始展示 */
|
||||
export function fillSchemaDefaults(
|
||||
fields: SchemaField[],
|
||||
options: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...options };
|
||||
for (const field of fields) {
|
||||
if (!(field.key in result) && field.default !== undefined) {
|
||||
result[field.key] = JSON.parse(JSON.stringify(field.default));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -45,7 +45,8 @@ export interface Program {
|
||||
}
|
||||
|
||||
export interface Downloader {
|
||||
type: TupleToUnion<DownloaderType>;
|
||||
/** 内置类型,或插件提供的下载器 id */
|
||||
type: TupleToUnion<DownloaderType> | (string & {});
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -87,7 +88,8 @@ export interface Network {
|
||||
}
|
||||
/** Notification provider configuration */
|
||||
export interface NotificationProviderConfig {
|
||||
type: TupleToUnion<NotificationType>;
|
||||
/** 内置渠道,或插件提供的渠道 id */
|
||||
type: TupleToUnion<NotificationType> | (string & {});
|
||||
enabled: boolean;
|
||||
// Common fields
|
||||
token?: string;
|
||||
|
||||
4
webui/types/dts/auto-imports.d.ts
vendored
4
webui/types/dts/auto-imports.d.ts
vendored
@@ -15,6 +15,7 @@ declare global {
|
||||
const apiLog: typeof import('../../src/api/log')['apiLog']
|
||||
const apiNotification: typeof import('../../src/api/notification')['apiNotification']
|
||||
const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']
|
||||
const apiPlugins: typeof import('../../src/api/plugins')['apiPlugins']
|
||||
const apiProgram: typeof import('../../src/api/program')['apiProgram']
|
||||
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
|
||||
const apiSearch: typeof import('../../src/api/search')['apiSearch']
|
||||
@@ -44,6 +45,7 @@ declare global {
|
||||
const dirtyConfigGroups: typeof import('../../src/utils/config-diff')['dirtyConfigGroups']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const expect: typeof import('vitest')['expect']
|
||||
const fillSchemaDefaults: typeof import('../../src/utils/plugin-schema')['fillSchemaDefaults']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
@@ -86,6 +88,7 @@ declare global {
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const resolvePosterUrl: typeof import('../../src/utils/poster')['resolvePosterUrl']
|
||||
const schemaFields: typeof import('../../src/utils/plugin-schema')['schemaFields']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
@@ -127,6 +130,7 @@ declare global {
|
||||
const useNotificationStore: typeof import('../../src/store/notification')['useNotificationStore']
|
||||
const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']
|
||||
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
|
||||
const usePluginProviders: typeof import('../../src/hooks/usePluginProviders')['usePluginProviders']
|
||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
||||
const useProgramStore: typeof import('../../src/store/program')['useProgramStore']
|
||||
const useRSSStore: typeof import('../../src/store/rss')['useRSSStore']
|
||||
|
||||
2
webui/types/dts/components.d.ts
vendored
2
webui/types/dts/components.d.ts
vendored
@@ -74,11 +74,13 @@ declare module '@vue/runtime-core' {
|
||||
ConfigParser: typeof import('./../../src/components/setting/config-parser.vue')['default']
|
||||
ConfigPasskey: typeof import('./../../src/components/setting/config-passkey.vue')['default']
|
||||
ConfigPlayer: typeof import('./../../src/components/setting/config-player.vue')['default']
|
||||
ConfigPlugins: typeof import('./../../src/components/setting/config-plugins.vue')['default']
|
||||
ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']
|
||||
ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']
|
||||
ConfigSecurity: typeof import('./../../src/components/setting/config-security.vue')['default']
|
||||
LlmAuthDialog: typeof import('./../../src/components/setting/llm-auth-dialog.vue')['default']
|
||||
MediaQuery: typeof import('./../../src/components/media-query.vue')['default']
|
||||
PluginSchemaForm: typeof import('./../../src/components/setting/plugin-schema-form.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
UpdateCard: typeof import('./../../src/components/setting/update-card.vue')['default']
|
||||
|
||||
60
webui/types/plugins.ts
Normal file
60
webui/types/plugins.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** 插件 config_model 生成的 JSON Schema(只用到表单渲染需要的子集) */
|
||||
export interface JsonSchemaProperty {
|
||||
type?:
|
||||
| 'string'
|
||||
| 'integer'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'null';
|
||||
title?: string;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: (string | number)[];
|
||||
format?: string;
|
||||
/** ab_sdk.secret_field() 标记的秘密字段 */
|
||||
secret?: boolean;
|
||||
writeOnly?: boolean;
|
||||
items?: JsonSchemaProperty;
|
||||
anyOf?: JsonSchemaProperty[];
|
||||
$ref?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
|
||||
export interface JsonSchema {
|
||||
title?: string;
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
$defs?: Record<string, JsonSchemaProperty>;
|
||||
}
|
||||
|
||||
export type PluginState = 'active' | 'disabled' | 'error';
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
source: 'builtin' | 'local' | 'pip';
|
||||
signed: boolean;
|
||||
state: PluginState;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
error: string | null;
|
||||
config_schema: JsonSchema | null;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginsOverview {
|
||||
allow_unsigned: boolean;
|
||||
plugins: PluginInfo[];
|
||||
}
|
||||
|
||||
/** 插件提供的 Provider id,按扩展点分组 */
|
||||
export interface PluginProviders {
|
||||
downloader: string[];
|
||||
notifier: string[];
|
||||
search_site: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user