diff --git a/CHANGELOG.md b/CHANGELOG.md index b0349d7f..7cffcdfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/backend/src/ab_sdk/__init__.py b/backend/src/ab_sdk/__init__.py index 5c660352..027dc47a 100644 --- a/backend/src/ab_sdk/__init__.py +++ b/backend/src/ab_sdk/__init__.py @@ -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", ] diff --git a/backend/src/ab_sdk/config.py b/backend/src/ab_sdk/config.py new file mode 100644 index 00000000..cd765a87 --- /dev/null +++ b/backend/src/ab_sdk/config.py @@ -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 + ) diff --git a/backend/src/ab_sdk/downloader.py b/backend/src/ab_sdk/downloader.py new file mode 100644 index 00000000..655f1576 --- /dev/null +++ b/backend/src/ab_sdk/downloader.py @@ -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] diff --git a/backend/src/ab_sdk/llm.py b/backend/src/ab_sdk/llm.py new file mode 100644 index 00000000..32c24340 --- /dev/null +++ b/backend/src/ab_sdk/llm.py @@ -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: + """注销远端凭据,尽力而为;默认无操作。""" diff --git a/backend/src/ab_sdk/notify.py b/backend/src/ab_sdk/notify.py new file mode 100644 index 00000000..a18bb795 --- /dev/null +++ b/backend/src/ab_sdk/notify.py @@ -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] diff --git a/backend/src/ab_sdk/points.py b/backend/src/ab_sdk/points.py new file mode 100644 index 00000000..ca25406d --- /dev/null +++ b/backend/src/ab_sdk/points.py @@ -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" diff --git a/backend/src/ab_sdk/search.py b/backend/src/ab_sdk/search.py new file mode 100644 index 00000000..0a63649f --- /dev/null +++ b/backend/src/ab_sdk/search.py @@ -0,0 +1,16 @@ +"""搜索站点契约。""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SearchSite: + """一个返回 RSS 的搜索站点。 + + ``url`` 中的 ``%s`` 会被替换为关键词(以 ``+`` 连接)。``parser`` 指定用哪个 + 元数据源补全搜索结果:``mikan`` 或 ``tmdb``。用户在搜索设置里配置的同名 + 站点优先于插件提供的站点。 + """ + + url: str + parser: str = "tmdb" diff --git a/backend/src/ab_sdk/tasks.py b/backend/src/ab_sdk/tasks.py new file mode 100644 index 00000000..2038794a --- /dev/null +++ b/backend/src/ab_sdk/tasks.py @@ -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 diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py index 10a91fe5..196e203a 100644 --- a/backend/src/module/api/config.py +++ b/backend/src/module/api/config.py @@ -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, diff --git a/backend/src/module/api/plugins.py b/backend/src/module/api/plugins.py index f1928f68..e7adb881 100644 --- a/backend/src/module/api/plugins.py +++ b/backend/src/module/api/plugins.py @@ -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) + } diff --git a/backend/src/module/api/rss.py b/backend/src/module/api/rss.py index a115a593..5152b466 100644 --- a/backend/src/module/api/rss.py +++ b/backend/src/module/api/rss.py @@ -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) diff --git a/backend/src/module/api/search.py b/backend/src/module/api/search.py index f7e18720..392f5340 100644 --- a/backend/src/module/api/search.py +++ b/backend/src/module/api/search.py @@ -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( diff --git a/backend/src/module/conf/__init__.py b/backend/src/module/conf/__init__.py index ab58794d..79707c82 100644 --- a/backend/src/module/conf/__init__.py +++ b/backend/src/module/conf/__init__.py @@ -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" diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py index 7c9834f5..94c875ab 100644 --- a/backend/src/module/core/context.py +++ b/backend/src/module/core/context.py @@ -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( diff --git a/backend/src/module/core/plugin_tasks.py b/backend/src/module/core/plugin_tasks.py new file mode 100644 index 00000000..40ceab8b --- /dev/null +++ b/backend/src/module/core/plugin_tasks.py @@ -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), + ) diff --git a/backend/src/module/core/scheduler.py b/backend/src/module/core/scheduler.py index 208eb80c..d55952d0 100644 --- a/backend/src/module/core/scheduler.py +++ b/backend/src/module/core/scheduler.py @@ -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() diff --git a/backend/src/module/downloader/base.py b/backend/src/module/downloader/base.py index 2eb9da49..bad5fe50 100644 --- a/backend/src/module/downloader/base.py +++ b/backend/src/module/downloader/base.py @@ -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", +] diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index 8652afaa..aafbffa1 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -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: diff --git a/backend/src/module/models/config.py b/backend/src/module/models/config.py index d67ac9b8..823fc7c7 100644 --- a/backend/src/module/models/config.py +++ b/backend/src/module/models/config.py @@ -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") diff --git a/backend/src/module/notification/manager.py b/backend/src/module/notification/manager.py index 7b591f10..b962ff6e 100644 --- a/backend/src/module/notification/manager.py +++ b/backend/src/module/notification/manager.py @@ -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: diff --git a/backend/src/module/notification/resolve.py b/backend/src/module/notification/resolve.py new file mode 100644 index 00000000..9b155c5d --- /dev/null +++ b/backend/src/module/notification/resolve.py @@ -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)) diff --git a/backend/src/module/parser/analyser/providers/base.py b/backend/src/module/parser/analyser/providers/base.py index da7a562b..3c6c4b2f 100644 --- a/backend/src/module/parser/analyser/providers/base.py +++ b/backend/src/module/parser/analyser/providers/base.py @@ -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", +] diff --git a/backend/src/module/parser/analyser/providers/registry.py b/backend/src/module/parser/analyser/providers/registry.py index 389a245d..f6d5cbfa 100644 --- a/backend/src/module/parser/analyser/providers/registry.py +++ b/backend/src/module/parser/analyser/providers/registry.py @@ -1,4 +1,7 @@ -"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件的统一入口。 +"""LLM 提供商注册表:内置适配器 + base_url 预设 + 已安装插件 + 扩展插件的统一入口。 + +扩展插件指通过 ``ab_sdk`` 以 ``points.LLM_PROVIDER`` 登记的适配器(随插件启停, +不缓存);已安装插件指旧的签名 LLM 插件目录(config/plugins//<版本>/)。 - ``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: diff --git a/backend/src/module/plugin/host.py b/backend/src/module/plugin/host.py new file mode 100644 index 00000000..f8f7369a --- /dev/null +++ b/backend/src/module/plugin/host.py @@ -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) diff --git a/backend/src/module/plugin/manager.py b/backend/src/module/plugin/manager.py index a323e6dc..8769c067 100644 --- a/backend/src/module/plugin/manager.py +++ b/backend/src/module/plugin/manager.py @@ -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] 插件变更回调失败") diff --git a/backend/src/module/plugin/secrets.py b/backend/src/module/plugin/secrets.py new file mode 100644 index 00000000..9f700b82 --- /dev/null +++ b/backend/src/module/plugin/secrets.py @@ -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 diff --git a/backend/src/module/searcher/__init__.py b/backend/src/module/searcher/__init__.py index 65b403e3..5e230e7e 100644 --- a/backend/src/module/searcher/__init__.py +++ b/backend/src/module/searcher/__init__.py @@ -1,2 +1,2 @@ -from .provider import SEARCH_CONFIG +from .provider import available_sites from .searcher import SearchTorrent diff --git a/backend/src/module/searcher/provider.py b/backend/src/module/searcher/provider.py index 2b7ff879..fc481c84 100644 --- a/backend/src/module/searcher/provider.py +++ b/backend/src/module/searcher/provider.py @@ -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. diff --git a/backend/src/test/test_api_rss.py b/backend/src/test/test_api_rss.py index d40eb636..46c81b23 100644 --- a/backend/src/test/test_api_rss.py +++ b/backend/src/test/test_api_rss.py @@ -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", diff --git a/backend/src/test/test_api_search.py b/backend/src/test/test_api_search.py index 0bafc421..5be81f63 100644 --- a/backend/src/test/test_api_search.py +++ b/backend/src/test/test_api_search.py @@ -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 diff --git a/backend/src/test/test_plugin_extensions.py b/backend/src/test/test_plugin_extensions.py new file mode 100644 index 00000000..76cbe715 --- /dev/null +++ b/backend/src/test/test_plugin_extensions.py @@ -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 diff --git a/backend/src/test/test_plugin_runtime.py b/backend/src/test/test_plugin_runtime.py index a9c8c884..634fd69e 100644 --- a/backend/src/test/test_plugin_runtime.py +++ b/backend/src/test/test_plugin_runtime.py @@ -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 diff --git a/backend/src/test/test_searcher.py b/backend/src/test/test_searcher.py index 74029e59..9782d58c 100644 --- a/backend/src/test/test_searcher.py +++ b/backend/src/test/test_searcher.py @@ -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") diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 146eabde..9e362af3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -108,6 +108,7 @@ const zhSidebar = [ text: '开发者指南', items: [ { text: '参与贡献', link: '/dev/' }, + { text: '插件开发', link: '/dev/plugins' }, ], }, ] diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md new file mode 100644 index 00000000..c46fb4a4 --- /dev/null +++ b/docs/dev/plugins.md @@ -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.` 前缀的 logger + - `kv`:插件私有的持久化键值存储,值须可 JSON 序列化 + - `data_dir`:`config/plugin-data//` + - `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/`。 diff --git a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md index 4a3588a6..ea544610 100644 --- a/docs/plans/2026-10-08-plugin-architecture-4.0-design.md +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -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` 等事件 | 模板重命名;硬链接示例插件 | diff --git a/webui/src/api/__tests__/plugins.test.ts b/webui/src/api/__tests__/plugins.test.ts new file mode 100644 index 00000000..d393a4bc --- /dev/null +++ b/webui/src/api/__tests__/plugins.test.ts @@ -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'); + }); +}); diff --git a/webui/src/api/plugins.ts b/webui/src/api/plugins.ts new file mode 100644 index 00000000..30ba6bcd --- /dev/null +++ b/webui/src/api/plugins.ts @@ -0,0 +1,37 @@ +import type { PluginProviders, PluginsOverview } from '#/plugins'; + +export const apiPlugins = { + /** 已发现的插件、状态、配置 schema 与(掩码后的)当前配置 */ + async list() { + const { data } = await axios.get('api/v1/plugins'); + return data; + }, + + /** 启用/停用插件或修改配置;后端保存后立即重新应用 */ + async update( + id: string, + body: { enabled?: boolean; options?: Record } + ) { + const { data } = await axios.put( + `api/v1/plugins/${encodeURIComponent(id)}`, + body + ); + return data; + }, + + async updateSettings(allowUnsigned: boolean) { + const { data } = await axios.put( + 'api/v1/plugins/settings', + { allow_unsigned: allowUnsigned } + ); + return data; + }, + + /** 插件提供的下载器 / 通知渠道 / 搜索站点 id */ + async providers() { + const { data } = await axios.get( + 'api/v1/plugins/providers' + ); + return data; + }, +}; diff --git a/webui/src/components/setting/config-download.vue b/webui/src/components/setting/config-download.vue index 5d862a96..0272d8de 100644 --- a/webui/src/components/setting/config-download.vue +++ b/webui/src/components/setting/config-download.vue @@ -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[] = [ +const items = computed[]>(() => [ { 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[] = [ label: () => t('config.downloader_set.ssl'), type: 'switch', }, -]; +]);