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:
Claude
2026-10-08 05:59:07 +00:00
parent 94da8fa741
commit 332fb451c4
55 changed files with 2459 additions and 351 deletions

View File

@@ -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`

View File

@@ -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",
]

View 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
)

View 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
View 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:
"""注销远端凭据,尽力而为;默认无操作。"""

View 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]

View 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"

View 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"

View 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

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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"

View File

@@ -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(

View 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),
)

View File

@@ -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()

View File

@@ -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",
]

View File

@@ -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:

View File

@@ -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")

View File

@@ -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:

View 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))

View File

@@ -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",
]

View File

@@ -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:

View 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)

View File

@@ -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] 插件变更回调失败")

View 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

View File

@@ -1,2 +1,2 @@
from .provider import SEARCH_CONFIG
from .provider import available_sites
from .searcher import SearchTorrent

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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")

View File

@@ -108,6 +108,7 @@ const zhSidebar = [
text: '开发者指南',
items: [
{ text: '参与贡献', link: '/dev/' },
{ text: '插件开发', link: '/dev/plugins' },
],
},
]

199
docs/dev/plugins.md Normal file
View 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/`。

View File

@@ -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` 等事件 | 模板重命名;硬链接示例插件 |

View 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
View 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;
},
};

View File

@@ -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>

View File

@@ -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 {

View 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>

View 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>

View 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;
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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',

View 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');
});
});

View File

@@ -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,
};
});

View 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([]);
});
});

View 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;
}

View File

@@ -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;

View File

@@ -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']

View File

@@ -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
View 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[];
}