diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 881a5fd0..1527a50f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -325,17 +325,21 @@ jobs:
run: |
cd skills && zip -r "../autobangumi-plugin-skill-${{ needs.version-info.outputs.version }}.zip" autobangumi-plugin
- # 在线自动更新 bundle:module 源码树 + pyproject/uv.lock + 前端 dist + manifest。
- # 同时产出 .sha256(客户端下载后据此校验完整性)。min_image_version 是能应用
- # 此 bundle 布局的最低镜像版本(引入在线更新的首个版本)。
+ # 在线自动更新 bundle:module 与 ab_sdk 源码树 + pyproject/uv.lock + 前端 dist +
+ # manifest。同时产出 .sha256(客户端下载后据此校验完整性)。min_image_version
+ # 是能应用此 bundle 布局的最低镜像版本。4.x 的 module 依赖同版本的 ab_sdk 与新
+ # 依赖,只有 4.0 起的镜像(boot_overlay 会替换 /app/ab_sdk)能应用;已发布的
+ # 3.3 镜像在 beta 通道会选中最新的预发布,必须靠这个下限拒绝。本分支只发 4.x,
+ # 下一个大版本若再改 bundle 布局,在这里提高下限。
- name: Build update bundle
run: |
VERSION="${{ needs.version-info.outputs.version }}"
- MIN_IMAGE_VERSION="3.3.0-beta.1"
+ MIN_IMAGE_VERSION="4.0.0-beta.1"
STAGE="bundle_stage"
rm -rf "$STAGE"
mkdir -p "$STAGE/backend/src" "$STAGE/webui-dist"
cp -r backend/src/module "$STAGE/backend/src/module"
+ cp -r backend/src/ab_sdk "$STAGE/backend/src/ab_sdk"
cp backend/pyproject.toml backend/uv.lock "$STAGE/backend/"
cp -r webui/dist/. "$STAGE/webui-dist/"
LOCK_SHA=$(sha256sum backend/uv.lock | awk '{print $1}')
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index f2719de4..b3aed4e6 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -2,9 +2,9 @@ name: Deploy Docs
on:
push:
- # 只从开发分支部署:main 仍是 3.3 文档,两边都部署会互相覆盖。4.0 正式版后改为 main。
+ # 只从 4.0-dev 部署:main 与 3.3-dev 仍是 3.3 文档,多分支部署会互相覆盖。4.0 正式版后改为 main。
branches:
- - "*-dev"
+ - 4.0-dev
paths:
- "docs/**"
- ".github/workflows/docs.yml"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d834b27..b1e10b02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,7 +24,21 @@
- **新增(发布)**:beta 与稳定版的 GitHub Release 附带 `autobangumi-sdk` 轮子和 `autobangumi-plugin-skill-<版本>.zip`
- **修复(插件开发工具)**:插件目录里的 `.venv`、`node_modules` 等工具链目录不再被当作原生扩展(`ab-plugin validate` / `pack` / `dev` 和宿主加载此前会报「含原生扩展」),`dev_mode` 也不再监听它们
- **修复(配置迁移)**:配置里有 Bark 或 WeCom 渠道时,每次启动都会新增一份 `config.json.v3.bak.N` 并改写配置;现在只迁移带值的旧字段
+- **修复(在线更新)**:在线更新包带上 `ab_sdk`,4.0 镜像启动时与 `module` 一起替换;更新包要求镜像不低于 4.0.0-beta.1,已安装 3.3 的用户在 beta 通道不会应用 4.x 更新包(缺少 `ab_sdk` 与新依赖,应用后无法启动),需要拉取 4.0 镜像
+- **修复(多下载器)**:删除规则并删除文件时,若某个下载器不可用,规则与种子记录会保留并提示稍后重试;此前规则和种子记录已先删除,该下载器上的种子之后无法再删
+- **修复(多下载器)**:aria2 任务与番剧的关联按下载器实例区分,两个 aria2 实例分配到相同 gid 时不再互相覆盖,同一种子也可以分别加入两个实例;插件下载器的工厂参数 `DownloaderConnection` 新增 `instance_id`
+- **新增(插件目录界面)**:设置 → 插件 新增「插件目录」:浏览签名目录中的插件并安装、更新;目录安装的插件可在卡片上卸载(内置、本地、pip 与 LLM 提供商插件不提供卸载)。插件卡片标出已签名或未签名。目录不可达时在区内显示原因,不弹全局提示
+- **修复(插件设置)**:经签名目录安装的插件在插件卡片上的来源显示为未翻译的 `config.plugins_set.source_catalog`
- **修复(插件安装)**:`DELETE /api/v1/plugins/{id}` 不再能卸载 LLM 提供商插件;安装通用插件后,LLM 插件列举不再反复输出 `Skipping broken plugin` 警告
+- **修复(插件安装)**:LLM 提供商插件的卸载也校验插件 id,并且只删除 LLM 安装器装入的目录:`..`、`local` 与目录安装的通用插件会被拒绝;卸载未安装的 id 现在返回「Plugin not installed」(400),此前返回成功。`build_plugin_catalog.py` 默认 `min_ab_version` 改为 `4.0.0-beta.1`,此前的 `4.0.0` 会让 beta 版拒绝安装目录中的插件
+- **修复(插件运行时)**:清单不是 UTF-8 或 pip 插件导入失败时,该插件显示为加载失败,不再中断全部插件的发现;`setup()` 抛错或超时后调用 `teardown` 释放已占用的资源;事件订阅者成功一次即清零熔断计数,只有连续失败才熔断;停用一个加载失败的插件后状态显示为「已停用」,重新启用时会再次尝试加载;插件重载后,旧实例的熔断不再停用新实例
+- **修复(设置向导)**:向导中填写的 Bark / WeCom 凭据写入渠道实际读取的 `device_key` / `webhook_url`,此前测试通知与保存后的渠道都拿不到凭据
+- **修复(升级路径)**:首次启动时也检查 2.x 的 `data/data.json`;没有 `version.info` 的 3.0 数据库会被识别并要求先升级到 3.1.x;拒绝启动的提示写明要先把 `config.json.v3.bak` 还原为 `config.json` 再启动旧版本。配置迁移失败时把备份移回原文件,反复重启不再堆积 `config.json.v3.bak.N`
+- **修复(多下载器)**:修改规则的保存路径时,若某个下载器不可用,不移动任何种子也不修改规则,提示稍后重试;此前可用实例上的种子已移动而规则路径未更新。下载器页的批量暂停 / 恢复 / 删除按实例分别执行并汇总失败,同一 hash 在两个实例上时只操作选中的那一个;只配置了插件下载器时下载器页不再显示为未配置
+- **修复(整理)**:插件提供的版本冲突策略或文件分类出错时退回宿主实现并计入熔断,不再中断整次重命名;重命名方式的插件未加载而按 `none` 处理时不发布 `torrent.organized`;已重命名的种子在每次启动后补发一次 `torrent.organized`,此前错过该事件的种子不会再被硬链接或触发媒体库刷新(开启硬链接时,重启后会对仍在下载器中的已整理种子补链一次)
+- **修复(硬链接)**:用户从媒体库删除的硬链接文件不再被重新创建(`backfill` 仍会恢复);媒体库刷新在硬链接放好文件后再刷新一次,刷新请求进行中到达的整理事件不再丢失
+- **修复(插件配置)**:秘密字段的标记写在 `Optional` / 嵌套模型引用外层或 `list` / `dict` 容器上时,读取接口此前返回明文,现在掩码;`/config/get` 掩码插件通知渠道的自定义字段,保存时按渠道还原
+- **修复(插件设置)**:在插件卡片上切换「允许未签名插件」后,点全局保存不再把它改回旧值;保存或启停一个插件不再清掉其他卡片未保存的输入;整数枚举的下拉框保存为数字而不是字符串
- **破坏性变更(升级路径)**:只支持从 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/cli.py b/backend/src/ab_sdk/cli.py
index 9a19e663..9a5c41e9 100644
--- a/backend/src/ab_sdk/cli.py
+++ b/backend/src/ab_sdk/cli.py
@@ -6,14 +6,13 @@
import argparse
import json
import os
-import re
import sys
import zipfile
from pathlib import Path
from string import Template
from . import SDK_VERSION, points
-from .manifest import MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check
+from .manifest import ID_RE, MANIFEST_NAME, TOOLING_DIRS, PluginManifest, check
# 打包时排除的目录名与文件后缀:开发产物、测试与作者侧的工程文件
_PACK_EXCLUDE_DIRS = TOOLING_DIRS | {"tests"}
@@ -164,8 +163,6 @@ uv run ab-plugin pack . # 打包为 dist/$id-<版本>.zip
```
""")
-_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
-
def scaffold(target: Path, plugin_id: str, kind: str) -> list[Path]:
"""在 ``target`` 下创建插件骨架,返回创建的文件。"""
@@ -265,7 +262,7 @@ def link_dev(plugin_dir: Path, config_dir: Path, manifest: PluginManifest) -> Pa
def _cmd_new(args: argparse.Namespace) -> int:
- if not _ID_RE.match(args.id):
+ if not ID_RE.match(args.id):
print("错误:id 只能由小写字母、数字和连字符组成", file=sys.stderr)
return 1
target = Path(args.dir) / args.id
diff --git a/backend/src/ab_sdk/downloader.py b/backend/src/ab_sdk/downloader.py
index 655f1576..5ed9a54a 100644
--- a/backend/src/ab_sdk/downloader.py
+++ b/backend/src/ab_sdk/downloader.py
@@ -4,7 +4,7 @@
:data:`DownloaderFactory`:接收连接参数,返回实现 :class:`DownloaderClient`
(至少 :class:`CoreDownloaderClient`)的对象。不同下载器能力不同,通过
``capabilities`` 类属性声明,门面会跳过不支持的操作而不是报错。
-用户在设置里把 ``downloader.type`` 设为该 id 即可启用。
+用户在设置里把下载器实例的 ``provider`` 设为该 id 即可启用。
"""
from collections.abc import Callable
@@ -144,6 +144,8 @@ class DownloaderConnection:
username: str
password: str
ssl: bool
+ # 实例 id(``plugins.instances[].id``),供需要按实例保存本地状态的下载器使用
+ instance_id: str = "default"
DownloaderFactory = Callable[[DownloaderConnection], CoreDownloaderClient]
diff --git a/backend/src/ab_sdk/events.py b/backend/src/ab_sdk/events.py
index f16f6672..eddf8752 100644
--- a/backend/src/ab_sdk/events.py
+++ b/backend/src/ab_sdk/events.py
@@ -31,7 +31,7 @@ class PluginDisabled(Event):
reason: str
-# --- P5:系统事件 -------------------------------------------------------------
+# --- 系统事件 -------------------------------------------------------------
#
# 以下事件由宿主经 ``NotificationManager.send_event`` 发出:先写入站内通知中心,
# 再发布到事件总线(插件可 ``@subscribe(kind)``),最后推送到外部通知渠道。
@@ -377,7 +377,7 @@ class RenameSkippedEvent(SystemEvent):
)
-# --- P4:organize 流水线事件(只发布到事件总线,不进通知中心) -------------------
+# --- organize 流水线事件(只发布到事件总线,不进通知中心) -------------------
#
# 路径均为下载器视角的绝对路径:保存目录与种子内相对路径以 "/" 拼接
# (Windows 下载器的 "\" 也统一为 "/")。AutoBangumi 与下载器不在同一文件系统
@@ -406,9 +406,9 @@ class OrganizedFile:
class TorrentOrganized(Event):
"""一个种子整理完成:顶层正片都已在最终位置(重命名方式为 none 时即原位置)。
- ``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:未打
- ``ab:renamed`` 标签的种子(如重命名方式为 none)每次进程重启后会再发布一次,
- 订阅者须保证幂等。
+ ``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:下载器中已整理
+ 的种子(含已打 ``ab:renamed`` 标签的)每次进程重启后会再发布一次,订阅者
+ 须保证幂等。选中的重命名方式未登记时不发布:原名不是最终文件名。
"""
kind: ClassVar[str] = "torrent.organized"
diff --git a/backend/src/ab_sdk/manifest.py b/backend/src/ab_sdk/manifest.py
index 2ba7bc42..b4eebd2e 100644
--- a/backend/src/ab_sdk/manifest.py
+++ b/backend/src/ab_sdk/manifest.py
@@ -186,7 +186,7 @@ def load_manifest(plugin_dir: Path) -> PluginManifest:
path = plugin_dir / MANIFEST_NAME
try:
text = path.read_text(encoding="utf-8")
- except OSError as e:
+ except (OSError, UnicodeDecodeError) as e:
raise ManifestError(f"{path}: 无法读取:{e}") from e
return parse_manifest(text, str(path))
diff --git a/backend/src/ab_sdk/points.py b/backend/src/ab_sdk/points.py
index 2075b115..fc971a83 100644
--- a/backend/src/ab_sdk/points.py
+++ b/backend/src/ab_sdk/points.py
@@ -3,7 +3,7 @@
Provider 扩展点(``@provider``):
- ``DOWNLOADER``:返回 :data:`ab_sdk.downloader.DownloaderFactory`,id 即
- ``downloader.type`` 的取值
+ 下载器实例的 ``provider``(``plugins.instances[].provider``)
- ``NOTIFIER``:返回 :data:`ab_sdk.notify.NotifierFactory`,id 即通知渠道的
``type``
- ``LLM_PROVIDER``:返回 :class:`ab_sdk.llm.LLMProviderAdapter` 子类
@@ -26,14 +26,14 @@ LLM_PROVIDER = "llm_provider"
SEARCH_SITE = "search_site"
SCHEDULED_TASK = "scheduled_task"
-# --- P3 ingest ---
+# --- ingest ---
METADATA_PROVIDER = "metadata_provider"
TORRENT_FILTER = "torrent.filter"
TITLE_PARSED = "title.parsed"
TORRENT_ADDING = "torrent.adding"
HTTP_REQUEST = "http.request"
-# --- P5:外部接口与通知模板 ---
+# --- 外部接口与通知模板 ---
#
# Provider 扩展点(id 只需在插件内唯一,宿主对外暴露时加插件 id 前缀):
#
@@ -55,7 +55,7 @@ MCP_TOOL = "mcp_tool"
MCP_RESOURCE = "mcp_resource"
MESSAGE_TEMPLATE = "message_template"
-# --- P4 organize(契约见 :mod:`ab_sdk.rename`) ---
+# --- organize(契约见 :mod:`ab_sdk.rename`) ---
#
# Provider 扩展点:
#
diff --git a/backend/src/boot_overlay.py b/backend/src/boot_overlay.py
index 0d671f17..d0bd8a49 100644
--- a/backend/src/boot_overlay.py
+++ b/backend/src/boot_overlay.py
@@ -9,7 +9,8 @@
CI 签名的 ``bundle.zip`` + ``bundle.zip.sig``。因此每次启动都重新验签留存的
zip,并且 module 树 / 前端 dist **直接从验签通过的 zip 解包**,绝不从 ab 可写
的 ``current/`` 目录复制——否则拿到 ab 权限的攻击者伪造 applied.json/current
-即可让 root 把任意代码落到 /app 并跨镜像升级持久化。
+即可让 root 把任意代码落到 /app 并跨镜像升级持久化。``ab_sdk`` 与 module
+同理(4.0 起 bundle 带它,module 依赖同版本的 SDK)。
版本比较也以 zip 内 manifest 为准:覆盖层版本高于镜像基线(``/app/
IMAGE_VERSION``)才应用,否则清除过期覆盖层,镜像版本生效。
@@ -395,17 +396,24 @@ def apply_overlay(
logger.error("Failed to unpack verified bundle: %s", exc)
return False
- src_module = tmp / "backend" / "src" / "module"
- if not src_module.exists():
- logger.warning("Verified bundle has no module tree; skipping.")
+ # module 依赖同版本的 ab_sdk,两棵树缺一不可:只换其一会在启动时 ImportError。
+ # ponytail: 两次替换不是一个事务,ab_sdk 换完后 module 失败会留下新 SDK +
+ # 旧 module;真要原子就得把两棵树放进同一个目录一起 rename。
+ trees = [
+ (tmp / "backend" / "src" / name, app_root / name)
+ for name in ("ab_sdk", "module")
+ ]
+ if not all(src.exists() for src, _ in trees):
+ logger.warning("Verified bundle lacks the module or ab_sdk tree; skipping.")
return False
- try:
- _replace_tree(src_module, app_root / "module")
- except Exception as exc:
- logger.error("Failed to overlay module tree: %s", exc)
- return False
- _chown_app_tree(app_root / "module")
+ for src, dst in trees:
+ try:
+ _replace_tree(src, dst)
+ except Exception as exc:
+ logger.error("Failed to overlay %s tree: %s", dst.name, exc)
+ return False
+ _chown_app_tree(dst)
src_dist = tmp / "webui-dist"
if src_dist.exists():
diff --git a/backend/src/module/api/config.py b/backend/src/module/api/config.py
index bfe23f89..70b0f674 100644
--- a/backend/src/module/api/config.py
+++ b/backend/src/module/api/config.py
@@ -1,5 +1,6 @@
import asyncio
import logging
+from collections.abc import Callable
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
@@ -8,6 +9,7 @@ from pydantic import BaseModel
from module.conf import settings
from module.core import AppContext
from module.models import APIResponse, Config
+from module.models.config import NotificationProvider
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
@@ -21,21 +23,35 @@ _SENSITIVE_KEYS = ("password", "api_key", "token", "secret")
_MASK = "********"
+# 通知渠道模型声明的字段;其余字段是插件渠道自带的配置,没有 schema 可判断
+# 哪些是秘密,与无 schema 的插件选项一样全部按秘密处理
+_PROVIDER_FIELDS = frozenset(
+ f.alias or k for k, f in NotificationProvider.model_fields.items()
+)
+
+Sensitive = Callable[[str], bool]
+
+
def _is_sensitive(key: str) -> bool:
return any(s in key.lower() for s in _SENSITIVE_KEYS)
-def _sanitize_dict(d: dict) -> dict:
+def _is_provider_secret(key: str) -> bool:
+ return _is_sensitive(key) or key not in _PROVIDER_FIELDS
+
+
+def _sanitize_dict(d: dict, sensitive: Sensitive = _is_sensitive) -> dict:
"""Recursively mask string values whose keys contain sensitive keywords."""
result: dict = {}
for k, v in d.items():
if isinstance(v, dict):
- result[k] = _sanitize_dict(v)
+ result[k] = _sanitize_dict(v, sensitive)
elif isinstance(v, list):
result[k] = [
- _sanitize_dict(item) if isinstance(item, dict) else item for item in v
+ _sanitize_dict(item, sensitive) if isinstance(item, dict) else item
+ for item in v
]
- elif isinstance(v, str) and v and _is_sensitive(k):
+ elif isinstance(v, str) and v and sensitive(k):
# 空值不打掩码:否则未设置密码的字段在前端永远显示一串幻影
# 掩码,用户误以为存了密码(并可能因此反复"删除保存")。
result[k] = _MASK
@@ -48,13 +64,13 @@ class MaskRestoreError(ValueError):
"""无法为掩码密钥可靠定位旧值来源(列表项被删/重排且身份字段同时被改)。"""
-def _identity(d: dict) -> tuple:
+def _identity(d: dict, sensitive: Sensitive) -> tuple:
"""列表项的身份 = 全部非敏感标量字段(敏感侧是掩码,无法参与匹配)。"""
return tuple(
sorted(
(k, v)
for k, v in d.items()
- if not isinstance(v, (dict, list)) and not _is_sensitive(k)
+ if not isinstance(v, (dict, list)) and not sensitive(k)
)
)
@@ -67,7 +83,9 @@ def _contains_mask(value) -> bool:
return value == _MASK
-def _restore_masked_list(incoming: list, current: list) -> None:
+def _restore_masked_list(
+ incoming: list, current: list, sensitive: Sensitive = _is_sensitive
+) -> None:
"""按身份匹配列表项后再恢复各自的掩码密钥。
掩码恢复不能盲目按下标:删掉第 0 个通知渠道后,幸存渠道的 ``********``
@@ -89,7 +107,9 @@ def _restore_masked_list(incoming: list, current: list) -> None:
def match_unique_identity(i: int) -> None:
candidates = [
- j for j in unconsumed if _identity(current[j]) == _identity(incoming[i])
+ j
+ for j in unconsumed
+ if _identity(current[j], sensitive) == _identity(incoming[i], sensitive)
]
if len(candidates) == 1:
matched[i] = candidates[0]
@@ -97,7 +117,9 @@ def _restore_masked_list(incoming: list, current: list) -> None:
if len(incoming) == len(current):
for i in masked:
- if i in unconsumed and _identity(incoming[i]) == _identity(current[i]):
+ if i in unconsumed and _identity(incoming[i], sensitive) == _identity(
+ current[i], sensitive
+ ):
matched[i] = i
unconsumed.discard(i)
for i in masked:
@@ -113,7 +135,7 @@ def _restore_masked_list(incoming: list, current: list) -> None:
for i in masked:
if i in matched:
- _restore_masked(incoming[i], current[matched[i]])
+ _restore_masked(incoming[i], current[matched[i]], sensitive)
else:
raise MaskRestoreError(
f"cannot determine which stored entry list item #{i} refers to; "
@@ -121,14 +143,16 @@ def _restore_masked_list(incoming: list, current: list) -> None:
)
-def _restore_masked(incoming: dict, current: dict) -> dict:
+def _restore_masked(
+ incoming: dict, current: dict, sensitive: Sensitive = _is_sensitive
+) -> dict:
"""Replace masked sentinel values with real values from current config."""
for k, v in incoming.items():
if isinstance(v, dict) and isinstance(current.get(k), dict):
- _restore_masked(v, current[k])
+ _restore_masked(v, current[k], sensitive)
elif isinstance(v, list) and isinstance(current.get(k), list):
- _restore_masked_list(v, current[k])
- elif v == _MASK and _is_sensitive(k):
+ _restore_masked_list(v, current[k], sensitive)
+ elif v == _MASK and sensitive(k):
incoming[k] = current.get(k, v)
return incoming
@@ -145,6 +169,10 @@ async def get_config(ctx: AppContext = Depends(get_context)):
options = config["plugins"]["options"]
for plugin_id, plugin_options in options.items():
options[plugin_id] = mask_options(plugin_options, schemas.get(plugin_id))
+ notification = config["notification"]
+ notification["providers"] = [
+ _sanitize_dict(p, _is_provider_secret) for p in notification["providers"]
+ ]
return _sanitize_dict(config)
@@ -165,6 +193,11 @@ async def update_config(config: Config, ctx: AppContext = Depends(get_context)):
current_options.get(plugin_id, {}),
schemas.get(plugin_id),
)
+ _restore_masked_list(
+ config_dict["notification"]["providers"],
+ current.get("notification", {}).get("providers", []),
+ _is_provider_secret,
+ )
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)
diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py
index 0de376bd..29c8337d 100644
--- a/backend/src/module/api/setup.py
+++ b/backend/src/module/api/setup.py
@@ -21,6 +21,7 @@ from module.network import RequestContent
from module.notification import PROVIDER_REGISTRY
from module.security.api import CredentialKind, get_auth_service, get_principal
from module.security.password import verify_password
+from module.update.v4 import migrate_v3_dict
from .deps import get_context
@@ -32,6 +33,14 @@ AuthService = Annotated[AuthenticationService, Depends(get_auth_service)]
SENTINEL_PATH = Path("config/.setup_complete")
+def _wizard_provider(type_: str, token: str, chat_id: str) -> dict:
+ """向导只有通用的 token / chat_id 输入,按渠道改写到实际读取的字段
+ (Bark 的 device_key、WeCom 的 webhook_url)。"""
+ provider = {"type": type_, "enabled": True, "token": token, "chat_id": chat_id}
+ migrate_v3_dict({"notification": {"providers": [provider]}})
+ return provider
+
+
def _require_setup_needed():
"""Guard: raise 403 if setup is already completed."""
if SENTINEL_PATH.exists():
@@ -351,11 +360,8 @@ async def test_notification(req: TestNotificationRequest):
try:
# Create provider config
- config = ProviderConfig(
- type=req.type,
- enabled=True,
- token=req.token,
- chat_id=req.chat_id,
+ config = ProviderConfig.model_validate(
+ _wizard_provider(req.type, req.token, req.chat_id)
)
provider = provider_cls(config)
async with provider:
@@ -422,12 +428,11 @@ async def complete_setup(
config_dict["notification"] = {
"enable": True,
"providers": [
- {
- "type": req.notification_type,
- "enabled": True,
- "token": req.notification_token,
- "chat_id": req.notification_chat_id,
- }
+ _wizard_provider(
+ req.notification_type,
+ req.notification_token,
+ req.notification_chat_id,
+ )
],
}
diff --git a/backend/src/module/core/context.py b/backend/src/module/core/context.py
index 3da95aaa..9bdd984e 100644
--- a/backend/src/module/core/context.py
+++ b/backend/src/module/core/context.py
@@ -25,6 +25,7 @@ from module.rss import RSSAnalyser
from module.searcher.searcher import reset_cache as reset_poster_cache
from module.update.auth import migrate_legacy_auth_tokens
from module.update.startup import cache_image, first_run, run_migrations
+from module.update.version_check import refuse_legacy_data
from .loops import (
calendar_tick,
@@ -219,6 +220,7 @@ class AppContext:
if self._startup_done:
return
self._start_info()
+ refuse_legacy_data()
if not Checker.check_database():
await first_run()
await migrate_legacy_auth_tokens()
diff --git a/backend/src/module/database/aria2.py b/backend/src/module/database/aria2.py
index 139ef030..52a5d713 100644
--- a/backend/src/module/database/aria2.py
+++ b/backend/src/module/database/aria2.py
@@ -52,8 +52,16 @@ class Aria2RenameIntent:
class Aria2GidDatabase:
- def __init__(self, session: AsyncSession):
+ """一个 aria2 实例的 gid 映射,所有读写都限定在 ``downloader_id`` 内。"""
+
+ def __init__(self, session: AsyncSession, downloader_id: str):
self.session = session
+ self.downloader_id = downloader_id
+
+ async def _get(self, gid: str) -> Aria2Gid | None:
+ return await self.session.get(
+ Aria2Gid, {"downloader_id": self.downloader_id, "gid": gid}
+ )
async def upsert(
self,
@@ -64,10 +72,11 @@ class Aria2GidDatabase:
renamed_paths: str | None = None,
) -> None:
"""新增一条 gid 记录,或者用非空字段覆盖已有记录。"""
- existing = await self.session.get(Aria2Gid, gid)
+ existing = await self._get(gid)
if existing is None:
self.session.add(
Aria2Gid(
+ downloader_id=self.downloader_id,
gid=gid,
bangumi_id=bangumi_id,
category=category,
@@ -88,20 +97,26 @@ class Aria2GidDatabase:
await self.session.commit()
async def get(self, gid: str) -> Aria2Gid | None:
- return await self.session.get(Aria2Gid, gid)
+ return await self._get(gid)
async def get_many(self, gids: list[str]) -> dict[str, Aria2Gid]:
if not gids:
return {}
result = await self.session.execute(
- select(Aria2Gid).where(Aria2Gid.gid.in_(gids)) # type: ignore[attr-defined]
+ select(Aria2Gid).where(
+ Aria2Gid.downloader_id == self.downloader_id, # type: ignore[arg-type]
+ Aria2Gid.gid.in_(gids), # type: ignore[attr-defined]
+ )
)
return {row.gid: row for row in result.scalars().all()}
async def find_by_dedup_key(self, dedup_key: str) -> str | None:
"""返回携带该 dedup_key 的已有 gid(没有则 None),用于新增前判重。"""
result = await self.session.execute(
- select(Aria2Gid.gid).where(Aria2Gid.dedup_key == dedup_key) # type: ignore[arg-type]
+ select(Aria2Gid.gid).where(
+ Aria2Gid.downloader_id == self.downloader_id, # type: ignore[arg-type]
+ Aria2Gid.dedup_key == dedup_key, # type: ignore[arg-type]
+ )
)
return result.scalars().first()
@@ -112,13 +127,14 @@ class Aria2GidDatabase:
"""Move local metadata from an aria2 metadata gid to its followedBy gid."""
if old_gid == new_gid:
return
- old = await self.session.get(Aria2Gid, old_gid)
+ old = await self._get(old_gid)
if old is None:
return
- existing = await self.session.get(Aria2Gid, new_gid)
+ existing = await self._get(new_gid)
if existing is None:
self.session.add(
Aria2Gid(
+ downloader_id=self.downloader_id,
gid=new_gid,
bangumi_id=old.bangumi_id,
category=old.category,
@@ -145,7 +161,7 @@ class Aria2GidDatabase:
await self.session.commit()
async def get_renamed_paths(self, gid: str) -> dict[str, str]:
- record = await self.session.get(Aria2Gid, gid)
+ record = await self._get(gid)
if record is None or not record.renamed_paths:
return {}
try:
@@ -166,7 +182,7 @@ class Aria2GidDatabase:
await self.upsert(gid, renamed_paths=json.dumps(mapping, ensure_ascii=False))
async def get_rename_intent(self, gid: str) -> Aria2RenameIntent | None:
- record = await self.session.get(Aria2Gid, gid)
+ record = await self._get(gid)
if record is None or not record.rename_intent:
return None
intent = Aria2RenameIntent.from_json(record.rename_intent)
@@ -175,9 +191,9 @@ class Aria2GidDatabase:
return intent
async def set_rename_intent(self, gid: str, intent: Aria2RenameIntent) -> None:
- record = await self.session.get(Aria2Gid, gid)
+ record = await self._get(gid)
if record is None:
- record = Aria2Gid(gid=gid)
+ record = Aria2Gid(downloader_id=self.downloader_id, gid=gid)
record.rename_intent = intent.to_json()
self.session.add(record)
await self.session.commit()
@@ -187,7 +203,7 @@ class Aria2GidDatabase:
gid: str,
expected: Aria2RenameIntent | None = None,
) -> bool:
- record = await self.session.get(Aria2Gid, gid)
+ record = await self._get(gid)
if record is None:
return False
if (
@@ -204,7 +220,7 @@ class Aria2GidDatabase:
self, gid: str, expected: Aria2RenameIntent
) -> bool:
"""Commit the sidecar mapping and clear its matching intent together."""
- record = await self.session.get(Aria2Gid, gid)
+ record = await self._get(gid)
if (
record is None
or Aria2RenameIntent.from_json(record.rename_intent) != expected
@@ -222,7 +238,7 @@ class Aria2GidDatabase:
return True
async def delete(self, gid: str) -> None:
- existing = await self.session.get(Aria2Gid, gid)
+ existing = await self._get(gid)
if existing is not None:
await self.session.delete(existing)
await self.session.commit()
diff --git a/backend/src/module/database/combine.py b/backend/src/module/database/combine.py
index 48c4ce55..3afd79d5 100644
--- a/backend/src/module/database/combine.py
+++ b/backend/src/module/database/combine.py
@@ -46,13 +46,16 @@ class Database:
self.bangumi = BangumiDatabase(self.session)
self.movie = MovieDatabase(self.session)
self.user = UserDatabase(self.session)
- self.aria2 = Aria2GidDatabase(self.session)
self.auth = AuthDatabase(self.session)
self.inbox = InboxDatabase(self.session)
self.llm_credential = LLMCredentialDatabase(self.session)
self.rename_operation = RenameOperationDatabase(self.session)
self.plugin_kv = PluginKVDatabase(self.session)
+ def aria2(self, downloader_id: str) -> Aria2GidDatabase:
+ """某个 aria2 实例的 gid 映射(gid 只在实例内唯一)。"""
+ return Aria2GidDatabase(self.session, downloader_id)
+
async def __aenter__(self):
return self
diff --git a/backend/src/module/database/migrations.py b/backend/src/module/database/migrations.py
index cb5d0878..6d019671 100644
--- a/backend/src/module/database/migrations.py
+++ b/backend/src/module/database/migrations.py
@@ -832,6 +832,32 @@ MIGRATIONS: tuple[Migration, ...] = (
for table, column in _DOWNLOADER_ID_COLUMNS.items()
),
),
+ Migration(
+ 27,
+ "scope aria2_gid by downloader instance",
+ # SQLite 不能修改主键,重建表;存量映射都属于 3.3 的下载器,即实例 default
+ (
+ """CREATE TABLE aria2_gid_new (
+ downloader_id VARCHAR NOT NULL,
+ gid VARCHAR NOT NULL,
+ bangumi_id INTEGER REFERENCES bangumi(id),
+ category VARCHAR,
+ dedup_key VARCHAR,
+ renamed_paths TEXT DEFAULT NULL,
+ rename_intent TEXT DEFAULT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (downloader_id, gid)
+ )""",
+ "INSERT INTO aria2_gid_new (downloader_id, gid, bangumi_id, category, "
+ "dedup_key, renamed_paths, rename_intent, created_at) "
+ "SELECT 'default', gid, bangumi_id, category, dedup_key, renamed_paths, "
+ "rename_intent, created_at FROM aria2_gid",
+ "DROP TABLE aria2_gid",
+ "ALTER TABLE aria2_gid_new RENAME TO aria2_gid",
+ "CREATE INDEX ix_aria2_gid_dedup_key ON aria2_gid(dedup_key)",
+ ),
+ _has_downloader_id("aria2_gid"),
+ ),
)
# 由迁移列表派生,新增迁移时无需手动同步
diff --git a/backend/src/module/downloader/client/aria2_downloader.py b/backend/src/module/downloader/client/aria2_downloader.py
index 607d1238..52bd9327 100644
--- a/backend/src/module/downloader/client/aria2_downloader.py
+++ b/backend/src/module/downloader/client/aria2_downloader.py
@@ -72,8 +72,10 @@ class Aria2Downloader:
can_rss_rules=False,
)
- def __init__(self, host: str, username: str, password: str):
+ def __init__(self, host: str, username: str, password: str, instance_id: str):
self.host = host
+ # gid 只在 aria2 实例内唯一,本地映射按实例 id 区分
+ self.instance_id = instance_id
self.secret = password
self._client: httpx.AsyncClient | None = None
self._authed = False
@@ -302,7 +304,7 @@ class Aria2Downloader:
"removing and re-adding",
gid,
)
- await db.aria2.delete(gid)
+ await db.aria2(self.instance_id).delete(gid)
return True
logger.debug("Cannot verify gid %s, keeping record: %s", gid, e)
return None
@@ -320,7 +322,7 @@ class Aria2Downloader:
"removing and re-adding",
gid,
)
- await db.aria2.delete(gid)
+ await db.aria2(self.instance_id).delete(gid)
return True
return False
@@ -387,7 +389,9 @@ class Aria2Downloader:
)
for url in urls:
dedup_key = f"url:{url}"
- existing_gid = await db.aria2.find_by_dedup_key(dedup_key)
+ existing_gid = await db.aria2(self.instance_id).find_by_dedup_key(
+ dedup_key
+ )
if existing_gid:
stale = await self._dedup_record_is_stale(db, existing_gid)
if stale is None:
@@ -405,7 +409,9 @@ class Aria2Downloader:
failed_any = True
continue
gid = await self._resolve_followed_by_gid(gid)
- await db.aria2.upsert(gid, bangumi_id, category, dedup_key)
+ await db.aria2(self.instance_id).upsert(
+ gid, bangumi_id, category, dedup_key
+ )
added_any = True
if torrent_files:
files = (
@@ -415,7 +421,9 @@ class Aria2Downloader:
)
for f in files:
dedup_key = f"file:{hashlib.sha1(f).hexdigest()}"
- existing_gid = await db.aria2.find_by_dedup_key(dedup_key)
+ existing_gid = await db.aria2(self.instance_id).find_by_dedup_key(
+ dedup_key
+ )
if existing_gid:
stale = await self._dedup_record_is_stale(db, existing_gid)
if stale is None:
@@ -433,7 +441,9 @@ class Aria2Downloader:
failed_any = True
continue
gid = await self._resolve_followed_by_gid(gid)
- await db.aria2.upsert(gid, bangumi_id, category, dedup_key)
+ await db.aria2(self.instance_id).upsert(
+ gid, bangumi_id, category, dedup_key
+ )
added_any = True
# 部分成功按 ADDED 报告(与 qB 的批量语义一致):已经真正开始下载
# 的任务必须让上层入库,否则会与 aria2 状态脱节;全部失败才算 FAILED。
@@ -467,8 +477,8 @@ class Aria2Downloader:
gids = [followed_by.get(d["gid"], d["gid"]) for d in raw if d.get("gid")]
async with Database() as db:
for old_gid, new_gid in followed_by.items():
- await db.aria2.replace_gid(old_gid, new_gid)
- meta = await db.aria2.get_many(gids)
+ await db.aria2(self.instance_id).replace_gid(old_gid, new_gid)
+ meta = await db.aria2(self.instance_id).get_many(gids)
allowed_statuses = (
_STATUS_FILTER_MAP.get(status_filter) if status_filter else None
@@ -560,7 +570,9 @@ class Aria2Downloader:
except (Aria2RpcError, Aria2ConnectionError) as e:
logger.debug("Could not resolve dir for %s: %s", torrent_hash, e)
async with Database() as db:
- renamed_paths = await db.aria2.get_renamed_paths(torrent_hash)
+ renamed_paths = await db.aria2(self.instance_id).get_renamed_paths(
+ torrent_hash
+ )
result = []
for f in files or []:
path = f.get("path", "")
@@ -598,7 +610,9 @@ class Aria2Downloader:
asyncio.to_thread(os.path.exists, new_abs),
)
async with Database() as db:
- persisted_intent = await db.aria2.get_rename_intent(torrent_hash)
+ persisted_intent = await db.aria2(self.instance_id).get_rename_intent(
+ torrent_hash
+ )
if not old_exists:
matching_intent = (
@@ -611,7 +625,7 @@ class Aria2Downloader:
if not new_exists:
if matching_intent is not None:
async with Database() as db:
- await db.aria2.clear_rename_intent(
+ await db.aria2(self.instance_id).clear_rename_intent(
torrent_hash, matching_intent
)
return RenameResult(
@@ -632,13 +646,15 @@ class Aria2Downloader:
return RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(e))
if not self._intent_matches_stat(matching_intent, target_stat):
async with Database() as db:
- await db.aria2.clear_rename_intent(torrent_hash, matching_intent)
+ await db.aria2(self.instance_id).clear_rename_intent(
+ torrent_hash, matching_intent
+ )
return RenameResult(
RenameOutcome.DESTINATION_EXISTS,
detail=f"destination does not match durable rename intent: {new_abs}",
)
async with Database() as db:
- finalized = await db.aria2.finalize_rename_intent(
+ finalized = await db.aria2(self.instance_id).finalize_rename_intent(
torrent_hash, matching_intent
)
if not finalized:
@@ -654,12 +670,14 @@ class Aria2Downloader:
return RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(e))
intent = self._rename_intent(old_path, new_path, source_stat)
async with Database() as db:
- await db.aria2.set_rename_intent(torrent_hash, intent)
+ await db.aria2(self.instance_id).set_rename_intent(torrent_hash, intent)
try:
await asyncio.to_thread(self._move_file, old_abs, new_abs)
except FileExistsError:
async with Database() as db:
- await db.aria2.clear_rename_intent(torrent_hash, intent)
+ await db.aria2(self.instance_id).clear_rename_intent(
+ torrent_hash, intent
+ )
logger.warning(
"Refusing to overwrite existing file %s, rename of %s skipped",
new_abs,
@@ -671,7 +689,9 @@ class Aria2Downloader:
)
except OSError as e:
async with Database() as db:
- await db.aria2.clear_rename_intent(torrent_hash, intent)
+ await db.aria2(self.instance_id).clear_rename_intent(
+ torrent_hash, intent
+ )
logger.warning("Failed to rename file %s -> %s: %s", old_abs, new_abs, e)
return RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(e))
try:
@@ -683,13 +703,17 @@ class Aria2Downloader:
return RenameResult(RenameOutcome.RETRYABLE_FAILURE, detail=str(e))
if not self._intent_matches_stat(intent, target_stat):
async with Database() as db:
- await db.aria2.clear_rename_intent(torrent_hash, intent)
+ await db.aria2(self.instance_id).clear_rename_intent(
+ torrent_hash, intent
+ )
return RenameResult(
RenameOutcome.DESTINATION_EXISTS,
detail=f"renamed target identity changed unexpectedly: {new_abs}",
)
async with Database() as db:
- finalized = await db.aria2.finalize_rename_intent(torrent_hash, intent)
+ finalized = await db.aria2(self.instance_id).finalize_rename_intent(
+ torrent_hash, intent
+ )
if not finalized:
return RenameResult(
RenameOutcome.RETRYABLE_FAILURE,
@@ -720,7 +744,7 @@ class Aria2Downloader:
# The task disappeared after a previous successful
# cleanup (or was removed externally). There is no
# downloader-owned file list left to resolve.
- await db.aria2.delete(gid)
+ await db.aria2(self.instance_id).delete(gid)
continue
logger.warning(
"Cannot safely delete current files for %s: %s", gid, e
@@ -737,7 +761,9 @@ class Aria2Downloader:
ok = False
continue
- renamed_paths = await db.aria2.get_renamed_paths(gid)
+ renamed_paths = await db.aria2(self.instance_id).get_renamed_paths(
+ gid
+ )
files_removed = True
for f in files or []:
raw_path = f.get("path")
@@ -797,7 +823,7 @@ class Aria2Downloader:
# may still own the task; delete only after confirmed removal
# or an explicit not-found response.
if removed:
- await db.aria2.delete(gid)
+ await db.aria2(self.instance_id).delete(gid)
return ok
async def torrents_pause(self, hashes: str | list[str]) -> None:
@@ -846,7 +872,7 @@ class Aria2Downloader:
async def set_category(self, _hash, category) -> None:
async with Database() as db:
for gid in self._normalize_hashes(_hash):
- await db.aria2.set_category(gid, category)
+ await db.aria2(self.instance_id).set_category(gid, category)
async def add_tag(self, _hash: str, tag: str) -> None:
"""只认识 'ab:' 格式的 tag(用于 offset 关联),其余忽略。"""
@@ -855,4 +881,4 @@ class Aria2Downloader:
logger.debug("Ignoring unsupported tag: %s", tag)
return
async with Database() as db:
- await db.aria2.upsert(_hash, bangumi_id=bangumi_id)
+ await db.aria2(self.instance_id).upsert(_hash, bangumi_id=bangumi_id)
diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py
index 76388acb..5c8081c4 100644
--- a/backend/src/module/downloader/download_client.py
+++ b/backend/src/module/downloader/download_client.py
@@ -248,6 +248,7 @@ class DownloadClient:
username=instance.username,
password=instance.password,
ssl=instance.ssl,
+ instance_id=instance.id,
)
# 只实现 CoreDownloaderClient 的后端(如 aria2 没有 qB 的 RSS 规则)
# 由 _supports() 按 capabilities 跳过不支持的操作
diff --git a/backend/src/module/downloader/path.py b/backend/src/module/downloader/path.py
index 98b71141..fb02e5d9 100644
--- a/backend/src/module/downloader/path.py
+++ b/backend/src/module/downloader/path.py
@@ -2,6 +2,7 @@ import logging
import re
from os import PathLike
from pathlib import PureWindowsPath
+from typing import get_args
from ab_sdk import points
from ab_sdk.rename import CORE_ID, MediaFiles, MediaKind
@@ -24,6 +25,7 @@ else:
_MEDIA_SUFFIXES = frozenset({".mp4", ".mkv"})
_SUBTITLE_SUFFIXES = frozenset({".ass", ".srt"})
+_MEDIA_KINDS = get_args(MediaKind)
# Windows/qB 保留字符 + 控制字符:出现在路径片段里会被下载器拆成多级
# 目录或直接丢字(qB 静默截断),必须在拼路径前替换掉 (#721)
@@ -56,17 +58,24 @@ class SuffixMediaFiles:
def check_files(files: list[dict]):
# 选中的实现未登记(插件停用或被熔断)时退回宿主实现
providers = plugin_host.get_registry().providers(points.MEDIA_FILES)
- entry = providers.get(settings.plugins.slots.media_files) or providers[CORE_ID]
- media_files: MediaFiles = entry.factory()
- media_list = []
- subtitle_list = []
- for f in files:
- file_name = f["name"]
- kind = media_files.classify(file_name)
- if kind == "media":
- media_list.append(file_name)
- elif kind == "subtitle":
- subtitle_list.append(file_name)
+ core = providers[CORE_ID]
+ entry = providers.get(settings.plugins.slots.media_files) or core
+ names = [f["name"] for f in files]
+
+ def classify(media_files: MediaFiles) -> list[MediaKind]:
+ return [media_files.classify(name) for name in names]
+
+ ok, kinds = plugin_host.call_sync(
+ entry,
+ points.MEDIA_FILES,
+ classify,
+ lambda result: all(kind in _MEDIA_KINDS for kind in result),
+ )
+ if not ok:
+ # 插件抛出异常或返回了无效类别(已计入熔断):本次按宿主实现分类
+ kinds = classify(core.factory())
+ media_list = [name for name, kind in zip(names, kinds) if kind == "media"]
+ subtitle_list = [name for name, kind in zip(names, kinds) if kind == "subtitle"]
return media_list, subtitle_list
diff --git a/backend/src/module/llm_plugins/installer.py b/backend/src/module/llm_plugins/installer.py
index a60ec0dd..409ce202 100644
--- a/backend/src/module/llm_plugins/installer.py
+++ b/backend/src/module/llm_plugins/installer.py
@@ -24,6 +24,7 @@ _MANIFEST_SCHEMA = 1
class PluginInstaller(SignedCatalogInstaller):
tag = "llm-plugins"
catalog_schema = _MANIFEST_SCHEMA
+ manifest_name = "plugin.json"
def _reject_reason(self, plugin_id: str) -> Optional[str]:
from module.parser.analyser.providers.builtin import BUILTIN
diff --git a/backend/src/module/manager/renamer.py b/backend/src/module/manager/renamer.py
index 98908f97..1f065f10 100644
--- a/backend/src/module/manager/renamer.py
+++ b/backend/src/module/manager/renamer.py
@@ -7,7 +7,7 @@ from typing import Any
from ab_sdk import points
from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized
from ab_sdk.rename import (
- ConflictPolicy,
+ ConflictDecision,
ConflictRequest,
FileKind,
RenameInput,
@@ -58,7 +58,8 @@ _unavailable: set[str] = set()
# 已记录过「未登记」日志的重命名方式(每个进程只记一次,重新登记后清除)
_missing_methods: set[str] = set()
# 已发布过的 TorrentOrganized(种子 hash → 最终文件)。未打 ab:renamed 标签的
-# 种子(重命名方式为 none)每轮都会重新处理,文件不变时不重复发布。
+# 种子(重命名方式为 none)每轮都会重新处理,文件不变时不重复发布;已打标签的
+# 种子不在其中时补发一次(_republish_organized)。
# ponytail: 进程内字典,重启后重发一次(事件约定为至少一次);条目随种子数增长
_organized_published: dict[str, tuple[OrganizedFile, ...]] = {}
# 已发送过「文件未重命名」通知的 (种子 hash, 原因)
@@ -97,31 +98,17 @@ def _target_name(entry: ProviderEntry, f: RenameInput) -> str:
插件策略经 runner 调用:异常与无效返回值计入熔断;策略主动抛出的
RenameSkipped(输入或用户配置有问题)不计入。
"""
- if entry.plugin_id == plugin_host.CORE:
- return entry.factory().target_name(f)
- def call() -> str | RenameSkipped:
+ def call(strategy: Any) -> str | RenameSkipped:
try:
- return entry.factory().target_name(f)
+ return strategy.target_name(f)
except RenameSkipped as e:
return e
- runner = plugin_host.get_runner()
- result: Any = None
- if runner is not None:
- ok, result = runner.call_provider_sync(
- entry.plugin_id, points.RENAME_STRATEGY, call, check=_valid_name
- )
- else:
- try:
- result = call()
- ok = _valid_name(result)
- except Exception as e:
- logger.warning("[Plugin:%s] 重命名策略失败:%s", entry.plugin_id, e)
- ok = False
+ ok, result = plugin_host.call_sync(entry, points.RENAME_STRATEGY, call, _valid_name)
if isinstance(result, RenameSkipped):
raise result
- if not ok or result is None:
+ if not ok:
raise RenameSkipped(f"重命名方式 {entry.id} 执行失败,详见日志")
return result
@@ -490,7 +477,7 @@ class Renamer(RevisionSaga):
)
if owners:
owner = owners[0] if len(owners) == 1 else None
- decision = self._conflict_policy().decide(
+ decision = self._decide_conflict(
ConflictRequest(
target_path=prepared.target_path,
incoming=RevisionTask(
@@ -638,11 +625,34 @@ class Renamer(RevisionSaga):
)
@staticmethod
- def _conflict_policy() -> ConflictPolicy:
- # 选中的策略未登记(插件停用或被熔断)时按 hold 处理,不会误删旧版本
+ def _decide_conflict(request: ConflictRequest) -> ConflictDecision:
+ # 选中的策略未登记(插件停用或被熔断)、抛出异常或返回值无效时按 hold
+ # 处理,不会误删旧版本;插件的失败已计入熔断
policies = plugin_host.get_registry().providers(points.CONFLICT_POLICY)
- entry = policies.get(settings.plugins.slots.conflict_policy) or policies["hold"]
- return entry.factory()
+ hold = policies["hold"]
+ entry = policies.get(settings.plugins.slots.conflict_policy) or hold
+ ok, decision = plugin_host.call_sync(
+ entry,
+ points.CONFLICT_POLICY,
+ lambda policy: policy.decide(request),
+ lambda result: isinstance(result, ConflictDecision),
+ )
+ return decision if ok else hold.factory().decide(request)
+
+ async def _republish_organized(self, infos: list[dict], method: str) -> None:
+ """已打 ab:renamed 的种子不再整理,但本进程还没发布过它的
+ torrent.organized(重启后;上个进程发布的事件可能没被订阅者处理完):
+ 按当前文件名补发一次,使投递为至少一次。没有总线时发布是空操作,
+ 不查询文件。"""
+ if not infos or plugin_host.get_bus() is None:
+ return
+ all_files = await asyncio.gather(
+ *[self.client.get_torrent_files(info["hash"]) for info in infos]
+ )
+ for info, files in zip(infos, all_files):
+ media_list, subtitle_list = check_files(files)
+ if media_list:
+ self._finish_torrent(info, media_list, subtitle_list, method, True)
async def _batch_lookup_offsets(
self, torrents_info: list[dict]
@@ -759,7 +769,12 @@ class Renamer(RevisionSaga):
async def rename(self) -> list[Notification]:
logger.debug("Start rename process.")
- strategy = _rename_strategy(settings.plugins.slots.rename_strategy)
+ configured = settings.plugins.slots.rename_strategy
+ strategy = _rename_strategy(configured)
+ # 选中的策略未登记(重载窗口、熔断、模板失效)时文件保留原名,但原名不是
+ # 最终文件名:策略恢复后还会改名,这时发布 torrent.organized 会让订阅者
+ # (如硬链接)按两个文件名各处理一次
+ final_names = strategy.id == configured
pending_infos = await self.client.get_torrent_info()
# Owner counting and Saga recovery must see tasks outside the normal
# Bangumi/completed filter (collections, paused tasks, changed category).
@@ -803,6 +818,16 @@ class Renamer(RevisionSaga):
if info.get("hash") in active_replacement_ids
or not self._has_tag(info.get("tags"), _RENAMED_TAG)
]
+ await self._republish_organized(
+ [
+ info
+ for info in all_infos
+ if info["hash"] not in _organized_published
+ and info["hash"] not in active_replacement_ids
+ and self._has_tag(info.get("tags"), _RENAMED_TAG)
+ ],
+ strategy.id,
+ )
renamed_info: list[Notification] = []
if not torrents_info:
logger.debug("Rename process finished: no pending torrents")
@@ -866,7 +891,7 @@ class Renamer(RevisionSaga):
media_list,
subtitle_list,
strategy.id,
- report.result.succeeded,
+ report.result.succeeded and final_names,
)
elif len(media_list) > 1:
logger.info("Start rename collection")
@@ -885,7 +910,11 @@ class Renamer(RevisionSaga):
await self._mark_renamed(torrent_hash, info.get("tags"))
await self.client.set_category(torrent_hash, "BangumiCollection")
self._finish_torrent(
- info, media_list, subtitle_list, strategy.id, collection_complete
+ info,
+ media_list,
+ subtitle_list,
+ strategy.id,
+ collection_complete and final_names,
)
else:
logger.warning(f"{torrent_name} has no media file")
diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py
index 795a9462..db825ea5 100644
--- a/backend/src/module/manager/torrent.py
+++ b/backend/src/module/manager/torrent.py
@@ -120,26 +120,29 @@ class TorrentManager:
async def delete_rule(self, _id: int | str, file: bool = False):
data = await self.db.bangumi.search_id(int(_id))
if isinstance(data, Bangumi):
- # 种子行删除前先记下种子所在的实例
- instance_ids = await self._torrent_instances(data) if file else []
+ torrent_message = None
+ if file:
+ # Only the file-cleanup path needs the downloader, so an
+ # unreachable downloader shouldn't block a DB-only delete.
+ torrent_message = await self.delete_torrents(
+ data, await self._torrent_instances(data)
+ )
+ if torrent_message.status_code == 500:
+ # 种子行经外键引用番剧,且记录了种子所在的实例:两者都保留,
+ # 实例恢复后重试删除(已删净的实例上匹配不到种子,直接跳过)
+ return ResponseModel(
+ status_code=500,
+ status=False,
+ msg_en=f"Deleting torrents for {data.official_title} "
+ "failed; the rule was kept, retry later.",
+ msg_zh=f"删除 {data.official_title} 的种子失败,"
+ "规则已保留,请稍后重试。",
+ )
# Clean up torrent records so re-adding the same anime can re-download
await self.db.torrent.delete_by_bangumi_id(int(_id))
await self.db.bangumi.delete_one(int(_id))
# 番剧删除后停用其独立订阅的孤儿 RSS;聚合订阅不受影响
await self._disable_orphan_sub_rss(data)
- torrent_message = None
- if file:
- # Only the file-cleanup path needs the downloader, so an
- # unreachable downloader shouldn't block a DB-only delete.
- torrent_message = await self.delete_torrents(data, instance_ids)
- if torrent_message.status_code == 500:
- return ResponseModel(
- status_code=500,
- status=False,
- msg_en=f"Deleted rule for {data.official_title}, "
- "but deleting its torrents failed.",
- msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。",
- )
logger.info(f"Delete rule for {data.official_title}")
return ResponseModel(
status_code=200,
@@ -218,13 +221,33 @@ class TorrentManager:
# 没换:规则的种子可能经订阅进了别的实例,逐个实例按各自的
# 下载目录移动
if new_instance == old_instance:
- for instance_id in await self._torrent_instances(old_data):
- root = settings.downloader_instance(instance_id).path
- moved_path = gen_save_path(data, root)
- old_paths = _save_paths(old_data, root)
- if old_paths == {normalize_save_path(moved_path)}:
- continue
- match_list = await self.__match_torrents_list(old_data, instance_id)
+ # 先在所有实例上匹配种子:任一实例不可用就什么都不改,
+ # 避免一部分实例已移动而规则仍记着旧目录
+ moves = []
+ try:
+ for instance_id in await self._torrent_instances(old_data):
+ root = settings.downloader_instance(instance_id).path
+ moved_path = gen_save_path(data, root)
+ old_paths = _save_paths(old_data, root)
+ if old_paths == {normalize_save_path(moved_path)}:
+ continue
+ match_list = await self.__match_torrents_list(
+ old_data, instance_id
+ )
+ moves.append((instance_id, moved_path, old_paths, match_list))
+ except ConnectionError as e:
+ logger.warning(
+ "[Manager] Can't update rule %s: %s", old_data.rule_name, e
+ )
+ return ResponseModel(
+ status_code=500,
+ status=False,
+ msg_en=f"Updating {old_data.official_title} failed: a "
+ "downloader is unreachable; nothing was changed, retry later.",
+ msg_zh=f"更新 {old_data.official_title} 失败:下载器不可用,"
+ "规则未修改,请稍后重试。",
+ )
+ for instance_id, moved_path, old_paths, match_list in moves:
async with DownloadClient(instance_id) as client:
# Move existing torrents to new location if path changed
if match_list:
diff --git a/backend/src/module/models/aria2.py b/backend/src/module/models/aria2.py
index 9436c194..965abc2c 100644
--- a/backend/src/module/models/aria2.py
+++ b/backend/src/module/models/aria2.py
@@ -16,6 +16,8 @@ class Aria2Gid(SQLModel, table=True):
__tablename__ = "aria2_gid"
+ # 不同 aria2 实例可能分配到相同的 gid,主键按实例区分
+ downloader_id: str = Field(primary_key=True)
gid: str = Field(primary_key=True)
bangumi_id: Optional[int] = Field(default=None, foreign_key="bangumi.id")
category: Optional[str] = None
diff --git a/backend/src/module/plugin/bus.py b/backend/src/module/plugin/bus.py
index ef956da4..b7e926a4 100644
--- a/backend/src/module/plugin/bus.py
+++ b/backend/src/module/plugin/bus.py
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
Handler = Callable[[Event], Awaitable[None] | None]
ErrorCallback = Callable[[str, str], None]
+SuccessCallback = Callable[[str], None]
ALL_EVENTS = "*"
DEFAULT_HANDLER_TIMEOUT = 30.0
@@ -31,12 +32,14 @@ class _Subscriber:
timeout: float,
queue_size: int,
on_error: ErrorCallback | None,
+ on_success: SuccessCallback | None,
) -> None:
self.kind = kind
self.handler = handler
self.owner = owner
self._timeout = timeout
self._on_error = on_error
+ self._on_success = on_success
self._queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=queue_size)
self._worker: asyncio.Task | None = None
@@ -80,6 +83,9 @@ class _Subscriber:
)
if self._on_error is not None and self.owner is not None:
self._on_error(self.owner, reason)
+ else:
+ if self._on_success is not None and self.owner is not None:
+ self._on_success(self.owner)
finally:
self._queue.task_done()
@@ -103,12 +109,14 @@ class EventBus:
handler_timeout: float = DEFAULT_HANDLER_TIMEOUT,
queue_size: int = DEFAULT_QUEUE_SIZE,
on_error: ErrorCallback | None = None,
+ on_success: SuccessCallback | None = None,
) -> None:
self._timeout = handler_timeout
self._queue_size = queue_size
self._subscribers: list[_Subscriber] = []
- # 订阅者失败回调(owner, reason),宿主用它驱动插件熔断
+ # 订阅者失败 / 成功回调,宿主用它驱动插件熔断(成功时清零连续失败计数)
self.on_error = on_error
+ self.on_success = on_success
def subscribe(
self,
@@ -125,6 +133,7 @@ class EventBus:
timeout if timeout is not None else self._timeout,
self._queue_size,
self._report_error,
+ self._report_success,
)
self._subscribers.append(sub)
@@ -141,7 +150,7 @@ class EventBus:
sub.offer(event)
async def drain(self) -> None:
- """等待当前已入队的事件全部处理完(测试与关闭流程使用)。"""
+ """等待当前已入队的事件全部处理完(测试使用)。"""
await asyncio.gather(*(sub.join() for sub in list(self._subscribers)))
async def close_owner(self, owner: str) -> None:
@@ -157,3 +166,7 @@ class EventBus:
def _report_error(self, owner: str, reason: str) -> None:
if self.on_error is not None:
self.on_error(owner, reason)
+
+ def _report_success(self, owner: str) -> None:
+ if self.on_success is not None:
+ self.on_success(owner)
diff --git a/backend/src/module/plugin/host.py b/backend/src/module/plugin/host.py
index bb8c8ea8..67e4758c 100644
--- a/backend/src/module/plugin/host.py
+++ b/backend/src/module/plugin/host.py
@@ -109,6 +109,36 @@ def hook_runner(point: str) -> HookRunner | None:
return runner
+def call_sync(
+ entry: ProviderEntry,
+ point: str,
+ method: Callable[[Any], Any],
+ check: Callable[[Any], bool],
+) -> tuple[bool, Any]:
+ """同步调用 Provider 实现上的方法 ``method(impl)``,返回 ``(是否成功, 结果)``。
+
+ core 实现直接调用;插件实现经 runner 调用,异常与 ``check`` 不通过的返回值
+ 计入熔断。未设置 runner(脚本、CLI)时插件失败只记录日志。``check`` 也会
+ 收到 None。
+ """
+ if entry.plugin_id == CORE:
+ return True, method(entry.factory())
+ runner = _runner
+ if runner is not None:
+ ok, result = runner.call_provider_sync(
+ entry.plugin_id, point, lambda: method(entry.factory()), check=check
+ )
+ else:
+ try:
+ result = method(entry.factory())
+ except Exception as e:
+ logger.warning("[Plugin:%s] %s 失败:%s", entry.plugin_id, point, e)
+ return False, None
+ ok = True
+ # runner 不校验 None(钩子返回 None 表示不修改),Provider 返回 None 同样无效
+ return ok and check(result), result
+
+
def provider_impls(
point: str, valid: Callable[[Any], bool] = lambda impl: True
) -> dict[str, Any]:
@@ -152,7 +182,9 @@ def _register_core(registry: ExtensionRegistry) -> None:
def aria2(conn: DownloaderConnection):
from module.downloader.client.aria2_downloader import Aria2Downloader
- return Aria2Downloader(conn.host, conn.username, conn.password)
+ return Aria2Downloader(
+ conn.host, conn.username, conn.password, conn.instance_id
+ )
def mock(conn: DownloaderConnection):
from module.downloader.client.mock_downloader import MockDownloader
diff --git a/backend/src/module/plugin/installer.py b/backend/src/module/plugin/installer.py
index 4b165aad..4e23437b 100644
--- a/backend/src/module/plugin/installer.py
+++ b/backend/src/module/plugin/installer.py
@@ -77,6 +77,8 @@ def safe_extract(zip_path: Path, dest: Path) -> None:
class SignedCatalogInstaller:
tag: ClassVar[str]
catalog_schema: ClassVar[int]
+ # 版本目录里的清单文件名,卸载时据此确认目录归本安装器所有
+ manifest_name: ClassVar[str]
def __init__(
self,
@@ -169,7 +171,7 @@ class SignedCatalogInstaller:
return InstallResult(success=False, message=str(e))
async def _install(self, plugin_id: str) -> InstallResult:
- if reason := self._reject_reason(plugin_id):
+ if reason := self._check_id(plugin_id):
return InstallResult(success=False, message=reason)
catalog = await self.fetch_catalog()
entry = next((p for p in catalog if p.get("id") == plugin_id), None)
@@ -221,7 +223,7 @@ class SignedCatalogInstaller:
async def uninstall(self, plugin_id: str) -> InstallResult:
async with self._lock:
- if reason := self._reject_reason(plugin_id):
+ if reason := self._check_id(plugin_id):
return InstallResult(success=False, message=reason)
if reason := self._uninstall_reject_reason(plugin_id):
return InstallResult(success=False, message=reason)
@@ -230,8 +232,22 @@ class SignedCatalogInstaller:
self._reload(plugin_id)
return InstallResult(success=True)
+ def _check_id(self, plugin_id: str) -> Optional[str]:
+ """id 不合法或不允许经安装器安装 / 卸载的原因;可以时为 None。"""
+ # 同时挡住 "local"(本地插件目录)与路径穿越:id 会拼进文件系统路径
+ if not ID_RE.match(plugin_id) or plugin_id in RESERVED_IDS:
+ return f"Invalid plugin id: {plugin_id}"
+ return self._reject_reason(plugin_id)
+
def _uninstall_reject_reason(self, plugin_id: str) -> Optional[str]:
- """卸载前的额外检查;默认没有。"""
+ # 只删经本安装器装入的目录(installed.json 指向含本类清单的版本目录),
+ # 绝不碰 config/plugins/ 下的其它内容:本地插件、另一类安装器装入的插件
+ version = installed_version(self.root, plugin_id)
+ if (
+ not version
+ or not (self.root / plugin_id / version / self.manifest_name).is_file()
+ ):
+ return f"Plugin not installed: {plugin_id}"
return None
async def _after_uninstall(self, plugin_id: str) -> None:
@@ -248,26 +264,13 @@ class PluginInstaller(SignedCatalogInstaller):
tag = "plugins"
catalog_schema = 2
+ manifest_name = MANIFEST_NAME
def _reject_reason(self, plugin_id: str) -> Optional[str]:
- # 同时挡住 "local"(本地插件目录)与路径穿越:id 会拼进文件系统路径
- if not ID_RE.match(plugin_id) or plugin_id in RESERVED_IDS:
- return f"Invalid plugin id: {plugin_id}"
if (BUILTIN_ROOT / plugin_id).is_dir():
return f"{plugin_id} is a builtin plugin"
return None
- def _uninstall_reject_reason(self, plugin_id: str) -> Optional[str]:
- # 只删经本安装器装入的目录(installed.json 指向含 plugin.toml 的版本目录),
- # 绝不碰 config/plugins/ 下的其它内容,包括同样带 installed.json 的 LLM 插件
- version = installed_version(self.root, plugin_id)
- if (
- not version
- or not (self.root / plugin_id / version / MANIFEST_NAME).is_file()
- ):
- return f"Plugin not installed: {plugin_id}"
- return None
-
def _validate_manifest(
self, unpacked: Path, entry: dict[str, Any]
) -> Optional[str]:
diff --git a/backend/src/module/plugin/loader.py b/backend/src/module/plugin/loader.py
index ee858458..bd10793e 100644
--- a/backend/src/module/plugin/loader.py
+++ b/backend/src/module/plugin/loader.py
@@ -222,10 +222,14 @@ def _unload_package(package: str, plugin_dir: Path) -> None:
def _entry_point_candidate(ep: importlib.metadata.EntryPoint) -> PluginCandidate:
top_package = ep.module.split(".", 1)[0]
try:
+ # 取包内文件会执行插件包顶层代码,语法错误、缺依赖等都在这里出现
package_files = importlib.resources.files(top_package)
+ except Exception as e:
+ raise PluginLoadError(f"导入 {top_package} 失败:{e}") from e
+ try:
text = (package_files / MANIFEST_NAME).read_text(encoding="utf-8")
- except (ModuleNotFoundError, FileNotFoundError) as e:
- raise PluginLoadError(f"{top_package} 包内缺少 {MANIFEST_NAME}") from e
+ except (OSError, UnicodeDecodeError) as e:
+ raise PluginLoadError(f"{top_package} 包内无法读取 {MANIFEST_NAME}:{e}") from e
manifest = parse_manifest(text, f"{top_package}/{MANIFEST_NAME}")
def load() -> type[Plugin[Any]]:
diff --git a/backend/src/module/plugin/manager.py b/backend/src/module/plugin/manager.py
index 73054a00..19e40ac1 100644
--- a/backend/src/module/plugin/manager.py
+++ b/backend/src/module/plugin/manager.py
@@ -94,7 +94,10 @@ class PluginManager:
self._data_root = data_root
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.bus = EventBus(
+ on_error=self.breaker.record_failure,
+ on_success=self.breaker.record_success,
+ )
self.runner = HookRunner(
self.registry,
self.breaker,
@@ -155,7 +158,11 @@ class PluginManager:
):
await self._deactivate(plugin_id)
for plugin_id, candidate in self._candidates.items():
- if plugin_id in self._active or self._blocked_reason(candidate):
+ if plugin_id in self._active:
+ continue
+ if self._blocked_reason(candidate):
+ # 已被关闭的插件不再显示旧的失败原因,重新开启时也会重试
+ self._failed.pop(plugin_id, None)
continue
failed = self._failed.get(plugin_id)
if failed is not None and failed[0] == self._snapshot(plugin_id):
@@ -353,6 +360,8 @@ class PluginManager:
manifest = candidate.manifest
plugin_id = manifest.id
snapshot = self._snapshot(plugin_id)
+ # setup 已开始执行的实例;失败时由 teardown 释放它已创建的任务、连接等
+ started: Plugin | None = None
try:
if not manifest.sdk_compatible():
raise PluginLoadError(
@@ -364,6 +373,7 @@ class PluginManager:
ctx = HostPluginContext(plugin_id, config, self.bus, self._data_root)
instance = cls(ctx)
self._register(plugin_id, instance)
+ started = instance
await asyncio.wait_for(instance.setup(), self._setup_timeout)
except Exception as e:
reason = (
@@ -373,6 +383,8 @@ class PluginManager:
)
self.registry.remove_plugin(plugin_id)
await self.bus.close_owner(plugin_id)
+ if started is not None:
+ await self._teardown(plugin_id, started)
candidate.unload()
self._failed[plugin_id] = (snapshot, reason)
logger.error("[Plugin:%s] 加载失败:%s", plugin_id, reason)
@@ -432,25 +444,31 @@ class PluginManager:
return
self.registry.remove_plugin(plugin_id)
await self.bus.close_owner(plugin_id)
- try:
- await asyncio.wait_for(active.instance.teardown(), self._setup_timeout)
- except Exception as e:
- logger.warning("[Plugin:%s] teardown 失败:%s", plugin_id, e)
+ await self._teardown(plugin_id, active.instance)
active.candidate.unload()
logger.info("[Plugin:%s] 已停用", plugin_id)
+ async def _teardown(self, plugin_id: str, instance: Plugin) -> None:
+ try:
+ await asyncio.wait_for(instance.teardown(), self._setup_timeout)
+ except Exception as e:
+ logger.warning("[Plugin:%s] teardown 失败:%s", plugin_id, e)
+
def _on_trip(self, plugin_id: str, reason: str) -> None:
- task = asyncio.create_task(self._disable(plugin_id, reason))
+ # 记下触发熔断的实例:禁用任务拿到锁前插件可能已被重新加载
+ tripped = self._active.get(plugin_id)
+ task = asyncio.create_task(self._disable(plugin_id, reason, tripped))
self._pending.add(task)
task.add_done_callback(self._pending.discard)
- async def _disable(self, plugin_id: str, reason: str) -> None:
+ async def _disable(
+ self, plugin_id: str, reason: str, tripped: _Active | None
+ ) -> None:
async with self._lock:
- active = self._active.get(plugin_id)
- if active is None:
+ if tripped is None or self._active.get(plugin_id) is not tripped:
return
await self._deactivate(plugin_id)
- self._failed[plugin_id] = (active.snapshot, reason)
+ self._failed[plugin_id] = (tripped.snapshot, reason)
logger.error("[Plugin:%s] %s", plugin_id, reason)
self.bus.publish(PluginDisabled(plugin_id=plugin_id, reason=reason))
await self._notify_change()
diff --git a/backend/src/module/plugin/secrets.py b/backend/src/module/plugin/secrets.py
index e3daab31..695c8d15 100644
--- a/backend/src/module/plugin/secrets.py
+++ b/backend/src/module/plugin/secrets.py
@@ -15,16 +15,20 @@ MASK = "********"
# 无 schema(插件代码本进程未加载过)时无法区分秘密字段:所有字符串都按秘密处理
_ALL: dict[str, Any] = {"secret": True}
-_ALL["additionalProperties"] = _ALL["items"] = _ALL
_MISSING = object()
def _branches(schema: dict[str, Any], root: dict[str, Any]) -> list[dict[str, Any]]:
- """展开 $ref 与 anyOf / oneOf(如 ``Server | None``)后的候选分支。"""
+ """展开 $ref 与 anyOf / oneOf(如 ``Server | None``)后的候选分支。
+
+ 外层节点也保留:``secret_field()`` 的标记写在 anyOf / $ref 的外层。
+ """
+ nodes = [schema]
while "$ref" in schema:
schema = root["$defs"][schema["$ref"].rsplit("/", 1)[-1]]
+ nodes.append(schema)
subs = schema.get("anyOf", []) + schema.get("oneOf", [])
- return [b for s in subs for b in _branches(s, root)] if subs else [schema]
+ return nodes + [b for s in subs for b in _branches(s, root)]
def _is_secret(branches: list[dict[str, Any]]) -> bool:
@@ -34,17 +38,24 @@ def _is_secret(branches: list[dict[str, Any]]) -> bool:
)
+def _union(branches: list[dict[str, Any]], subs: list[Any]) -> dict[str, Any]:
+ # 容器整体是秘密时,其中的元素 / 字典值 / 嵌套字段也是秘密
+ return {
+ "anyOf": [s for s in subs if isinstance(s, dict)],
+ "secret": _is_secret(branches),
+ }
+
+
def _child(branches: list[dict[str, Any]], key: str) -> dict[str, Any]:
subs = [
b.get("properties", {}).get(key, b.get("additionalProperties"))
for b in branches
]
- return {"anyOf": [s for s in subs if isinstance(s, dict)]}
+ return _union(branches, subs)
def _items(branches: list[dict[str, Any]]) -> dict[str, Any]:
- subs = [b.get("items") for b in branches]
- return {"anyOf": [s for s in subs if isinstance(s, dict)]}
+ return _union(branches, [b.get("items") for b in branches])
def _mask(value: Any, schema: dict[str, Any], root: dict[str, Any]) -> Any:
diff --git a/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py b/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py
index 0063b70f..90471977 100644
--- a/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py
+++ b/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py
@@ -8,7 +8,8 @@
- 跨文件系统(EXDEV)时按 ``cross_device`` 复制、建软链接或跳过;
- 目标已存在且不是本插件创建的:跳过并通知;是本插件之前为同一集创建的
(版本升级后同名文件换成了新文件):原子替换;
-- 事件投递为「至少一次」,已链接的文件再次收到时不做任何事。
+- 事件投递为「至少一次」,已链接的文件再次收到时不做任何事;插件放置后被
+ 用户从媒体库删除的文件也不重建,只有补链会重建。
"""
import asyncio
@@ -25,7 +26,7 @@ from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ab_sdk import Plugin, points, provider, subscribe
-from ab_sdk.events import SystemEvent, TorrentOrganized
+from ab_sdk.events import Event, SystemEvent, TorrentOrganized
Status = Literal["linked", "exists", "conflict", "failed"]
@@ -33,7 +34,7 @@ Status = Literal["linked", "exists", "conflict", "failed"]
LINK_TIMEOUT = 3600.0
# ponytail: 补链按扩展名挑文件,与宿主 media_files 的 core 实现同一组;插件不能
-# import module.*,若用户换了 media_files 实现(P2.5 slots),这里需要改为经 SDK 查询
+# import module.*,若用户在 plugins.slots.media_files 换了实现,这里需要改为经 SDK 查询
BACKFILL_SUFFIXES = frozenset({".mp4", ".mkv", ".ass", ".srt"})
@@ -101,6 +102,16 @@ class HardlinkFailed(SystemEvent):
return ("硬链接未完成", f"种子:{self.torrent_hash}\n{self.files}")
+@dataclass(frozen=True, slots=True)
+class HardlinkLinked(Event):
+ """种子有文件新放入了媒体库。跨盘复制可能比媒体库刷新的延迟更久,
+ media-server-refresh 订阅它,在文件放好后再刷新一次。"""
+
+ kind: ClassVar[str] = "hardlink.linked"
+
+ torrent_hash: str
+
+
def _identity(path: Path) -> list[int]:
"""文件身份(不跟随软链接)。含大小与修改时间:旧文件删除后 inode 可能被复用。"""
st = os.lstat(path)
@@ -151,10 +162,11 @@ Record = list[list[int]]
def place(
- src: Path, dst: Path, owned: Record | None, cross_device: str
+ src: Path, dst: Path, owned: Record | None, cross_device: str, restore: bool
) -> tuple[Status, str, Record | None]:
"""在线程中执行的文件操作。``owned`` 是插件上次在 ``dst`` 放置文件时的记录,
- None 表示 ``dst`` 不是插件创建的。返回 linked / exists 时附带新记录。"""
+ None 表示 ``dst`` 不是插件创建的。``restore`` 为 False 时,插件放置过、之后
+ 被删除的同一文件不再重建。返回 linked / exists 时附带新记录。"""
ident = _identity(src)
if os.path.lexists(dst):
here = _identity(dst)
@@ -166,6 +178,10 @@ def place(
if owned is None or owned[1] != here:
return "conflict", "媒体库中已有同名文件且不是本插件创建的", None
# 否则是版本升级:同一集换成了新文件,替换插件之前创建的链接
+ elif owned is not None and owned[0] == ident and not restore:
+ # 插件放置过同一文件、之后被删除(用户清理已看完的剧集):重复投递的
+ # 事件不再放回,只有用户主动补链时才重建
+ return "exists", "", owned
else:
dst.parent.mkdir(parents=True, exist_ok=True)
status, reason = _make(src, dst, cross_device)
@@ -189,7 +205,7 @@ class HardlinkPlugin(Plugin[Options]):
return path
return best[1] + path[len(best[0]) :]
- async def link(self, src: Path) -> tuple[Status, str]:
+ async def link(self, src: Path, restore: bool = False) -> tuple[Status, str]:
try:
rel = Path(os.path.normpath(src)).relative_to(self.config.source_root)
except ValueError:
@@ -199,7 +215,7 @@ class HardlinkPlugin(Plugin[Options]):
owned = await self.ctx.kv.get(key)
try:
status, reason, record = await asyncio.to_thread(
- place, src, dst, owned, self.config.cross_device
+ place, src, dst, owned, self.config.cross_device, restore
)
except OSError as e:
return "failed", str(e)
@@ -210,12 +226,16 @@ class HardlinkPlugin(Plugin[Options]):
@subscribe(TorrentOrganized.kind, timeout=LINK_TIMEOUT)
async def on_organized(self, event: TorrentOrganized) -> None:
problems: list[str] = []
+ linked = False
for f in event.files:
status, reason = await self.link(
Path(self.to_local(f.path, event.downloader_id))
)
+ linked = linked or status == "linked"
if status in ("conflict", "failed"):
problems.append(f"{f.path}:{reason}")
+ if linked:
+ self.ctx.bus.publish(HardlinkLinked(torrent_hash=event.torrent_hash))
if problems:
self.ctx.log.warning("种子 %s 未完成链接:%s", event.torrent_hash, problems)
self.ctx.bus.publish(
@@ -241,7 +261,7 @@ class HardlinkPlugin(Plugin[Options]):
"failed": 0,
}
for src in files:
- status, _ = await self.link(src)
+ status, _ = await self.link(src, restore=True)
counts[status] += 1
self.ctx.log.info("补链完成:%s", counts)
return counts
diff --git a/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py b/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py
index bb5b5cb5..029c6d14 100644
--- a/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py
+++ b/backend/src/module/plugins/builtin/media-server-refresh/media_server_refresh.py
@@ -1,10 +1,11 @@
"""内置插件:种子整理完成后通知媒体服务器刷新媒体库(订阅 ``torrent.organized``)。
收到第一个事件后等待 ``delay`` 秒,期间整理完成的所有种子合并成一次刷新请求。
+同时订阅 hardlink 插件的 ``hardlink.linked``:文件放入媒体库后再刷新一次。
未填写服务器地址或 API Key 时什么也不做,所以默认启用也无副作用。
-``torrent.organized`` 投递为「至少一次」:重命名方式为 none 的种子每次进程
-重启后会再发布一次,已配置时重启后最多多出一次(合并后的)刷新。
+``torrent.organized`` 投递为「至少一次」:下载器中已整理的种子每次进程重启后
+会再发布一次,已配置时重启后最多多出一次(合并后的)刷新。
"""
import asyncio
@@ -15,7 +16,7 @@ import httpx
from pydantic import BaseModel, Field
from ab_sdk import Plugin, PluginContext, secret_field, subscribe
-from ab_sdk.events import TorrentOrganized
+from ab_sdk.events import Event, TorrentOrganized
ServerType = Literal["jellyfin", "emby", "plex"]
@@ -61,6 +62,8 @@ class MediaServerRefresh(Plugin[Options]):
def __init__(self, ctx: PluginContext) -> None:
super().__init__(ctx)
self._pending: asyncio.Task[None] | None = None
+ # 刷新请求发出后又有新事件:这次刷新之后再排一次
+ self._again = False
# 测试可替换为带 MockTransport 的客户端
self.client_factory: Callable[[], httpx.AsyncClient] = lambda: (
httpx.AsyncClient(timeout=10)
@@ -81,18 +84,34 @@ class MediaServerRefresh(Plugin[Options]):
@subscribe(TorrentOrganized.kind)
async def on_organized(self, event: TorrentOrganized) -> None:
+ self._schedule(event.kind)
+
+ # hardlink 插件把文件放入媒体库后发布;跨盘复制可能比 delay 更久,
+ # 放好后再刷新一次(期间的事件同样合并)
+ @subscribe("hardlink.linked")
+ async def on_linked(self, event: Event) -> None:
+ self._schedule(event.kind)
+
+ def _schedule(self, kind: str) -> None:
if not self.configured:
return
if self._pending is None or self._pending.done():
- self.ctx.log.debug("种子 %s 整理完成,计划刷新媒体库", event.torrent_hash)
+ self.ctx.log.debug("收到 %s,计划刷新媒体库", kind)
self._pending = asyncio.create_task(self._refresh_later())
+ else:
+ self._again = True
async def _refresh_later(self) -> None:
- await asyncio.sleep(self.config.delay)
- try:
- await self.refresh()
- except Exception as e:
- self.ctx.log.warning("刷新媒体库失败:%s", e)
+ again = True
+ while again:
+ await asyncio.sleep(self.config.delay)
+ # 等待期间到达的事件由这次刷新覆盖
+ self._again = False
+ try:
+ await self.refresh()
+ except Exception as e:
+ self.ctx.log.warning("刷新媒体库失败:%s", e)
+ again = self._again
async def refresh(self) -> bool:
method, url, headers = build_request(self.config)
diff --git a/backend/src/module/update/v4.py b/backend/src/module/update/v4.py
index 527864f3..9a13c4ba 100644
--- a/backend/src/module/update/v4.py
+++ b/backend/src/module/update/v4.py
@@ -11,7 +11,7 @@
→ ``webhook_url``(渠道实现不再读旧字段)
在 ``Settings`` 读写配置文件之前运行。改写前把原文件备份为 ``<文件名>.v3.bak``;
-失败时从备份恢复并抛出 :class:`ConfigMigrationError`,拒绝启动。
+失败时从备份恢复(不留下备份)并抛出 :class:`ConfigMigrationError`,拒绝启动。
本模块不能 import ``module.conf``:它在 ``settings`` 构造之前被调用。
"""
@@ -147,11 +147,12 @@ def migrate_v3_config(path: Path) -> bool:
)
os.replace(tmp, path)
except Exception as e:
- shutil.copy2(backup, path)
+ # 移回而不是复制:备份与原文件相同,留着会让每次失败的重启多一份
+ os.replace(backup, path)
message = (
f"config migration to 4.0 failed at {_failed_field(e)}; "
- f"{path.name} was restored from {backup.name}. Fix the field and restart. "
- f"配置迁移失败,已从备份恢复 {path.name},请修正该字段后重新启动。"
+ f"{path.name} was left unchanged. Fix the field and restart. "
+ f"配置迁移失败,{path.name} 未改动,请修正该字段后重新启动。"
)
logger.critical(message)
raise ConfigMigrationError(message) from e
diff --git a/backend/src/module/update/version_check.py b/backend/src/module/update/version_check.py
index b6d8e029..7eaaeee5 100644
--- a/backend/src/module/update/version_check.py
+++ b/backend/src/module/update/version_check.py
@@ -1,11 +1,17 @@
import logging
+import sqlite3
+from contextlib import closing
+from pathlib import Path
import semver
-from module.conf import LEGACY_DATA_PATH, VERSION, VERSION_PATH
+from module.conf import DATA_PATH, LEGACY_DATA_PATH, VERSION, VERSION_PATH
+from module.conf.config import CONFIG_PATH
logger = logging.getLogger(__name__)
+DATABASE_PATH = Path(DATA_PATH.removeprefix("sqlite:///"))
+
# 4.0 只支持从 3.3.x 起跳升级;更早版本的跨版本迁移代码已移除。
MIN_UPGRADE_FROM = (3, 3)
@@ -14,16 +20,40 @@ class UnsupportedUpgradeError(RuntimeError):
"""已有数据来自过旧的版本,无法直接升级到当前版本。"""
-def _upgrade_hint(previous: str) -> str:
+def _upgrade_hint(previous: str, via: str = "3.3.x") -> str:
+ # 拒绝前 Settings 已把 config.json 改写为 4.0 格式,旧版本读不懂,
+ # 必须先还原迁移前的备份
+ backup = f"{CONFIG_PATH}.v3.bak"
return (
f"Detected data from AutoBangumi {previous}. Upgrading directly to "
- f"{VERSION} is not supported: please upgrade to the latest 3.3.x first, "
+ f"{VERSION} is not supported. If {backup} exists, copy it back to "
+ f"{CONFIG_PATH} first. Then upgrade to the latest {via}, "
"start it once so it migrates your data, then upgrade again. "
f"检测到 {previous} 版本的数据,无法直接升级到 {VERSION}:"
- "请先升级到最新的 3.3.x 并启动一次完成数据迁移,再升级到本版本。"
+ f"若存在 {backup},请先用它覆盖 {CONFIG_PATH};"
+ f"再升级到最新的 {via} 并启动一次完成数据迁移,然后再升级。"
)
+def refuse_legacy_data() -> None:
+ """残留 2.x 的 ``data.json`` 时抛 :class:`UnsupportedUpgradeError`。
+
+ 首次启动(尚无数据库)也要检查:否则会建出新库当作新安装启动,
+ 之后每次启动才被拒绝。
+ """
+ if LEGACY_DATA_PATH.exists():
+ raise UnsupportedUpgradeError(_upgrade_hint("2.x"))
+
+
+def _is_v30_database() -> bool:
+ """3.0.x 不写 version.info,数据库里也没有 3.1 起才有的 rssitem 表。"""
+ with closing(sqlite3.connect(DATABASE_PATH)) as conn:
+ row = conn.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'rssitem'"
+ ).fetchone()
+ return row is None
+
+
def _read_last_version() -> semver.Version | None:
try:
lines = VERSION_PATH.read_text().splitlines()
@@ -40,14 +70,15 @@ def _read_last_version() -> semver.Version | None:
def version_check() -> semver.Version | None:
"""校验并记录版本,返回上一次运行的版本(未知时为 None)。
- 仅在已有数据库时调用。上一次版本低于 ``MIN_UPGRADE_FROM``,或残留 2.x 的
- ``data.json`` 时抛出 :class:`UnsupportedUpgradeError`,中止启动。
+ 仅在已有数据库时调用。上一次版本低于 ``MIN_UPGRADE_FROM``,或数据库来自
+ 3.0.x 时抛出 :class:`UnsupportedUpgradeError`,中止启动。
"""
- if LEGACY_DATA_PATH.exists():
- raise UnsupportedUpgradeError(_upgrade_hint("2.x"))
if VERSION in ("DEV_VERSION", "local"):
return None
if not VERSION_PATH.exists():
+ # 3.3 也不补跑 3.0 → 3.1 的数据迁移(缺 version.info 时跳过),只有 3.1.x 会
+ if _is_v30_database():
+ raise UnsupportedUpgradeError(_upgrade_hint("3.0", via="3.1.x"))
VERSION_PATH.write_text(VERSION + "\n")
return None
last_ver = _read_last_version()
diff --git a/backend/src/test/test_api_config.py b/backend/src/test/test_api_config.py
index 85a67b3b..44596a3a 100644
--- a/backend/src/test/test_api_config.py
+++ b/backend/src/test/test_api_config.py
@@ -599,6 +599,40 @@ class TestRestoreMasked:
assert incoming["providers"][0]["token"] == "tg-token"
assert incoming["providers"][1]["token"] == "new-bark-token"
+ def test_config_plugin_notifier_extras_masked_and_restored(
+ self, authed_client, mock_settings
+ ):
+ """插件渠道的自定义字段没有 schema:读接口掩码,保存时按身份还原。"""
+ config = Config.model_validate(
+ {
+ "notification": {
+ "providers": [
+ {"type": "telegram", "token": "tg", "chat_id": "1"},
+ {"type": "ext", "sendkey": "sk", "webhook": "https://h"},
+ ]
+ }
+ }
+ )
+ with patch("module.api.config.settings", config):
+ data = authed_client.get("/api/v1/config/get").json()
+ providers = data["notification"]["providers"]
+ assert (providers[1]["sendkey"], providers[1]["webhook"]) == (
+ "********",
+ "********",
+ )
+ assert providers[0]["chat_id"] == "1"
+
+ # 删除第一个渠道后保存:插件渠道取回自己的值
+ providers.pop(0)
+ mock_settings.dict.return_value = config.dict()
+ with patch("module.api.config.settings", mock_settings):
+ response = authed_client.patch("/api/v1/config/update", json=data)
+ assert response.status_code == 200
+ [saved] = mock_settings.save.call_args[1]["config_dict"]["notification"][
+ "providers"
+ ]
+ assert (saved["sendkey"], saved["webhook"]) == ("sk", "https://h")
+
def test_update_config_preserves_password_when_masked(
self, authed_client, mock_settings
):
diff --git a/backend/src/test/test_aria2_downloader.py b/backend/src/test/test_aria2_downloader.py
index c8ae9e64..a8d5477f 100644
--- a/backend/src/test/test_aria2_downloader.py
+++ b/backend/src/test/test_aria2_downloader.py
@@ -25,7 +25,7 @@ from module.downloader.client.aria2_downloader import (
def _aria2() -> Aria2Downloader:
- return Aria2Downloader("http://localhost:6800", "u", "secret-token")
+ return Aria2Downloader("http://localhost:6800", "u", "secret-token", "default")
def _rename_intent(old_path: str, new_path: str, source) -> Aria2RenameIntent:
@@ -218,7 +218,7 @@ class TestAddTorrents:
)
assert result is AddResult.ADDED
async with Database() as db:
- record = await db.aria2.get("gid001")
+ record = await db.aria2("default").get("gid001")
assert record is not None
assert record.bangumi_id == 42
assert record.category == "Bangumi"
@@ -244,8 +244,8 @@ class TestAddTorrents:
assert result is AddResult.ADDED
async with Database() as db:
- assert await db.aria2.get("metadata-gid") is None
- record = await db.aria2.get("payload-gid")
+ assert await db.aria2("default").get("metadata-gid") is None
+ record = await db.aria2("default").get("payload-gid")
assert record is not None
assert record.bangumi_id == 42
assert record.dedup_key == "url:magnet:?xt=urn:btih:abc"
@@ -356,7 +356,10 @@ class TestAddTorrents:
async with Database() as db:
# Nothing to persist -- addUri never returned a gid.
assert (
- await db.aria2.find_by_dedup_key("url:magnet:?xt=urn:btih:zzz") is None
+ await db.aria2("default").find_by_dedup_key(
+ "url:magnet:?xt=urn:btih:zzz"
+ )
+ is None
)
async def test_rpc_non_duplicate_error_returns_false_for_that_item(self):
@@ -409,7 +412,7 @@ class TestAddTorrents:
aria2 = _aria2()
url = "magnet:?xt=urn:btih:abc"
async with Database() as db:
- await db.aria2.upsert(
+ await db.aria2("default").upsert(
"gid-stale", bangumi_id=1, category="Bangumi", dedup_key=f"url:{url}"
)
@@ -431,15 +434,15 @@ class TestAddTorrents:
assert result is AddResult.ADDED
async with Database() as db:
- assert await db.aria2.get("gid-stale") is None
- assert await db.aria2.get("gid-fresh") is not None
+ assert await db.aria2("default").get("gid-stale") is None
+ assert await db.aria2("default").get("gid-fresh") is not None
async def test_add_torrents_stale_dedup_gid_removed_status_readds(self):
"""gid 还在 aria2 里但 status 是 removed(用户在 UI 删除)→ 视作陈旧,重新添加。"""
aria2 = _aria2()
url = "magnet:?xt=urn:btih:abc"
async with Database() as db:
- await db.aria2.upsert("gid-removed", dedup_key=f"url:{url}")
+ await db.aria2("default").upsert("gid-removed", dedup_key=f"url:{url}")
async def fake_call(method, params=None, timeout=10.0):
if method == "tellStatus":
@@ -458,15 +461,15 @@ class TestAddTorrents:
assert result is AddResult.ADDED
async with Database() as db:
- assert await db.aria2.get("gid-removed") is None
- assert await db.aria2.get("gid-fresh") is not None
+ assert await db.aria2("default").get("gid-removed") is None
+ assert await db.aria2("default").get("gid-fresh") is not None
async def test_add_torrents_dedup_gid_still_active_skips_readd(self):
"""gid 仍活在 aria2 中 → 真重复,不重新添加。"""
aria2 = _aria2()
url = "magnet:?xt=urn:btih:abc"
async with Database() as db:
- await db.aria2.upsert("gid-alive", dedup_key=f"url:{url}")
+ await db.aria2("default").upsert("gid-alive", dedup_key=f"url:{url}")
async def fake_call(method, params=None, timeout=10.0):
if method == "tellStatus":
@@ -485,14 +488,14 @@ class TestAddTorrents:
assert result is AddResult.DUPLICATE
async with Database() as db:
- assert await db.aria2.get("gid-alive") is not None
+ assert await db.aria2("default").get("gid-alive") is not None
async def test_add_torrents_dedup_verify_unreachable_keeps_record_and_skips(self):
"""aria2 不可达时不能断定记录陈旧:保留本地记录,也不重复添加。"""
aria2 = _aria2()
url = "magnet:?xt=urn:btih:abc"
async with Database() as db:
- await db.aria2.upsert("gid-unknown", dedup_key=f"url:{url}")
+ await db.aria2("default").upsert("gid-unknown", dedup_key=f"url:{url}")
async def fake_call(method, params=None, timeout=10.0):
if method == "tellStatus":
@@ -511,7 +514,7 @@ class TestAddTorrents:
assert result is AddResult.FAILED
async with Database() as db:
- assert await db.aria2.get("gid-unknown") is not None
+ assert await db.aria2("default").get("gid-unknown") is not None
async def test_add_torrents_stale_dedup_torrent_file_readds(self):
"""种子文件走 content-hash 去重,同样要做陈旧校验。"""
@@ -521,7 +524,7 @@ class TestAddTorrents:
payload = b"fake torrent bytes"
dedup_key = f"file:{hashlib.sha1(payload).hexdigest()}"
async with Database() as db:
- await db.aria2.upsert("gid-stale-file", dedup_key=dedup_key)
+ await db.aria2("default").upsert("gid-stale-file", dedup_key=dedup_key)
async def fake_call(method, params=None, timeout=10.0):
if method == "tellStatus":
@@ -540,8 +543,8 @@ class TestAddTorrents:
assert result is AddResult.ADDED
async with Database() as db:
- assert await db.aria2.get("gid-stale-file") is None
- assert await db.aria2.get("gid-fresh-file") is not None
+ assert await db.aria2("default").get("gid-stale-file") is None
+ assert await db.aria2("default").get("gid-fresh-file") is not None
async def test_no_tags_leaves_bangumi_id_unset(self):
aria2 = _aria2()
@@ -554,7 +557,7 @@ class TestAddTorrents:
tags=None,
)
async with Database() as db:
- record = await db.aria2.get("gid-no-tag")
+ record = await db.aria2("default").get("gid-no-tag")
assert record is not None
assert record.bangumi_id is None
@@ -589,7 +592,9 @@ class TestTorrentsInfo:
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
async with Database() as db:
- await db.aria2.upsert("gidA", bangumi_id=5, category="Bangumi")
+ await db.aria2("default").upsert(
+ "gidA", bangumi_id=5, category="Bangumi"
+ )
result = await aria2.torrents_info(status_filter=None, category=None)
assert len(result) == 1
@@ -641,8 +646,8 @@ class TestTorrentsInfo:
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
async with Database() as db:
- await db.aria2.upsert("gidA", category="Bangumi")
- await db.aria2.upsert("gidB", category="Other")
+ await db.aria2("default").upsert("gidA", category="Bangumi")
+ await db.aria2("default").upsert("gidB", category="Other")
result = await aria2.torrents_info(status_filter=None, category="Bangumi")
assert [r["hash"] for r in result] == ["gidA"]
@@ -834,7 +839,9 @@ class TestTorrentsInfo:
return []
async with Database() as db:
- await db.aria2.upsert("metadata-gid", bangumi_id=5, category="Bangumi")
+ await db.aria2("default").upsert(
+ "metadata-gid", bangumi_id=5, category="Bangumi"
+ )
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
result = await aria2.torrents_info(status_filter=None, category="Bangumi")
@@ -845,8 +852,8 @@ class TestTorrentsInfo:
assert result[0]["tags"] == "ab:5"
assert result[0]["category"] == "Bangumi"
async with Database() as db:
- assert await db.aria2.get("metadata-gid") is None
- assert await db.aria2.get("payload-gid") is not None
+ assert await db.aria2("default").get("metadata-gid") is None
+ assert await db.aria2("default").get("payload-gid") is not None
# ---------------------------------------------------------------------------
@@ -854,6 +861,34 @@ class TestTorrentsInfo:
# ---------------------------------------------------------------------------
+async def test_two_instances_same_gid_kept_apart():
+ """两个 aria2 实例分配到同一个 gid:番剧关联与去重都只看本实例。"""
+ url = "magnet:?xt=urn:btih:abc"
+
+ async def fake_call(method, params=None, timeout=10.0):
+ if method == "addUri":
+ return "gid001"
+ if method == "tellStatus":
+ return {"status": "active"}
+ if method == "tellActive":
+ return [_download("gid001", status="active")]
+ return []
+
+ for instance_id, bangumi_id in (("a1", 1), ("a2", 2)):
+ aria2 = Aria2Downloader(f"http://{instance_id}:6800", "u", "s", instance_id)
+ with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
+ result = await aria2.add_torrents(
+ url, None, "/downloads", "Bangumi", tags=f"ab:{bangumi_id}"
+ )
+ assert result is AddResult.ADDED, instance_id
+
+ for instance_id, bangumi_id in (("a1", 1), ("a2", 2)):
+ aria2 = Aria2Downloader(f"http://{instance_id}:6800", "u", "s", instance_id)
+ with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
+ [info] = await aria2.torrents_info(status_filter=None, category=None)
+ assert info["tags"] == f"ab:{bangumi_id}", instance_id
+
+
class TestRenameIntentDatabase:
async def test_persists_and_reads_typed_rename_intent(self, tmp_path):
source = tmp_path / "old.mkv"
@@ -861,9 +896,9 @@ class TestRenameIntentDatabase:
intent = _rename_intent("old.mkv", "new.mkv", source)
async with Database() as db:
- await db.aria2.set_rename_intent("gidA", intent)
- assert await db.aria2.get_rename_intent("gidA") == intent
- record = await db.aria2.get("gidA")
+ await db.aria2("default").set_rename_intent("gidA", intent)
+ assert await db.aria2("default").get_rename_intent("gidA") == intent
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.rename_intent is not None
@@ -876,13 +911,15 @@ class TestRenameIntentDatabase:
intent = _rename_intent("old.mkv", "new.mkv", source)
async with Database() as db:
- await db.aria2.set_rename_intent("gidA", intent)
- finalized = await db.aria2.finalize_rename_intent("gidA", intent)
+ await db.aria2("default").set_rename_intent("gidA", intent)
+ finalized = await db.aria2("default").finalize_rename_intent("gidA", intent)
assert finalized is True
- assert await db.aria2.get_renamed_paths("gidA") == {"old.mkv": "new.mkv"}
- assert await db.aria2.get_rename_intent("gidA") is None
- record = await db.aria2.get("gidA")
+ assert await db.aria2("default").get_renamed_paths("gidA") == {
+ "old.mkv": "new.mkv"
+ }
+ assert await db.aria2("default").get_rename_intent("gidA") is None
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.rename_intent is None
@@ -902,12 +939,12 @@ class TestRenameIntentDatabase:
)
async with Database() as db:
- await db.aria2.set_rename_intent("gidA", persisted)
- finalized = await db.aria2.finalize_rename_intent("gidA", stale)
+ await db.aria2("default").set_rename_intent("gidA", persisted)
+ finalized = await db.aria2("default").finalize_rename_intent("gidA", stale)
assert finalized is False
- assert await db.aria2.get_renamed_paths("gidA") == {}
- assert await db.aria2.get_rename_intent("gidA") == persisted
+ assert await db.aria2("default").get_renamed_paths("gidA") == {}
+ assert await db.aria2("default").get_rename_intent("gidA") == persisted
# ---------------------------------------------------------------------------
@@ -948,7 +985,9 @@ class TestTorrentsFiles:
return None
async with Database() as db:
- await db.aria2.set_renamed_path("gidA", "ep01.mkv", "Show - 01.mkv")
+ await db.aria2("default").set_renamed_path(
+ "gidA", "ep01.mkv", "Show - 01.mkv"
+ )
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
files = await aria2.torrents_files("gidA")
@@ -1053,7 +1092,7 @@ class TestTorrentsRenameFile:
old_file.write_bytes(b"data")
intent = _rename_intent("old.mkv", "new.mkv", old_file)
async with Database() as db:
- await db.aria2.set_rename_intent("gidA", intent)
+ await db.aria2("default").set_rename_intent("gidA", intent)
old_file.rename(new_file)
async def fake_call(method, params=None, timeout=10.0):
@@ -1068,7 +1107,7 @@ class TestTorrentsRenameFile:
assert result.outcome is RenameOutcome.ALREADY_APPLIED
assert files == [{"name": "new.mkv", "size": 4}]
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.rename_intent is None
@@ -1088,7 +1127,7 @@ class TestTorrentsRenameFile:
assert result.outcome is RenameOutcome.DESTINATION_EXISTS
assert target.read_bytes() == b"unrelated"
async with Database() as db:
- assert await db.aria2.get_renamed_paths("gidA") == {}
+ assert await db.aria2("default").get_renamed_paths("gidA") == {}
async def test_crash_recovery_rejects_target_with_mismatched_fingerprint(
self, tmp_path
@@ -1098,7 +1137,7 @@ class TestTorrentsRenameFile:
old_file.write_bytes(b"original")
intent = _rename_intent("old.mkv", "new.mkv", old_file)
async with Database() as db:
- await db.aria2.set_rename_intent("gidA", intent)
+ await db.aria2("default").set_rename_intent("gidA", intent)
old_file.unlink()
(tmp_path / "new.mkv").write_bytes(b"unrelated")
@@ -1110,10 +1149,10 @@ class TestTorrentsRenameFile:
assert result.outcome is RenameOutcome.DESTINATION_EXISTS
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.rename_intent is None
- assert await db.aria2.get_renamed_paths("gidA") == {}
+ assert await db.aria2("default").get_renamed_paths("gidA") == {}
async def test_move_failure_clears_rename_intent(self, tmp_path):
aria2 = _aria2()
@@ -1130,7 +1169,7 @@ class TestTorrentsRenameFile:
assert result.outcome is RenameOutcome.RETRYABLE_FAILURE
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.rename_intent is None
@@ -1188,7 +1227,7 @@ class TestTorrentsDelete:
return None
async with Database() as db:
- await db.aria2.upsert("gidA", bangumi_id=1)
+ await db.aria2("default").upsert("gidA", bangumi_id=1)
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
result = await aria2.torrents_delete("gidA", delete_files=True)
@@ -1200,7 +1239,7 @@ class TestTorrentsDelete:
assert season_dir.exists() # boundary directory never removed
async with Database() as db:
- assert await db.aria2.get("gidA") is None
+ assert await db.aria2("default").get("gidA") is None
async def test_returns_false_on_remove_rpc_error(self):
aria2 = _aria2()
@@ -1215,7 +1254,7 @@ class TestTorrentsDelete:
return None
async with Database() as db:
- await db.aria2.upsert(
+ await db.aria2("default").upsert(
"gidA", bangumi_id=1, renamed_paths='{"old.mkv": "staged.mkv"}'
)
@@ -1224,7 +1263,7 @@ class TestTorrentsDelete:
assert result is False
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.renamed_paths == '{"old.mkv": "staged.mkv"}'
@@ -1247,8 +1286,10 @@ class TestTorrentsDelete:
return None
async with Database() as db:
- await db.aria2.upsert("gidA", bangumi_id=1)
- await db.aria2.set_renamed_path("gidA", original.name, staged.name)
+ await db.aria2("default").upsert("gidA", bangumi_id=1)
+ await db.aria2("default").set_renamed_path(
+ "gidA", original.name, staged.name
+ )
with patch.object(aria2, "_call", AsyncMock(side_effect=fake_call)):
result = await aria2.torrents_delete("gidA", delete_files=True)
@@ -1257,7 +1298,7 @@ class TestTorrentsDelete:
assert not staged.exists()
assert original.read_bytes() == b"new-v2"
async with Database() as db:
- assert await db.aria2.get("gidA") is None
+ assert await db.aria2("default").get("gidA") is None
async def test_file_listing_failure_preserves_sidecar_and_task(self):
aria2 = _aria2()
@@ -1274,7 +1315,7 @@ class TestTorrentsDelete:
return None
async with Database() as db:
- await db.aria2.upsert(
+ await db.aria2("default").upsert(
"gidA", bangumi_id=1, renamed_paths='{"old.mkv": "staged.mkv"}'
)
@@ -1284,7 +1325,7 @@ class TestTorrentsDelete:
assert result is False
assert "forceRemove" not in calls
async with Database() as db:
- assert await db.aria2.get("gidA") is not None
+ assert await db.aria2("default").get("gidA") is not None
async def test_not_found_error_is_treated_as_already_gone(self):
aria2 = _aria2()
@@ -1312,7 +1353,7 @@ class TestTorrentsDelete:
raise AssertionError(f"unexpected RPC after not-found: {method}")
async with Database() as db:
- await db.aria2.upsert(
+ await db.aria2("default").upsert(
"gidA", bangumi_id=1, renamed_paths='{"old.mkv": "staged.mkv"}'
)
@@ -1321,7 +1362,7 @@ class TestTorrentsDelete:
assert result is True
async with Database() as db:
- assert await db.aria2.get("gidA") is None
+ assert await db.aria2("default").get("gidA") is None
async def test_accepts_pipe_joined_hashes(self):
aria2 = _aria2()
@@ -1426,7 +1467,7 @@ class TestSetCategoryAddTag:
aria2 = _aria2()
await aria2.set_category("gidA", "BangumiCollection")
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.category == "BangumiCollection"
@@ -1434,7 +1475,7 @@ class TestSetCategoryAddTag:
aria2 = _aria2()
await aria2.add_tag("gidA", "ab:99")
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is not None
assert record.bangumi_id == 99
@@ -1442,7 +1483,7 @@ class TestSetCategoryAddTag:
aria2 = _aria2()
await aria2.add_tag("gidA", "some-other-tag")
async with Database() as db:
- record = await db.aria2.get("gidA")
+ record = await db.aria2("default").get("gidA")
assert record is None
diff --git a/backend/src/test/test_context.py b/backend/src/test/test_context.py
index c7ce8542..3167b894 100644
--- a/backend/src/test/test_context.py
+++ b/backend/src/test/test_context.py
@@ -80,6 +80,21 @@ class TestStartup:
assert ctx._startup_done is True
assert ctx.scheduler.running is False
+ async def test_first_run_with_legacy_data_json_aborts_startup(self, ctx, tmp_path):
+ """2.x 的卷只有 data.json 没有数据库:首次启动就拒绝,不建新库。"""
+ legacy = tmp_path / "data.json"
+ legacy.write_text("{}")
+ with (
+ patch("module.update.version_check.LEGACY_DATA_PATH", legacy),
+ patch("module.core.context.Checker.check_database", return_value=False),
+ patch("module.core.context.first_run") as mock_first_run,
+ ):
+ with pytest.raises(UnsupportedUpgradeError, match="2.x"):
+ await ctx.startup()
+
+ mock_first_run.assert_not_called()
+ assert ctx._startup_done is False
+
async def test_existing_db_runs_pending_migrations(self, ctx):
"""Existing DB from a supported version => run_migrations() is invoked."""
with (
diff --git a/backend/src/test/test_database.py b/backend/src/test/test_database.py
index 6e1a867f..4f971e15 100644
--- a/backend/src/test/test_database.py
+++ b/backend/src/test/test_database.py
@@ -135,7 +135,7 @@ async def test_bangumi_delete_all_with_fk_references_succeeds(db_session):
await db_session.execute(text("PRAGMA foreign_keys=ON"))
await _ensure_bangumi(db_session, 1)
db_session.add(Torrent(name="ep01", url="https://example.com/1", bangumi_id=1))
- db_session.add(Aria2Gid(gid="gid-1", bangumi_id=1))
+ db_session.add(Aria2Gid(downloader_id="default", gid="gid-1", bangumi_id=1))
await db_session.commit()
db = BangumiDatabase(db_session)
diff --git a/backend/src/test/test_downloader_protocol.py b/backend/src/test/test_downloader_protocol.py
index 232923d1..e987b4ae 100644
--- a/backend/src/test/test_downloader_protocol.py
+++ b/backend/src/test/test_downloader_protocol.py
@@ -22,7 +22,7 @@ def _qb() -> QbDownloader:
def _aria2() -> Aria2Downloader:
- return Aria2Downloader("http://localhost:6800", "u", "p")
+ return Aria2Downloader("http://localhost:6800", "u", "p", "default")
def _mock() -> MockDownloader:
diff --git a/backend/src/test/test_hardlink.py b/backend/src/test/test_hardlink.py
index a637b106..74b75ce3 100644
--- a/backend/src/test/test_hardlink.py
+++ b/backend/src/test/test_hardlink.py
@@ -60,6 +60,10 @@ def organized(*names: str, downloader: str = "default") -> TorrentOrganized:
)
+def failures(ctx) -> list:
+ return [e for e in ctx.bus.published if e.kind == "hardlink.failed"]
+
+
def exdev(src, dst):
raise OSError(errno.EXDEV, "Invalid cross-device link")
@@ -90,7 +94,7 @@ async def test_on_organized_maps_longest_prefix_of_same_downloader_and_links(
assert os.path.samefile(src, library / SEASON / src.name)
assert os.path.samefile(sub, library / SEASON / sub.name)
- assert ctx.bus.published == []
+ assert failures(ctx) == []
@pytest.mark.parametrize("mode", ["copy", "symlink", "skip"])
@@ -105,12 +109,12 @@ async def test_on_organized_cross_device_applies_policy(roots, monkeypatch, mode
dst = library / SEASON / src.name
if mode == "skip":
assert not dst.exists()
- [event] = ctx.bus.published
+ [event] = failures(ctx)
assert event.kind == "hardlink.failed" and src.name in event.files
return
assert dst.read_text() == "v1"
assert dst.is_symlink() is (mode == "symlink")
- assert ctx.bus.published == []
+ assert failures(ctx) == []
async def test_on_organized_redelivery_does_not_copy_again(roots, monkeypatch):
@@ -130,7 +134,7 @@ async def test_on_organized_redelivery_does_not_copy_again(roots, monkeypatch):
await plugin.on_organized(organized(src.name))
assert len(copies) == 1
- assert ctx.bus.published == []
+ assert failures(ctx) == []
async def test_on_organized_interrupted_copy_leaves_no_target(roots, monkeypatch):
@@ -152,7 +156,7 @@ async def test_on_organized_interrupted_copy_leaves_no_target(roots, monkeypatch
assert (library / SEASON / src.name).read_text() == "v1"
assert [p.name for p in (library / SEASON).iterdir()] == [src.name]
- [failed] = ctx.bus.published
+ [failed] = failures(ctx)
assert "No space left" in failed.files
@@ -170,7 +174,7 @@ async def test_on_organized_revision_upgrade_replaces_own_link(roots):
dst = library / SEASON / src.name
assert os.path.samefile(src, dst) and dst.read_text() == "v2"
assert [p.name for p in dst.parent.iterdir()] == [src.name]
- assert ctx.bus.published == []
+ assert failures(ctx) == []
async def test_on_organized_problem_files_kept_and_notified_once(roots, tmp_path):
@@ -189,7 +193,7 @@ async def test_on_organized_problem_files_kept_and_notified_once(roots, tmp_path
await plugin.on_organized(organized(taken.name, "outside/x.mkv"))
assert (library / SEASON / taken.name).read_text() == "user file"
- [event] = ctx.bus.published
+ [event] = failures(ctx)
assert event.kind == "hardlink.failed" and event.torrent_hash == "h1"
assert taken.name in event.files and "x.mkv" in event.files
@@ -213,7 +217,7 @@ def test_backfill_route_links_existing_files_and_counts(roots):
downloads / SEASON / "new.zh.ass", library / SEASON / "new.zh.ass"
)
assert not (library / SEASON / "note.nfo").exists()
- assert ctx.bus.published == []
+ assert failures(ctx) == []
@pytest.mark.parametrize(
@@ -250,7 +254,7 @@ async def test_on_organized_unrecorded_own_copy_adopted_and_upgraded(
await plugin.on_organized(organized(src.name))
assert dst.read_text() == "v2"
- assert ctx.bus.published == []
+ assert failures(ctx) == []
async def test_on_organized_user_file_at_former_link_kept(roots):
@@ -268,7 +272,7 @@ async def test_on_organized_user_file_at_former_link_kept(roots):
await plugin.on_organized(organized(src.name))
assert dst.read_text() == "user file"
- [event] = ctx.bus.published
+ [event] = failures(ctx)
assert event.kind == "hardlink.failed" and src.name in event.files
@@ -292,3 +296,43 @@ async def test_link_concurrent_copies_of_same_target_all_succeed(roots, monkeypa
dst = library / SEASON / src.name
assert dst.read_text() == "v1"
assert [p.name for p in dst.parent.iterdir()] == [src.name]
+
+
+@pytest.mark.parametrize("via_backfill", [False, True])
+async def test_link_target_deleted_by_user_restored_only_by_backfill(
+ roots, via_backfill
+):
+ downloads, library = roots
+ plugin, ctx = make_plugin(roots)
+ src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
+ await plugin.on_organized(organized(src.name))
+ # 用户看完后从媒体库删掉;torrent.organized 重启后会再次投递
+ dst = library / SEASON / src.name
+ dst.unlink()
+
+ if via_backfill:
+ assert await plugin.backfill() == {
+ "linked": 1,
+ "exists": 0,
+ "conflict": 0,
+ "failed": 0,
+ }
+ else:
+ await plugin.on_organized(organized(src.name))
+
+ assert dst.exists() is via_backfill
+ assert failures(ctx) == []
+
+
+async def test_on_organized_publishes_linked_only_when_file_placed(roots):
+ downloads, library = roots
+ plugin, ctx = make_plugin(roots)
+ src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
+
+ await plugin.on_organized(organized(src.name))
+ await plugin.on_organized(organized(src.name))
+
+ # 媒体库刷新据此在文件放好后再刷新一次;已链接的重复投递不再发布
+ assert [(e.kind, e.torrent_hash) for e in ctx.bus.published] == [
+ ("hardlink.linked", "h1")
+ ]
diff --git a/backend/src/test/test_llm_plugin_installer.py b/backend/src/test/test_llm_plugin_installer.py
index 898cbfef..e03a5c1f 100644
--- a/backend/src/test/test_llm_plugin_installer.py
+++ b/backend/src/test/test_llm_plugin_installer.py
@@ -206,6 +206,29 @@ class TestInstallRejections:
assert result.success is False
+class TestUninstallScope:
+ @pytest.mark.parametrize("plugin_id", ["..", "local", "generic-plugin"])
+ async def test_uninstall_foreign_or_unsafe_id_deletes_nothing(
+ self, tmp_path, keypair, plugin_id
+ ):
+ installer = _installer(tmp_path, keypair, _make_plugin_zip())
+ root = tmp_path / "plugins"
+ # 本地插件源码、经通用安装器装入的插件,以及插件根之外的配置
+ (root / "local" / "mine").mkdir(parents=True)
+ generic = root / "generic-plugin"
+ (generic / "0.1.0").mkdir(parents=True)
+ (generic / "0.1.0" / "plugin.toml").write_text("")
+ (generic / "installed.json").write_text('{"version": "0.1.0"}')
+ (tmp_path / "data.db").write_text("")
+
+ result = await installer.uninstall(plugin_id)
+
+ assert result.success is False
+ assert (root / "local" / "mine").is_dir()
+ assert (generic / "installed.json").is_file()
+ assert (tmp_path / "data.db").is_file()
+
+
class TestUninstallClearsCredential:
async def test_uninstall_deletes_credential_row(self, tmp_path, keypair):
from unittest.mock import AsyncMock, patch
diff --git a/backend/src/test/test_media_server_refresh.py b/backend/src/test/test_media_server_refresh.py
index e58af2ba..8f800e8a 100644
--- a/backend/src/test/test_media_server_refresh.py
+++ b/backend/src/test/test_media_server_refresh.py
@@ -2,11 +2,13 @@
import asyncio
import logging
+from types import SimpleNamespace
import httpx
import pytest
from ab_sdk.events import OrganizedFile, TorrentOrganized
+from ab_sdk.hooks import SUBSCRIBE_ATTR
from ab_sdk.testing import create_plugin
from module.plugin.loader import BUILTIN_ROOT, discover
@@ -134,3 +136,41 @@ async def test_teardown_pending_refresh_cancelled():
await plugin.teardown()
assert pending.cancelled()
assert requests == []
+
+
+async def test_on_organized_during_refresh_request_refreshes_again():
+ plugin, _ = make_plugin({"url": "http://srv", "api_key": "k", "delay": 0})
+ requests: list[httpx.Request] = []
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ requests.append(request)
+ if len(requests) == 1:
+ # 刷新请求已发出、尚未返回时又有种子整理完成
+ await plugin.on_organized(organized(2))
+ return httpx.Response(204)
+
+ plugin.client_factory = lambda: httpx.AsyncClient(
+ transport=httpx.MockTransport(handler)
+ )
+ await plugin.on_organized(organized(1))
+ assert plugin._pending is not None
+ await asyncio.wait_for(plugin._pending, 1)
+
+ assert len(requests) == 2
+
+
+async def test_hardlink_linked_event_schedules_refresh():
+ plugin, requests = make_plugin({"url": "http://srv", "api_key": "k", "delay": 0})
+ # 硬链接(跨盘复制可能比 delay 更久)放好文件后再刷新一次
+ [handler] = [
+ getattr(plugin, name)
+ for name, member in vars(type(plugin)).items()
+ if getattr(getattr(member, SUBSCRIBE_ATTR, None), "kind", None)
+ == "hardlink.linked"
+ ]
+
+ await handler(SimpleNamespace(kind="hardlink.linked", torrent_hash="h1"))
+ assert plugin._pending is not None
+ await asyncio.wait_for(plugin._pending, 1)
+
+ assert len(requests) == 1
diff --git a/backend/src/test/test_migrations_module.py b/backend/src/test/test_migrations_module.py
index e2d3d0ee..f3e2b709 100644
--- a/backend/src/test/test_migrations_module.py
+++ b/backend/src/test/test_migrations_module.py
@@ -140,6 +140,8 @@ def _make_v19_auth_engine() -> Engine:
engine = create_engine("sqlite://")
with engine.begin() as conn:
conn.execute(text("PRAGMA foreign_keys=ON"))
+ # 真实数据库总有 bangumi 表;v27 重建 aria2_gid 时外键检查要求它存在
+ conn.execute(text("CREATE TABLE bangumi (id INTEGER PRIMARY KEY)"))
conn.execute(
text(
"CREATE TABLE user ("
@@ -738,6 +740,40 @@ class TestRunMigrations:
).scalars()
assert list(values) == [expected.get(table, "default")], table
+ def test_v27_aria2_gid_existing_rows_scoped_to_default_instance(self, monkeypatch):
+ """v27:aria2_gid 主键加上实例 id,存量行归属 default,各列原样保留;
+ 另一个实例可以有同一个 gid。"""
+ engine = _make_v0_engine()
+ _run_through_version(engine, 26, monkeypatch)
+ with engine.begin() as conn:
+ conn.execute(
+ text(
+ "INSERT INTO aria2_gid (gid, category, dedup_key, renamed_paths, "
+ "rename_intent, created_at) "
+ "VALUES ('g1', 'Bangumi', 'url:x', '{}', 'i', '2026-01-01')"
+ )
+ )
+
+ run_migrations(engine)
+
+ with engine.begin() as conn:
+ row = conn.execute(
+ text(
+ "SELECT downloader_id, category, dedup_key, renamed_paths, "
+ "rename_intent FROM aria2_gid"
+ )
+ ).one()
+ conn.execute(
+ text(
+ "INSERT INTO aria2_gid (downloader_id, gid, created_at) "
+ "VALUES ('b', 'g1', '2026-01-01')"
+ )
+ )
+ assert tuple(row) == ("default", "Bangumi", "url:x", "{}", "i")
+ assert "ix_aria2_gid_dedup_key" in {
+ ix["name"] for ix in inspect(engine).get_indexes("aria2_gid")
+ }
+
def test_is_idempotent(self):
engine = _make_v0_engine()
run_migrations(engine)
diff --git a/backend/src/test/test_multi_downloader.py b/backend/src/test/test_multi_downloader.py
index b3aa758b..7f26bebd 100644
--- a/backend/src/test/test_multi_downloader.py
+++ b/backend/src/test/test_multi_downloader.py
@@ -90,6 +90,7 @@ def test_download_client_qb_and_aria2_side_by_side(monkeypatch):
assert isinstance(qb.client, QbDownloader)
assert isinstance(ar.client, Aria2Downloader)
assert ar.client.host.endswith("ar:6800")
+ assert ar.client.instance_id == "ar"
assert Renamer(qb)._downloader_type() != Renamer(ar)._downloader_type()
@@ -447,24 +448,73 @@ class TestRuleOnAnotherInstance:
moved = _mock("b")._torrents["hb"]["save_path"]
assert moved == "/b/Bangumi/Test Anime (2024)/Season 2"
- async def test_delete_rule_unavailable_instance_others_still_deleted(
+ async def test_update_rule_unavailable_instance_changes_nothing_for_retry(
self, two_instances
):
+ """实例 b 不可用:a 上的种子不移动、规则不改,b 恢复后重试两边一起移动。"""
+ from module.manager import TorrentManager
+ from module.models import BangumiUpdate
+
+ bangumi_id = await self._rule_with_torrent_on_a()
+ hb = _completed("B", "hb", "/b/Bangumi/Test Anime (2024)/Season 1")
+ _mock("b")._torrents["hb"] = hb
+ _mock("b").auth = AsyncMock(return_value=False) # type: ignore[method-assign]
+ async with Database() as db:
+ await db.torrent.add(
+ Torrent(name="B", url="u-b", bangumi_id=1, downloader_id="b")
+ )
+ old = await db.bangumi.search_id(bangumi_id)
+ assert old is not None
+ update = BangumiUpdate(**old.model_dump(exclude={"id"}))
+ update.season = 2
+ resp = await TorrentManager(db).update_rule(bangumi_id, update)
+ assert resp.status is False and resp.status_code == 500
+ assert _mock("a")._torrents["old"]["save_path"] == old.save_path
+ kept = await db.bangumi.search_id(bangumi_id)
+ assert kept is not None and kept.save_path == old.save_path
+
+ # 登录失败会丢弃缓存的客户端,恢复后是新的客户端实例
+ _mock("b")._torrents["hb"] = hb
+ update = BangumiUpdate(**old.model_dump(exclude={"id"}))
+ update.season = 2
+ retry = await TorrentManager(db).update_rule(bangumi_id, update)
+ assert retry.status is True
+ new = await db.bangumi.search_id(bangumi_id)
+
+ season2 = "Bangumi/Test Anime (2024)/Season 2"
+ assert new is not None and new.save_path == f"/a/{season2}"
+ assert _mock("a")._torrents["old"]["save_path"] == f"/a/{season2}"
+ assert _mock("b")._torrents["hb"]["save_path"] == f"/b/{season2}"
+
+ async def test_delete_rule_unavailable_instance_keeps_rule_for_retry(
+ self, two_instances
+ ):
+ """实例 a 不可用:b 上的种子照常删除,规则与种子行保留,a 恢复后重试删净。"""
from module.manager import TorrentManager
bangumi_id = await self._rule_with_torrent_on_a()
_mock("b")._torrents["new"] = _completed(
"New", "new", "/b/Bangumi/Test Anime (2024)/Season 1"
)
+ auth = _mock("a").auth
_mock("a").auth = AsyncMock(return_value=False) # type: ignore[method-assign]
async with Database() as db:
await db.torrent.add(
Torrent(name="New", url="u-new", bangumi_id=1, downloader_id="b")
)
resp = await TorrentManager(db).delete_rule(bangumi_id, file=True)
+ assert _mock("b")._torrents == {}
+ assert resp.status is False and resp.status_code == 500
+ assert await db.bangumi.search_id(bangumi_id) is not None
+ rows = await db.torrent.search_by_bangumi_id(bangumi_id)
+ assert "a" in {r.downloader_id for r in rows}
- assert _mock("b")._torrents == {}
- assert resp.status is False and resp.status_code == 500
+ _mock("a").auth = auth # type: ignore[method-assign]
+ retry = await TorrentManager(db).delete_rule(bangumi_id, file=True)
+ assert retry.status is True
+ assert _mock("a")._torrents == {}
+ assert await db.bangumi.search_id(bangumi_id) is None
+ assert await db.torrent.search_by_bangumi_id(bangumi_id) == []
async def test_eps_complete_unavailable_instance_other_rules_collected(two_instances):
diff --git a/backend/src/test/test_organize.py b/backend/src/test/test_organize.py
index c7f70a71..dc68fb2f 100644
--- a/backend/src/test/test_organize.py
+++ b/backend/src/test/test_organize.py
@@ -23,6 +23,7 @@ from ab_sdk.rename import (
)
from ab_sdk.testing import RecordingBus, create_plugin
from module.downloader import DownloadClient
+from module.downloader.path import check_files
from module.manager import renamer as renamer_module
from module.manager.renamer import Renamer
from module.manager.revision_policy import CoreConflictPolicy
@@ -118,7 +119,8 @@ async def test_rename_unregistered_method_keeps_names_without_tag(plugins, renam
renamer.client.client.torrents_rename_file.assert_not_called()
renamer.client.client.add_tag.assert_not_called()
- assert published(plugins.bus, TorrentOrganized)
+ # 原名不是最终文件名:策略恢复后会改名,此时发布会让硬链接留下两份
+ assert published(plugins.bus, TorrentOrganized) == []
async def test_rename_plugin_strategy_renames_and_publishes_events(plugins, renamer):
@@ -193,6 +195,35 @@ async def test_rename_none_publishes_organized_once_per_process(plugins, renamer
assert organized[0].files[0] == OrganizedFile(f"{SAVE_PATH}/{NAME}", "media")
+async def test_rename_tagged_torrent_republishes_organized_once_per_process(
+ plugins, renamer
+):
+ # 已改名、打了 ab:renamed 的种子:上个进程发布的事件可能没被处理完
+ # (重启、订阅者被取消),本进程按当前文件名补发一次
+ renamer.client.client.torrents_info.return_value = [
+ {**INFO, "tags": "ab:7, ab:renamed"}
+ ]
+ renamer.client.client.torrents_files.return_value = [
+ {"name": "Anime S01E01.mkv"},
+ {"name": "Anime S01E01.zh.ass"},
+ ]
+
+ await run(renamer, "pn")
+ await run(renamer, "pn")
+
+ renamer.client.client.torrents_rename_file.assert_not_called()
+ assert published(plugins.bus, TorrentOrganized) == [
+ TorrentOrganized(
+ torrent_hash="h1",
+ bangumi_id=7,
+ files=(
+ OrganizedFile(f"{SAVE_PATH}/Anime S01E01.mkv", "media"),
+ OrganizedFile(f"{SAVE_PATH}/Anime S01E01.zh.ass", "subtitle"),
+ ),
+ )
+ ]
+
+
def _skip(f: RenameInput) -> str:
raise RenameSkipped("模板渲染失败:'titel' is undefined")
@@ -226,6 +257,22 @@ async def test_rename_strategy_failure_keeps_name_and_notifies_once(
assert plugins.tripped == (["ext"] if counted else [])
+@pytest.mark.parametrize("classify", [lambda path: 1 / 0, lambda path: "video"])
+def test_check_files_plugin_media_files_failure_falls_back_to_suffix(plugins, classify):
+ impl = SimpleNamespace(classify=classify)
+ plugins.registry.add_provider(
+ points.MEDIA_FILES, ProviderEntry("ext", "custom", lambda: impl)
+ )
+ files = [{"name": NAME}, {"name": SUB}, {"name": "readme.txt"}]
+
+ with patch("module.downloader.path.settings") as mock_settings:
+ mock_settings.plugins.slots.media_files = "custom"
+ results = [check_files(files), check_files(files)]
+
+ assert results == [([NAME], [SUB])] * 2
+ assert plugins.tripped == ["ext"]
+
+
def _task(file_count: int = 1, revision: int | None = 1) -> RevisionTask:
identity = (
None
diff --git a/backend/src/test/test_plugin_catalog.py b/backend/src/test/test_plugin_catalog.py
index ccb4a2a2..4a80c004 100644
--- a/backend/src/test/test_plugin_catalog.py
+++ b/backend/src/test/test_plugin_catalog.py
@@ -241,7 +241,11 @@ class TestInstall:
class TestReleaseScript:
- async def test_catalog_built_by_release_script_installs(self, tmp_path, keypair):
+ # 默认 --min-ab 须放行 4.0 的 beta 宿主(semver 中 4.0.0-beta.N < 4.0.0)
+ @pytest.mark.parametrize("app_version", ["4.0.0-beta.1", "4.0.0"])
+ async def test_catalog_built_by_release_script_installs(
+ self, tmp_path, keypair, app_version
+ ):
script = (
Path(__file__).resolve().parents[3] / "scripts" / "build_plugin_catalog.py"
)
@@ -258,7 +262,7 @@ class TestReleaseScript:
archive.parent.mkdir()
archive.write_bytes(build_zip(tmp_path))
- module.build([archive], tmp_path / "release", key_path, "4.0.0")
+ module.build([archive], tmp_path / "release", key_path)
released = tmp_path / "release"
@@ -270,7 +274,7 @@ class TestReleaseScript:
root=tmp_path / "plugins",
pubkey_path=pubkey_path,
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
- app_version="4.0.0",
+ app_version=app_version,
)
[entry] = await installer.fetch_catalog()
assert entry["extension_points"] == ["rename_strategy"]
diff --git a/backend/src/test/test_plugin_extensions.py b/backend/src/test/test_plugin_extensions.py
index df6df807..8b74e862 100644
--- a/backend/src/test/test_plugin_extensions.py
+++ b/backend/src/test/test_plugin_extensions.py
@@ -6,7 +6,7 @@ from typing import ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from pydantic import BaseModel, field_validator
+from pydantic import BaseModel, Field, field_validator
from ab_sdk import points, secret_field
from ab_sdk.downloader import (
@@ -468,6 +468,13 @@ class NestedOptions(BaseModel):
NESTED = NestedOptions.model_json_schema()
+class WrappedSecrets(BaseModel):
+ cookie: str | None = secret_field()
+ tokens: list[str] = Field(default=[], json_schema_extra={"secret": True})
+ headers: dict[str, str] = Field(default={}, json_schema_extra={"secret": True})
+ server: Server | None = secret_field()
+
+
class TestNestedSecrets:
def test_mask_options_nested_secrets_masked(self):
options = {
@@ -533,6 +540,30 @@ class TestNestedSecrets:
masked = mask_options(current, NESTED)
assert restore_options(masked, current, NESTED) == current
+ @pytest.mark.parametrize(
+ "field, value, masked",
+ [
+ # 可选字段:秘密标记在 anyOf 外层
+ ("cookie", "c=1", MASK),
+ # 秘密标记在容器上:元素 / 字典值 / 嵌套对象都按秘密处理
+ ("tokens", ["t1", "t2"], [MASK, MASK]),
+ ("headers", {"auth": "h"}, {"auth": MASK}),
+ (
+ "server",
+ {"host": "a", "password": "p"},
+ {"host": MASK, "password": MASK},
+ ),
+ ],
+ )
+ def test_mask_options_secret_on_wrapper_masked_and_restored(
+ self, field, value, masked
+ ):
+ schema = WrappedSecrets.model_json_schema()
+ assert mask_options({field: value}, schema) == {field: masked}
+ assert restore_options({field: masked}, {field: value}, schema) == {
+ field: value
+ }
+
def test_restore_options_nested_without_schema_restored(self):
current = {"servers": [{"host": "a", "n": 1}]}
masked = mask_options(current, None)
diff --git a/backend/src/test/test_plugin_runtime.py b/backend/src/test/test_plugin_runtime.py
index 33891cdf..7e6cd8d7 100644
--- a/backend/src/test/test_plugin_runtime.py
+++ b/backend/src/test/test_plugin_runtime.py
@@ -555,6 +555,38 @@ class TestLoader:
with pytest.raises(PluginLoadError, match=message):
candidates[0].load()
+ def test_undecodable_manifest_is_reported(self, tmp_path):
+ local = tmp_path / "local"
+ (local / "demo").mkdir(parents=True)
+ (local / "demo" / "plugin.toml").write_bytes(b"\xff\xfe[plugin]\n")
+ candidates, errors = discover(
+ builtin_root=tmp_path / "none",
+ local_root=local,
+ entry_point_group="ab-test-none",
+ )
+ assert candidates == []
+ assert len(errors) == 1
+
+ def test_entry_point_import_failure_is_reported(self, tmp_path, monkeypatch):
+ import importlib.metadata
+
+ package = tmp_path / "ab_broken_ep"
+ package.mkdir()
+ (package / "__init__.py").write_text("from os import ab_missing_name\n")
+ monkeypatch.syspath_prepend(str(tmp_path))
+ ep = importlib.metadata.EntryPoint(
+ "broken", "ab_broken_ep.plugin:BrokenPlugin", "ab-test-broken"
+ )
+ monkeypatch.setattr(importlib.metadata, "entry_points", lambda group: [ep])
+ candidates, errors = discover(
+ builtin_root=tmp_path / "none",
+ local_root=tmp_path / "none",
+ entry_point_group="ab-test-broken",
+ )
+ assert candidates == []
+ [error] = errors
+ assert "ab_missing_name" in error.error
+
# ---------------------------------------------------------------- manager
@@ -771,28 +803,55 @@ class TestManager:
await manager.stop()
async def test_failed_setup_rolls_back_registrations(self, tmp_path):
+ recorder = Recorder()
+
class Broken(Plugin):
@hook("torrent.filter")
def check(self, x):
return True
async def setup(self):
+ recorder.events.append("setup")
raise RuntimeError("cannot connect")
+ async def teardown(self):
+ recorder.events.append("teardown")
+
manager, _ = make_manager(candidate_for(Broken), tmp_path=tmp_path)
await manager.start()
assert manager.registry.hooks("torrent.filter") == []
assert manager.statuses()[0].error == "cannot connect"
+ # setup 中途创建的资源由 teardown 释放
+ assert recorder.events == ["setup", "teardown"]
+
+ async def test_disabling_failed_plugin_shows_disabled(self, tmp_path):
+ class Broken(Plugin):
+ async def setup(self):
+ raise RuntimeError("cannot connect")
+
+ manager, settings_obj = make_manager(candidate_for(Broken), tmp_path=tmp_path)
+ await manager.start()
+ settings_obj.plugins.enabled = {"demo": False}
+ await manager.apply_settings()
+ [status] = manager.statuses()
+ assert (status.state, status.error) == ("disabled", "未启用")
async def test_unknown_extension_point_fails_load(self, tmp_path):
+ recorder = Recorder()
+
class Typo(Plugin):
@hook("torrent.filtr")
def check(self, x):
return True
+ async def teardown(self):
+ recorder.events.append("teardown")
+
manager, _ = make_manager(candidate_for(Typo), tmp_path=tmp_path)
await manager.start()
assert "未知扩展点" in (manager.statuses()[0].error or "")
+ # setup 未执行,没有需要释放的资源
+ assert recorder.events == []
async def test_apply_settings_reloads_on_option_change_only(self, tmp_path):
recorder = Recorder()
@@ -848,6 +907,37 @@ class TestManager:
assert len(seen) == 1
await manager.stop()
+ async def test_subscriber_success_resets_failure_count(self, tmp_path):
+ class Sometimes(Plugin):
+ @subscribe("demo.happened")
+ def on_demo(self, event):
+ if event.value < 0:
+ raise RuntimeError("transient")
+
+ manager, _ = make_manager(candidate_for(Sometimes), tmp_path=tmp_path)
+ await manager.start()
+ for value in [-1] * 4 + [1] + [-1] * 4:
+ manager.bus.publish(DemoEvent(value))
+ await manager.bus.drain()
+ await asyncio.gather(*manager._pending)
+ assert manager.statuses()[0].state == "active"
+ await manager.stop()
+
+ async def test_stale_breaker_trip_spares_reloaded_instance(self, tmp_path):
+ recorder = Recorder()
+ manager, _ = make_manager(
+ candidate_for(build_plugin(recorder)), tmp_path=tmp_path
+ )
+ await manager.start()
+ # 熔断任务尚未拿到锁时,插件已被重新加载(安装升级 / dev_mode)
+ manager._on_trip("demo", "连续失败 5 次")
+ await manager.reload("demo")
+ await asyncio.gather(*manager._pending)
+ [status] = manager.statuses()
+ assert status.state == "active"
+ assert recorder.events == ["setup:0", "teardown", "setup:0"]
+ await manager.stop()
+
async def test_end_to_end_from_directory(self, tmp_path):
write_plugin(
tmp_path / "builtin",
diff --git a/backend/src/test/test_release_classification.py b/backend/src/test/test_release_classification.py
index 8a8c8059..9f5329d3 100644
--- a/backend/src/test/test_release_classification.py
+++ b/backend/src/test/test_release_classification.py
@@ -122,3 +122,17 @@ def test_release_job_marks_dev_tags_prerelease_and_attaches_plugin_assets():
assert (
SCRIPT_PATH.parents[1] / "skills" / "autobangumi-plugin" / "SKILL.md"
).is_file()
+
+
+def test_update_bundle_carries_sdk_and_requires_4x_image():
+ """4.x bundle 带 ab_sdk 与新依赖;3.3 镜像在 beta 通道会选中它,
+ 只能靠 min_image_version 拒绝(3.3 的 boot_overlay 不会替换 ab_sdk)。"""
+ workflow = SCRIPT_PATH.parents[1] / ".github" / "workflows" / "build.yml"
+ source = workflow.read_text(encoding="utf-8")
+ step = source[
+ source.index("- name: Build update bundle") : source.index(
+ "- name: Sign update bundle"
+ )
+ ]
+ assert 'MIN_IMAGE_VERSION="4.0.0-beta.1"' in step
+ assert 'cp -r backend/src/ab_sdk "$STAGE/backend/src/ab_sdk"' in step
diff --git a/backend/src/test/test_renamer.py b/backend/src/test/test_renamer.py
index 4b2587fa..798c0f09 100644
--- a/backend/src/test/test_renamer.py
+++ b/backend/src/test/test_renamer.py
@@ -1003,7 +1003,13 @@ class TestRevisionConflictFlow:
def _offsets():
return {"new-v2": (0, "episode")}
- async def test_renamed_tag_skips_before_file_and_offset_queries(self, renamer):
+ async def test_renamed_tag_skips_before_file_and_offset_queries(
+ self, renamer, monkeypatch
+ ):
+ from module.manager import renamer as renamer_module
+
+ # 本进程已发布过它的 torrent.organized(否则会按当前文件名补发一次)
+ monkeypatch.setitem(renamer_module._organized_published, "old-v1", ())
renamer.client.client.torrents_info.return_value = [self._infos()[0]]
with patch.object(renamer, "_batch_lookup_offsets", AsyncMock()) as lookup:
assert await renamer.rename() == []
@@ -1138,6 +1144,55 @@ class TestRevisionConflictFlow:
assert restarted.events == []
renamer.client.client.torrents_rename_file.assert_not_awaited()
+ @pytest.mark.parametrize(
+ "decide",
+ [
+ lambda request: 1 / 0,
+ lambda request: "replace", # 不是 ConflictDecision
+ ],
+ )
+ async def test_plugin_conflict_policy_failure_holds_instead_of_aborting(
+ self, renamer, test_settings, decide
+ ):
+ from types import SimpleNamespace
+
+ from ab_sdk import points
+ from module.plugin import host
+ from module.plugin.registry import ProviderEntry
+
+ impl = SimpleNamespace(decide=decide)
+ host.get_registry().add_provider(
+ points.CONFLICT_POLICY, ProviderEntry("ext", "ext-policy", lambda: impl)
+ )
+ renamer.client.client.torrents_info.return_value = self._infos()
+
+ async def files(torrent_hash):
+ if torrent_hash == "old-v1":
+ return [{"name": self.TARGET}]
+ return [{"name": self.V2}]
+
+ renamer.client.client.torrents_files.side_effect = files
+ test_settings.plugins.slots.rename_strategy = "pn"
+ test_settings.plugins.slots.conflict_policy = "ext-policy"
+
+ try:
+ with (
+ patch("module.manager.renamer.settings", test_settings),
+ patch.object(
+ renamer,
+ "_batch_lookup_offsets",
+ AsyncMock(return_value=self._offsets()),
+ ),
+ ):
+ assert await renamer.rename() == []
+ finally:
+ host.get_registry().remove_plugin("ext")
+
+ # 插件失败按 hold 处理:记录冲突并通知,不删除旧版本
+ assert len(renamer.events) == 1
+ renamer.client.client.torrents_rename_file.assert_not_awaited()
+ renamer.client.client.torrents_delete.assert_not_awaited()
+
async def test_replace_stages_promotes_then_deletes_old(
self, renamer, test_settings
):
diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py
index 369bf022..b7dd9eb2 100644
--- a/backend/src/test/test_setup.py
+++ b/backend/src/test/test_setup.py
@@ -377,6 +377,32 @@ class TestSetupComplete:
}
]
+ @pytest.mark.parametrize(
+ ("provider", "field", "old", "value"),
+ [
+ ("bark", "device_key", "notification_token", "bark-key"),
+ ("wecom", "webhook_url", "notification_chat_id", "https://wecom/hook"),
+ ],
+ )
+ def test_complete_notification_alias_saved_to_provider_field(
+ self, client, mock_first_run, auth_service, provider, field, old, value
+ ):
+ """向导的通用 token / chat_id 输入要写成渠道实际读取的字段。"""
+ auth_service.authenticate_session.return_value = User(
+ id=7, username="admin", password="hashed-password", enabled=True
+ )
+ client.cookies.set("token", "pre-setup-session")
+ payload = self._payload() | {
+ "notification_enable": True,
+ "notification_type": provider,
+ old: value,
+ }
+ response = client.post("/api/v1/setup/complete", json=payload)
+
+ assert response.status_code == 200
+ saved = mock_first_run.save.call_args.args[0]
+ assert saved["notification"]["providers"][0][field] == value
+
def test_complete_routes_through_ctx_reload_settings(
self, client, mock_first_run, mock_ctx, auth_service
):
@@ -459,6 +485,41 @@ class TestSetupComplete:
auth_service.update_user.assert_not_awaited()
+@pytest.mark.parametrize(
+ ("provider", "field", "body"),
+ [
+ ("bark", "device_key", {"token": "bark-key"}),
+ ("wecom", "webhook_url", {"token": "k", "chat_id": "https://wecom/hook"}),
+ ],
+)
+def test_test_notification_alias_reaches_provider_field(
+ client, mock_first_run, provider, field, body
+):
+ """测试通知用的配置与保存的一致:Bark 读 device_key,WeCom 读 webhook_url。"""
+ seen = []
+
+ class StubProvider:
+ def __init__(self, config):
+ seen.append(config)
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ async def test(self):
+ return True, ""
+
+ with patch.dict("module.api.setup.PROVIDER_REGISTRY", {provider: StubProvider}):
+ response = client.post(
+ "/api/v1/setup/test-notification", json={"type": provider, **body}
+ )
+
+ assert response.json()["success"] is True
+ assert getattr(seen[0], field) == list(body.values())[-1]
+
+
class TestSentinelPath:
def test_sentinel_path_is_in_config_dir(self):
assert str(SENTINEL_PATH) == "config/.setup_complete"
diff --git a/backend/src/test/test_update.py b/backend/src/test/test_update.py
index f6fadf5b..780874ce 100644
--- a/backend/src/test/test_update.py
+++ b/backend/src/test/test_update.py
@@ -49,7 +49,9 @@ def _write_test_pubkey(dest_dir: Path) -> Path:
# ---------------------------------------------------------------------------
-def _make_bundle(version: str, min_image_version: str = "3.3.0-beta.1") -> bytes:
+def _make_bundle(
+ version: str, min_image_version: str = "3.3.0-beta.1", with_sdk: bool = True
+) -> bytes:
"""构造一个合法的 update bundle zip(内存字节)。"""
lock_content = f"# uv.lock for {version}\n".encode()
lock_sha = hashlib.sha256(lock_content).hexdigest()
@@ -62,6 +64,8 @@ def _make_bundle(version: str, min_image_version: str = "3.3.0-beta.1") -> bytes
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("backend/src/module/__marker__.txt", f"module {version}")
+ if with_sdk:
+ zf.writestr("backend/src/ab_sdk/__marker__.txt", f"sdk {version}")
zf.writestr("backend/pyproject.toml", "[project]\nname='x'\n")
zf.writestr("backend/uv.lock", lock_content)
zf.writestr("webui-dist/index.html", f"{version}")
@@ -421,14 +425,23 @@ class TestApplyUpdate:
assert (tmp_path / "u" / "bundle.zip.sig").exists()
@pytest.mark.asyncio
- async def test_incompatible_min_image_version_aborts(self, tmp_path):
- data = _make_bundle("3.3.0-beta.2", min_image_version="9.9.9")
+ @pytest.mark.parametrize(
+ ("image", "min_image_version"),
+ # 第二组:已发布的 3.3 镜像在 beta 通道拿到 4.x bundle(缺 ab_sdk 与新依赖)
+ [("3.3.0-beta.1", "9.9.9"), ("3.3.6", "4.0.0-beta.1")],
+ )
+ async def test_incompatible_min_image_version_aborts(
+ self, tmp_path, image, min_image_version
+ ):
+ data = _make_bundle("4.0.0-beta.2", min_image_version=min_image_version)
sha = hashlib.sha256(data).hexdigest()
- up = _updater_for_apply(tmp_path, data, sha, image="3.3.0-beta.1")
+ up = _updater_for_apply(tmp_path, data, sha, image=image)
res = await up.apply_update("beta")
assert res.success is False
assert "too old" in res.message.lower()
assert not (tmp_path / "u" / "current").exists()
+ # boot_overlay 不看 min_image_version,只要没有留存的 bundle 就不会应用
+ assert not (tmp_path / "u" / "bundle.zip").exists()
@pytest.mark.asyncio
async def test_second_apply_moves_current_to_backup(self, tmp_path, bundle):
@@ -672,7 +685,9 @@ class TestRollback:
class TestBootOverlay:
- def _seed(self, tmp_path, overlay_version, sign=True, stage_venv=True):
+ def _seed(
+ self, tmp_path, overlay_version, sign=True, stage_venv=True, with_sdk=True
+ ):
"""构造一个假的 /app 布局 + 已验签覆盖层。返回相关路径。
module 树与前端 dist 现在从已验签的 bundle.zip 解包,不再从 current/
@@ -683,10 +698,12 @@ class TestBootOverlay:
(app / "module" / "old.txt").write_text("image module")
(app / "dist").mkdir(parents=True)
(app / "dist" / "old.html").write_text("image dist")
+ (app / "ab_sdk").mkdir(parents=True)
+ (app / "ab_sdk" / "old.txt").write_text("image sdk")
updates = app / "config" / "updates"
updates.mkdir(parents=True)
- bundle_bytes = _make_bundle(overlay_version)
+ bundle_bytes = _make_bundle(overlay_version, with_sdk=with_sdk)
(updates / "bundle.zip").write_bytes(bundle_bytes)
sig = _sign_bytes(bundle_bytes)
if not sign:
@@ -722,9 +739,28 @@ class TestBootOverlay:
# module 树来自 zip 内 backend/src/module/__marker__.txt
assert (app / "module" / "__marker__.txt").exists()
assert not (app / "module" / "old.txt").exists() # tree replaced, not merged
+ # module 依赖同版本的 ab_sdk,两者一起替换
+ assert (app / "ab_sdk" / "__marker__.txt").exists()
+ assert not (app / "ab_sdk" / "old.txt").exists()
assert (app / "dist" / "index.html").exists()
assert (app / ".venv" / "pyvenv.cfg").read_text() == "overlay venv"
+ def test_skips_overlay_without_sdk_tree(self, tmp_path):
+ """只换 module 不换 ab_sdk 会让新 module 在旧 SDK 上 ImportError。"""
+ import boot_overlay
+
+ app, updates, ivp, lock, pubkey = self._seed(tmp_path, "3.4.0", with_sdk=False)
+ applied = boot_overlay.apply_overlay(
+ app_root=app,
+ updates_root=updates,
+ image_version_path=ivp,
+ baseline_lock=lock,
+ pubkey_path=pubkey,
+ )
+ assert applied is False
+ assert (app / "module" / "old.txt").exists()
+ assert (app / "ab_sdk" / "old.txt").exists()
+
def test_applies_overlay_when_rename_hits_exdev(self, tmp_path, monkeypatch):
"""overlayfs refuses to rename a lower-layer dir (EXDEV); the overlay
must still land via the in-place content-replace fallback (#update)."""
diff --git a/backend/src/test/test_update_v4.py b/backend/src/test/test_update_v4.py
index 845773c7..b5683bc3 100644
--- a/backend/src/test/test_update_v4.py
+++ b/backend/src/test/test_update_v4.py
@@ -91,8 +91,8 @@ def test_migrate_v3_config_invalid_field_restores_and_refuses(
migrate_v3_config(v3_config)
assert v3_config.read_text() == original
- assert v3_config.with_name("config.json.v3.bak").read_text() == original
- assert not v3_config.with_name("config.json.tmp").exists()
+ # 每次失败的启动都不留下新的备份(容器重启循环不会堆积 .v3.bak.N)
+ assert not list(v3_config.parent.glob("config.json.*"))
@pytest.mark.parametrize(
diff --git a/backend/src/test/test_version_check.py b/backend/src/test/test_version_check.py
index 11e903f1..4d592a9b 100644
--- a/backend/src/test/test_version_check.py
+++ b/backend/src/test/test_version_check.py
@@ -1,19 +1,33 @@
"""version_check:记录运行版本,并拒绝从 3.3 以前的版本直接升级。"""
import importlib
+import sqlite3
+from contextlib import closing
+from pathlib import Path
import pytest
vc = importlib.import_module("module.update.version_check")
+def _make_db(path, *tables: str) -> None:
+ with closing(sqlite3.connect(path)) as conn:
+ for table in tables:
+ conn.execute(f"CREATE TABLE {table} (id INTEGER)")
+
+
@pytest.fixture
def paths(tmp_path, monkeypatch):
version_path = tmp_path / "version.info"
legacy_path = tmp_path / "data.json"
+ db_path = tmp_path / "data.db"
+ _make_db(db_path, "bangumi", "rssitem")
monkeypatch.setattr(vc, "VERSION_PATH", version_path)
monkeypatch.setattr(vc, "LEGACY_DATA_PATH", legacy_path)
+ monkeypatch.setattr(vc, "DATABASE_PATH", db_path)
monkeypatch.setattr(vc, "VERSION", "4.0.0")
+ # 发布版本的配置文件(测试环境是 config_dev.json)
+ monkeypatch.setattr(vc, "CONFIG_PATH", Path("config/config.json"))
return version_path, legacy_path
@@ -23,6 +37,18 @@ def test_missing_version_file_records_current(paths):
assert version_path.read_text() == "4.0.0\n"
+def test_missing_version_file_with_30_database_is_refused(paths, tmp_path, monkeypatch):
+ """3.0.x 不写 version.info,数据库也没有 3.1 起才有的 rssitem 表。"""
+ version_path, _ = paths
+ db_path = tmp_path / "v30.db"
+ _make_db(db_path, "bangumi", "torrent", "user")
+ monkeypatch.setattr(vc, "DATABASE_PATH", db_path)
+ # 只有 3.1.x 会在缺少 version.info 时补跑 3.0 → 3.1 的数据迁移
+ with pytest.raises(vc.UnsupportedUpgradeError, match=r"3\.0.*3\.1\.x"):
+ vc.version_check()
+ assert not version_path.exists()
+
+
def test_upgrade_from_33_is_recorded(paths):
version_path, _ = paths
version_path.write_text("3.2.7\n3.3.6\n")
@@ -55,8 +81,10 @@ def test_downgrade_is_not_recorded(paths):
def test_upgrade_from_pre_33_is_refused(paths, previous):
version_path, _ = paths
version_path.write_text(previous + "\n")
- with pytest.raises(vc.UnsupportedUpgradeError, match=previous):
+ with pytest.raises(vc.UnsupportedUpgradeError, match=previous) as exc:
vc.version_check()
+ # 本次启动已把 config.json 改写为 4.0 格式,提示要先还原备份再回到旧版本
+ assert "config.json.v3.bak" in str(exc.value)
# 拒绝时不改写记录,用户回退到 3.3.x 后仍能正常迁移
assert version_path.read_text() == previous + "\n"
@@ -65,7 +93,7 @@ def test_legacy_data_json_is_refused(paths):
_, legacy_path = paths
legacy_path.write_text("{}")
with pytest.raises(vc.UnsupportedUpgradeError, match="2.x"):
- vc.version_check()
+ vc.refuse_legacy_data()
def test_malformed_version_file_is_rewritten(paths):
diff --git a/backend/vulture_whitelist.py b/backend/vulture_whitelist.py
index f11e0863..701f7b2d 100644
--- a/backend/vulture_whitelist.py
+++ b/backend/vulture_whitelist.py
@@ -29,11 +29,6 @@ _.can_rollback
phase
percent
candidate_ids
-credentials
-can_query
-can_rename
-can_manage
-can_rss_rules
extension_points
min_ab_version
@@ -41,12 +36,8 @@ min_ab_version
connection_record # SQLAlchemy connect 事件回调
exc_value # __aexit__
traceback # __aexit__
-user_input # LLMProviderAdapter.complete_auth 的接口参数
hash_ # for 循环解包
-# --- 框架回调 ---
-_.dispatch # Starlette BaseHTTPMiddleware
-
# --- 测试 / e2e 支撑:生产路径不调用 ---
_.check_single # e2e worker
_.set_renamed_path # aria2 测试构造状态
@@ -62,15 +53,7 @@ run_benchmark
render_text
# --- 插件运行时(module/plugin)---
-# 公开接口:宿主在 P2/P3 迁移扩展点时调用(声明扩展点、执行 transform 钩子)
-_.declare
-_.transform
-_.runner
-_.drain # 等待事件处理完,测试与关闭流程使用
-# 插件通过 ctx 访问的能力、清单与注册表的数据字段
-_.kv
-authors
-factory
+_.drain # 等待事件处理完,测试使用
# 动态创建插件包时设置的模块属性
_.__path__
_.__package__
@@ -89,4 +72,5 @@ _.template_strategy
HardlinkPlugin
MediaServerRefresh
_.on_organized # hardlink 与 media-server-refresh 共用
+_.on_linked # media-server-refresh 订阅 hardlink.linked
_.api # 插件 HTTP 路由,宿主挂载到 /api/v1/plugins//
diff --git a/docs/config/manager.md b/docs/config/manager.md
index e247ce35..f2296f8e 100644
--- a/docs/config/manager.md
+++ b/docs/config/manager.md
@@ -82,6 +82,7 @@
- 媒体库中已有同名文件、但不是本插件创建的:跳过,**绝不覆盖**,并发送「硬链接未完成」通知。
- 版本升级(新版本种子替换旧种子,文件名不变)后,本插件之前创建的链接会被原子替换为指向新文件的链接。
- 删除种子不会删除媒体库中的链接。
+- 本插件放置后被你从媒体库删除的文件不会被重新链接;补链会重建它们。
- 启用前已经下载的文件不会自动处理。启用插件后,设置页出现「硬链接补链」分区,点击 **链接已有文件** 把下载根目录下已有的正片与字幕补链到媒体库,已链接的文件跳过。该按钮调用 `POST /api/v1/plugins/hardlink/backfill`。
- `path_map` 在插件配置表单中按行编辑(**添加一行**)。在 `config/config.json` 中对应 `plugins.options.hardlink.path_map`,例如 `[{"downloader": "default", "from": "/downloads", "to": "/media/downloads"}]`。
@@ -91,7 +92,7 @@
## 媒体库刷新
-内置插件「媒体库刷新」(`media-server-refresh`)在种子整理完成后请求 Jellyfin、Emby 或 Plex 刷新媒体库。在 **设置 → 插件 → 媒体库刷新** 中选择服务器类型,填写服务器地址与 API Key(Plex 为 `X-Plex-Token`)后生效;未填写时不做任何事。收到事件后等待「延迟」秒(默认 30),期间整理完成的种子合并为一次刷新。
+内置插件「媒体库刷新」(`media-server-refresh`)在种子整理完成后请求 Jellyfin、Emby 或 Plex 刷新媒体库。在 **设置 → 插件 → 媒体库刷新** 中选择服务器类型,填写服务器地址与 API Key(Plex 为 `X-Plex-Token`)后生效;未填写时不做任何事。收到事件后等待「延迟」秒(默认 30),期间整理完成的种子合并为一次刷新;刷新请求发出后到达的事件会再排一次刷新。启用「硬链接到媒体库」时,文件放入媒体库后会再刷新一次,跨盘复制慢于延迟时新剧集也能出现。
[1]: https://www.autobangumi.org/faq/#download-path
[2]: https://www.autobangumi.org/faq/#file-renaming
diff --git a/docs/dev/plugins/builtin.md b/docs/dev/plugins/builtin.md
index 36f2bf96..c25e026a 100644
--- a/docs/dev/plugins/builtin.md
+++ b/docs/dev/plugins/builtin.md
@@ -23,7 +23,7 @@
## media-server-refresh
-订阅 `torrent.organized`。收到第一个事件后等待 `delay` 秒,期间整理完成的种子合并成一次刷新请求。
+订阅 `torrent.organized` 与 `hardlink` 发布的 `hardlink.linked`(文件新放入媒体库)。收到第一个事件后等待 `delay` 秒,期间的事件合并成一次刷新请求;刷新请求发出后到达的事件再排一次。
| 选项 | 说明 |
| --- | --- |
@@ -47,7 +47,7 @@
- 链接或副本先写到目标旁的临时文件,再原子地改名,复制中断不会留下半个文件。
- 媒体库中已有同名文件、但不是本插件创建的:跳过,不覆盖,并发一条 `hardlink.failed` 通知。
- 本插件之前为同一集创建的链接,在版本升级(新版本替换旧种子,规范文件名不变)后被原子替换为指向新文件的链接。
-- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。
+- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。插件放置后被用户删除的文件,再次收到事件时也不重建,只有补链会重建。
- **删除种子不会删除媒体库中的链接。**
- 启用前已经下载的文件不会自动处理。在插件的设置分区点「链接已有文件」,或调用 `POST /api/v1/plugins/hardlink/backfill`:遍历 `source_root` 下的 `.mp4` / `.mkv` / `.ass` / `.srt`,返回 `{"linked", "exists", "conflict", "failed"}` 计数。
diff --git a/docs/dev/plugins/events.md b/docs/dev/plugins/events.md
index 026b6780..2acb22f6 100644
--- a/docs/dev/plugins/events.md
+++ b/docs/dev/plugins/events.md
@@ -42,11 +42,11 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent`
| kind | 事件类 | 字段 | 何时发布 |
| --- | --- | --- | --- |
| `file.renamed` | `FileRenamed` | `bangumi_id`、`old_path`、`new_path`、`file_kind`、`downloader_id` | 一个文件被实际重命名(含版本替换后的改名) |
-| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` 元组)、`downloader_id` | 一个种子整理完成;重命名方式为 `none` 时同样发布,`files` 为原路径 |
+| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` 元组)、`downloader_id` | 一个种子整理完成;重命名方式为 `none` 时同样发布,`files` 为原路径。选中的重命名方式未登记(插件停用或被熔断)时不发布 |
- 路径是**下载器视角**的绝对路径,以 `/` 拼接(Windows 下载器的 `\` 也统一为 `/`)。AB 与下载器看到的目录不同(如分别运行在不同容器)时,订阅者要自己做路径映射。
- `bangumi_id` 来自种子的 `ab:` 标签,旧种子可能为 `None`。`downloader_id` 是种子所在下载器实例的 id;多个实例的路径视角可能不同。
-- `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次。订阅者必须幂等。
+- `torrent.organized` 的投递是**至少一次**:下载器中已整理的种子(包括已打「已重命名」标签的)在每次 AB 重启后会再发布一次。订阅者必须幂等。
- 这两个事件只发布到事件总线,不进入通知中心。
## 自定义事件
diff --git a/docs/dev/plugins/points/downloader.md b/docs/dev/plugins/points/downloader.md
index 800cef67..b8349ff0 100644
--- a/docs/dev/plugins/points/downloader.md
+++ b/docs/dev/plugins/points/downloader.md
@@ -13,7 +13,7 @@ class MyClient:
)
def __init__(self, conn: DownloaderConnection) -> None:
- self.conn = conn # host / username / password / ssl
+ self.conn = conn # host / username / password / ssl / instance_id
async def auth(self, retry: int = 3) -> bool: ...
async def logout(self) -> None: ...
diff --git a/docs/dev/plugins/signing.md b/docs/dev/plugins/signing.md
index 6d1ed9a5..9dc9828c 100644
--- a/docs/dev/plugins/signing.md
+++ b/docs/dev/plugins/signing.md
@@ -26,7 +26,7 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含:
"kind": "plugin",
"extension_points": ["notifier"],
"sdk": ">=0.5,<1",
- "min_ab_version": "4.0.0",
+ "min_ab_version": "4.0.0-beta.1",
"description": "…",
"asset": "ntfy-notifier-0.1.0.zip",
"sha256": "…"
@@ -49,7 +49,11 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含:
签名目录的插件只能依赖标准库和 AB 已有的包,不做 pip 安装。需要第三方纯 Python 库时放进 `vendor/`。
-在 WebUI 中,**设置 → 插件** 的「插件目录」区域可以浏览目录,并安装、更新与卸载插件。API:
+::: tip 在 WebUI 中安装
+在 设置 → 插件 的「插件目录」区点击「浏览目录」,可以安装、更新目录中的插件。经目录安装的插件卡片上有「卸载」按钮;内置、本地和 pip 插件没有。
+:::
+
+API:
| 请求 | 说明 |
| --- | --- |
@@ -71,11 +75,11 @@ AB 从 GitHub release `plugins` 下载 `catalog.json`。目录包含:
```bash
uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \
- --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0 \
+ --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \
--out release-assets dist/*.zip
```
- 脚本输出 `catalog.json`、各 zip 及其 `.sig`。
+ 脚本输出 `catalog.json`、各 zip 及其 `.sig`。`--min-ab` 不要写成 `4.0.0`:按 semver,`4.0.0-beta.N` 低于 `4.0.0`,4.0 beta 用户将无法安装。
4. 维护者把整个目录上传到 release `plugins`(覆盖旧文件)。
用户端通过 `GET /api/v1/plugins/catalog` 看到新版本。
diff --git a/docs/en/config/manager.md b/docs/en/config/manager.md
index 9427ce4a..7661bbfb 100644
--- a/docs/en/config/manager.md
+++ b/docs/en/config/manager.md
@@ -66,6 +66,7 @@ The built-in `hardlink` plugin is **disabled by default**. Enable it under **Set
- If the library already has a file with that name that the plugin did not create, the plugin skips it, **never overwrites it**, and sends a notification.
- After a revision upgrade (a new release replaces the old torrent under the same file name), the link the plugin made earlier is atomically replaced with a link to the new file.
- Deleting a torrent does not delete its links in the library.
+- If you delete a file that the plugin placed in the library, the plugin does not link it again. A backfill links it again.
- Files downloaded before you enabled the plugin are not linked automatically. When the plugin is enabled, the settings page shows a "Hardlink backfill" section. Click **Link existing files** to link the videos and subtitles already in the download root into the library. Files that are already linked are skipped. The button calls `POST /api/v1/plugins/hardlink/backfill`.
- Edit `path_map` in the plugin options form, one row per entry (**Add row**). In `config/config.json` it is `plugins.options.hardlink.path_map`, for example `[{"downloader": "default", "from": "/downloads", "to": "/media/downloads"}]`.
@@ -75,7 +76,7 @@ Hard links cannot cross file systems. In Docker, keep the download folder and th
## Media server refresh
-The built-in `media-server-refresh` plugin asks Jellyfin, Emby or Plex to refresh the library after a torrent is organized. Under **Settings → Plugins → media-server-refresh**, select the server type and enter the server URL and API key (for Plex, the `X-Plex-Token`). Without them the plugin does nothing. After an event it waits `delay` seconds (default 30) and sends one refresh for all torrents organized in that time.
+The built-in `media-server-refresh` plugin asks Jellyfin, Emby or Plex to refresh the library after a torrent is organized. Under **Settings → Plugins → media-server-refresh**, select the server type and enter the server URL and API key (for Plex, the `X-Plex-Token`). Without them the plugin does nothing. After an event it waits `delay` seconds (default 30) and sends one refresh for all torrents organized in that time. Events that arrive after the refresh request is sent cause one more refresh. When the hardlink plugin is enabled, the plugin refreshes again after the files are in the library, so a copy across disks that takes longer than the delay does not hide the new episode.
## `config.json`
diff --git a/docs/en/dev/plugins/builtin.md b/docs/en/dev/plugins/builtin.md
index dff003ab..5f722d84 100644
--- a/docs/en/dev/plugins/builtin.md
+++ b/docs/en/dev/plugins/builtin.md
@@ -23,7 +23,7 @@ Adds an include filter. The rule already has an exclude filter. Set a list of re
## media-server-refresh
-Subscribes to `torrent.organized`. After the first event, it waits `delay` seconds. The torrents that finish in this time share one refresh request.
+Subscribes to `torrent.organized` and to `hardlink.linked`, which `hardlink` publishes when it places new files in the library. After the first event, it waits `delay` seconds. The events in this time share one refresh request. An event that arrives after the request is sent causes one more refresh.
| Option | Description |
| --- | --- |
@@ -47,7 +47,7 @@ Subscribes to `torrent.organized` and links media files and subtitles into the m
- The plugin writes the link or copy to a temporary file next to the target, then renames it atomically. An interrupted copy leaves no half file.
- If the library has a file with the same name that this plugin did not create, the plugin skips it, does not overwrite it, and sends a `hardlink.failed` notification.
- A link that this plugin made for the same episode is replaced atomically after a version upgrade (a new version replaces the old torrent and the canonical name stays the same). The new link points to the new file.
-- When the plugin receives an event for a file that is already linked, it does nothing. It records the targets that it created in its own key-value store.
+- When the plugin receives an event for a file that is already linked, it does nothing. It records the targets that it created in its own key-value store. If the user deletes a file that the plugin placed, a new event does not create it again. Only a backfill creates it again.
- **Deleting a torrent does not delete the links in the library.**
- AB does not process files that were downloaded before you enabled the plugin. Click "Link existing files" in the settings section of the plugin, or call `POST /api/v1/plugins/hardlink/backfill`. It walks `.mp4` / `.mkv` / `.ass` / `.srt` files under `source_root` and returns the counts `{"linked", "exists", "conflict", "failed"}`.
diff --git a/docs/en/dev/plugins/events.md b/docs/en/dev/plugins/events.md
index 8a585938..e871e1dc 100644
--- a/docs/en/dev/plugins/events.md
+++ b/docs/en/dev/plugins/events.md
@@ -42,11 +42,11 @@ The `kind` of a system event keeps the 3.x value (the notification center stores
| kind | Event class | Fields | When AB publishes it |
| --- | --- | --- | --- |
| `file.renamed` | `FileRenamed` | `bangumi_id`, `old_path`, `new_path`, `file_kind`, `downloader_id` | A file is renamed (including a rename after a version replacement) |
-| `torrent.organized` | `TorrentOrganized` | `torrent_hash`, `bangumi_id`, `files` (a tuple of `OrganizedFile(path, kind)`), `downloader_id` | A torrent is organized. AB also publishes it when the rename strategy is `none`; `files` then holds the original paths |
+| `torrent.organized` | `TorrentOrganized` | `torrent_hash`, `bangumi_id`, `files` (a tuple of `OrganizedFile(path, kind)`), `downloader_id` | A torrent is organized. AB also publishes it when the rename strategy is `none`; `files` then holds the original paths. AB does not publish it while the selected rename strategy is not registered (its plugin is disabled or tripped) |
- Paths are absolute paths **as the downloader sees them**, joined with `/` (a `\` from a Windows downloader is also changed to `/`). If AB and the downloader see different directories (for example, they run in different containers), the subscriber must map the paths.
- `bangumi_id` comes from the `ab:` tag of the torrent. An old torrent can have `None`. `downloader_id` is the id of the downloader instance that holds the torrent. Different instances can have different path views.
-- `torrent.organized` has **at-least-once** delivery. AB publishes it again after each restart for torrents without the "renamed" tag (for example, when the rename strategy is `none`). Subscribers must be idempotent.
+- `torrent.organized` has **at-least-once** delivery. AB publishes it again after each restart for the organized torrents in the downloader, also for torrents with the "renamed" tag. Subscribers must be idempotent.
- These two events go to the event bus only. They do not go to the notification center.
## Custom events
diff --git a/docs/en/dev/plugins/points/downloader.md b/docs/en/dev/plugins/points/downloader.md
index 65b7273c..3b8c9872 100644
--- a/docs/en/dev/plugins/points/downloader.md
+++ b/docs/en/dev/plugins/points/downloader.md
@@ -13,7 +13,7 @@ class MyClient:
)
def __init__(self, conn: DownloaderConnection) -> None:
- self.conn = conn # host / username / password / ssl
+ self.conn = conn # host / username / password / ssl / instance_id
async def auth(self, retry: int = 3) -> bool: ...
async def logout(self) -> None: ...
diff --git a/docs/en/dev/plugins/signing.md b/docs/en/dev/plugins/signing.md
index bbac7816..53c81db8 100644
--- a/docs/en/dev/plugins/signing.md
+++ b/docs/en/dev/plugins/signing.md
@@ -26,7 +26,7 @@ AB downloads `catalog.json` from the GitHub release `plugins`. The catalog conta
"kind": "plugin",
"extension_points": ["notifier"],
"sdk": ">=0.5,<1",
- "min_ab_version": "4.0.0",
+ "min_ab_version": "4.0.0-beta.1",
"description": "…",
"asset": "ntfy-notifier-0.1.0.zip",
"sha256": "…"
@@ -49,7 +49,11 @@ After these checks, AB unpacks the plugin to `config/plugins///`, w
A catalog plugin can depend only on the standard library and on packages that AB already has. AB does not run pip. Put third-party pure-Python libraries in `vendor/`.
-In the WebUI, the "Plugin catalog" section of **Settings → Plugins** lets you browse the catalog and install, update and remove plugins. API:
+::: tip Install from the WebUI
+In Settings → Plugins, go to the "Plugin catalog" area and select "Browse catalog". You can then install and update catalog plugins. A plugin installed from the catalog has an "Uninstall" button on its card. Built-in, local and pip plugins do not have one.
+:::
+
+API:
| Request | Description |
| --- | --- |
@@ -71,11 +75,11 @@ Only a maintainer who has the signing private key can publish the catalog. The s
```bash
uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \
- --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0 \
+ --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \
--out release-assets dist/*.zip
```
- The script writes `catalog.json`, the zips and their `.sig` files.
+ The script writes `catalog.json`, the zips and their `.sig` files. Do not set `--min-ab` to `4.0.0`: in semver, `4.0.0-beta.N` is lower than `4.0.0`, so 4.0 beta users cannot install the plugin.
4. The maintainer uploads the whole directory to the release `plugins` (and overwrites the old files).
A user sees the new version through `GET /api/v1/plugins/catalog`.
diff --git a/docs/ja/config/manager.md b/docs/ja/config/manager.md
index a7973b20..e85deb69 100644
--- a/docs/ja/config/manager.md
+++ b/docs/ja/config/manager.md
@@ -64,6 +64,7 @@
- プラグインが作成していない同名ファイルがライブラリにある場合はスキップし、**上書きしません**。通知が送られます。
- リビジョン更新(新しいリリースが同じファイル名で古い Torrent を置き換える)の後、プラグインが以前作成したリンクは新しいファイルへのリンクにアトミックに置き換えられます。
- Torrent を削除しても、ライブラリのリンクは削除されません。
+- プラグインが配置した後にライブラリから削除したファイルは、再びリンクされません。バックフィルを実行すると再作成されます。
- 有効化する前にダウンロード済みのファイルは自動ではリンクされません。プラグインを有効にすると設定画面に「Hardlink backfill」セクションが表示されます。**Link existing files** をクリックすると、ダウンロードルートにある本編と字幕をライブラリにリンクします。リンク済みのファイルはスキップされます。このボタンは `POST /api/v1/plugins/hardlink/backfill` を呼び出します。
- `path_map` はプラグインの設定フォームで 1 行ずつ編集できます(**Add row**)。`config/config.json` では `plugins.options.hardlink.path_map` です(例:`[{"downloader": "default", "from": "/downloads", "to": "/media/downloads"}]`)。
@@ -73,7 +74,7 @@
## メディアサーバーの更新
-内蔵プラグイン `media-server-refresh` は、Torrent の整理後に Jellyfin・Emby・Plex へライブラリの更新を要求します。**設定 → プラグイン → media-server-refresh** でサーバー種別を選び、サーバー URL と API Key(Plex は `X-Plex-Token`)を入力すると有効になります。未入力の間は何もしません。イベントを受け取ると `delay` 秒(既定 30)待ち、その間に整理された Torrent をまとめて 1 回だけ更新します。
+内蔵プラグイン `media-server-refresh` は、Torrent の整理後に Jellyfin・Emby・Plex へライブラリの更新を要求します。**設定 → プラグイン → media-server-refresh** でサーバー種別を選び、サーバー URL と API Key(Plex は `X-Plex-Token`)を入力すると有効になります。未入力の間は何もしません。イベントを受け取ると `delay` 秒(既定 30)待ち、その間に整理された Torrent をまとめて 1 回だけ更新します。更新リクエストの送信後に届いたイベントは、もう 1 回の更新になります。ハードリンクプラグインが有効な場合は、ファイルがライブラリに入った後にもう一度更新します。ディスクをまたぐコピーが遅延より長くかかっても、新しいエピソードが表示されます。
## `config.json`
diff --git a/docs/ja/dev/plugins/builtin.md b/docs/ja/dev/plugins/builtin.md
index d8342134..d9ac46c6 100644
--- a/docs/ja/dev/plugins/builtin.md
+++ b/docs/ja/dev/plugins/builtin.md
@@ -23,7 +23,7 @@
## media-server-refresh
-`torrent.organized` を購読します。最初のイベントを受け取ってから `delay` 秒待ち、その間に整理が完了したトレントを 1 回の更新リクエストにまとめます。
+`torrent.organized` と、`hardlink` が新しいファイルをライブラリに置いたときに発行する `hardlink.linked` を購読します。最初のイベントを受け取ってから `delay` 秒待ち、その間のイベントを 1 回の更新リクエストにまとめます。リクエスト送信後に届いたイベントは、もう 1 回の更新になります。
| オプション | 説明 |
| --- | --- |
@@ -47,7 +47,7 @@
- リンクまたはコピーは、まず保存先の隣の一時ファイルに作り、その後アトミックにリネームします。コピーが中断しても、中途半端なファイルは残りません。
- ライブラリに同名のファイルがあり、このプラグインが作ったものでない場合は、スキップして上書きせず、`hardlink.failed` 通知を送ります。
- このプラグインが同じ話のために作ったリンクは、バージョンアップ後(新バージョンが古いトレントを置換し、正規のファイル名が同じ場合)にアトミックに置き換えられ、新しいファイルを指します。
-- 既にリンク済みのファイルのイベントを受け取っても、何もしません。作成したパスはプラグイン自身のキーバリューストアに記録されます。
+- 既にリンク済みのファイルのイベントを受け取っても、何もしません。作成したパスはプラグイン自身のキーバリューストアに記録されます。プラグインが配置した後にユーザーが削除したファイルは、イベントを再び受け取っても再作成されません。バックフィルだけが再作成します。
- **トレントを削除しても、ライブラリのリンクは削除されません。**
- プラグインを有効にする前にダウンロードしたファイルは自動では処理されません。プラグインの設定セクションで「既存ファイルをリンク」をクリックするか、`POST /api/v1/plugins/hardlink/backfill` を呼びます。`source_root` 以下の `.mp4` / `.mkv` / `.ass` / `.srt` を走査し、`{"linked", "exists", "conflict", "failed"}` の件数を返します。
diff --git a/docs/ja/dev/plugins/events.md b/docs/ja/dev/plugins/events.md
index 0f5082db..a17275ef 100644
--- a/docs/ja/dev/plugins/events.md
+++ b/docs/ja/dev/plugins/events.md
@@ -42,11 +42,11 @@ AB が通知センターに送るイベントは、すべて `ab_sdk.events.Syst
| kind | イベントクラス | フィールド | 発行のタイミング |
| --- | --- | --- | --- |
| `file.renamed` | `FileRenamed` | `bangumi_id`、`old_path`、`new_path`、`file_kind`、`downloader_id` | ファイルが実際にリネームされたとき(バージョン置換後のリネームを含む) |
-| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` のタプル)、`downloader_id` | トレントの整理が完了したとき。リネーム方式が `none` でも発行され、`files` は元のパスです |
+| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` のタプル)、`downloader_id` | トレントの整理が完了したとき。リネーム方式が `none` でも発行され、`files` は元のパスです。選択したリネーム方式が未登録(プラグインが無効、または遮断済み)の間は発行されません |
- パスは**ダウンローダーから見た**絶対パスで、`/` で連結されます(Windows ダウンローダーの `\` も `/` に統一されます)。AB とダウンローダーで見えるディレクトリが異なる場合(別々のコンテナで動作している場合など)は、購読者側でパスを変換する必要があります。
- `bangumi_id` はトレントの `ab:` タグに由来します。古いトレントでは `None` の場合があります。`downloader_id` はトレントがあるダウンローダーインスタンスの id です。インスタンスごとにパスの見え方が異なることがあります。
-- `torrent.organized` の配信は**少なくとも 1 回**(at-least-once)です。「リネーム済み」タグのないトレント(リネーム方式が `none` の場合など)は、AB を再起動するたびにもう一度発行されます。購読者は冪等にしてください。
+- `torrent.organized` の配信は**少なくとも 1 回**(at-least-once)です。ダウンローダーにある整理済みのトレントは、「リネーム済み」タグがあっても、AB を再起動するたびにもう一度発行されます。購読者は冪等にしてください。
- この 2 つのイベントはイベントバスにだけ発行されます。通知センターには入りません。
## 独自イベント
diff --git a/docs/ja/dev/plugins/points/downloader.md b/docs/ja/dev/plugins/points/downloader.md
index 700a305c..4b342dda 100644
--- a/docs/ja/dev/plugins/points/downloader.md
+++ b/docs/ja/dev/plugins/points/downloader.md
@@ -13,7 +13,7 @@ class MyClient:
)
def __init__(self, conn: DownloaderConnection) -> None:
- self.conn = conn # host / username / password / ssl
+ self.conn = conn # host / username / password / ssl / instance_id
async def auth(self, retry: int = 3) -> bool: ...
async def logout(self) -> None: ...
diff --git a/docs/ja/dev/plugins/signing.md b/docs/ja/dev/plugins/signing.md
index 10b1b604..481759e2 100644
--- a/docs/ja/dev/plugins/signing.md
+++ b/docs/ja/dev/plugins/signing.md
@@ -26,7 +26,7 @@ AB は GitHub release `plugins` から `catalog.json` をダウンロードし
"kind": "plugin",
"extension_points": ["notifier"],
"sdk": ">=0.5,<1",
- "min_ab_version": "4.0.0",
+ "min_ab_version": "4.0.0-beta.1",
"description": "…",
"asset": "ntfy-notifier-0.1.0.zip",
"sha256": "…"
@@ -49,7 +49,11 @@ AB は GitHub release `plugins` から `catalog.json` をダウンロードし
カタログのプラグインが依存できるのは、標準ライブラリと AB に既にあるパッケージだけです。AB は pip を実行しません。サードパーティの純 Python ライブラリは `vendor/` に入れてください。
-WebUI では、**設定 → プラグイン** の「Plugin catalog」(插件目录)セクションでカタログを閲覧し、プラグインをインストール・更新・削除できます。API:
+::: tip WebUI からインストールする
+設定 → プラグインの「Plugin catalog」(プラグインカタログ)で「Browse catalog」を選ぶと、カタログのプラグインをインストール・更新できます。カタログからインストールしたプラグインのカードには「Uninstall」ボタンがあります。組み込み、ローカル、pip のプラグインにはありません。
+:::
+
+API:
| リクエスト | 説明 |
| --- | --- |
@@ -71,11 +75,11 @@ WebUI では、**設定 → プラグイン** の「Plugin catalog」(插件
```bash
uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \
- --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0 \
+ --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0-beta.1 \
--out release-assets dist/*.zip
```
- スクリプトは `catalog.json`、各 zip、およびその `.sig` を出力します。
+ スクリプトは `catalog.json`、各 zip、およびその `.sig` を出力します。`--min-ab` に `4.0.0` を指定しないでください。semver では `4.0.0-beta.N` は `4.0.0` より低いため、4.0 beta のユーザーがインストールできなくなります。
4. メンテナーがディレクトリ全体を release `plugins` にアップロードします(古いファイルは上書きします)。
ユーザーは `GET /api/v1/plugins/catalog` で新しいバージョンを確認できます。
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 6065b478..8641997c 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
@@ -59,7 +59,7 @@
- **模板失效的后果**:已保存的模板若不再通过校验(手工改配置、Jinja2 升级),整个 `rename` 插件加载失败,`pn` / `advance` 随之消失,所有种子按 `none` 处理。插件因连续 5 次异常或非法返回值被熔断时同样如此。
- **`media_files` 与 `conflict_policy`** 只解析固定的 core id `default`(`ab_sdk.rename.CORE_ID`)。插件可以登记实现,但选择要等 P2.5 的 `slots`。`conflict_policy` 的接口改为 `decide(ConflictRequest) -> ConflictDecision`:`ConflictRequest` 带上宿主读取的设置值 `configured`(`bangumi_manage.revision_conflict_policy`,测试 patch 的是 `module.manager.renamer.settings`,所以设置仍在 `renamer.py` 中读取)和宿主计算的 `strict_upgrade`。宿主仍只在「唯一占用者、双方都是单文件种子、双方身份完整」时执行替换。
- **事件字段**:`FileRenamed` 的类别字段叫 `file_kind`,因为 `kind` 是 `Event` 的类变量;`OrganizedFile` 保留 `kind`。路径为下载器视角、以 `/` 拼接的绝对路径;`downloader_id` 在 P2.5 之前固定为 `"default"`。
-- **`TorrentOrganized` 的投递是至少一次**:进程内按 hash 记忆已发布的最终文件集合,重启后、或文件集合变化时会再次发布。重命名方式为 `none` 的种子同样发布。订阅者必须幂等。
+- **`TorrentOrganized` 的投递是至少一次**:进程内按 hash 记忆已发布的最终文件集合,重启后、或文件集合变化时会再次发布。已打 `ab:renamed` 标签的种子不再整理,但本进程未发布过时按当前文件名补发一次(`Renamer._republish_organized`),否则订阅者处理中被取消(重启、插件重载)的事件不会再投递;没有事件总线时不查询文件。重命名方式为 `none` 的种子同样发布;选中的策略未登记而按 `none` 处理时不发布,因为原名不是最终文件名,策略恢复后改名再发布会让硬链接在媒体库留下两份。订阅者必须幂等。
- **WebUI**:重命名方式下拉框经 `usePluginProviders` 合并插件提供的 id(如 `template`),`rename_method` 类型放宽为字符串。`template` 在 设置 → 番剧管理设置 → 重命名方式 中选择,模板在 设置 → 插件 → 重命名 中填写。
- `ab_sdk` 新增 `ab_sdk.rename`(契约与冻结快照 `RenameInput`、`Revision`、`RevisionTask`、`ConflictRequest`,以及 `pad()`),`SDK_VERSION` 升到 `0.4.0`。
@@ -76,7 +76,7 @@
- `path_map` 是对象列表,P2 的 JSON Schema 表单不支持对象数组(显示为「不支持的字段」),P4 期间只能在 `config.json` 中填写。表单支持对象数组留到 P6 / P7。
- 所有放置(新建与版本升级替换)都先写到同目录的临时文件 `.<文件名>.<随机串>.ab-hardlink`,再 `os.replace` 到目标;失败时删除临时文件。复制中断不会在目标留下半个文件,否则重试时它会被当成「不是本插件创建的」冲突。临时文件名每次不同,因为补链与订阅可能同时处理同一个目标。
- `hardlink.failed` 的通知正文只能显示种子 hash,因为 `TorrentOrganized` 不带种子名;补充种子名留到 P2.5 / P6。
-- 「由插件创建」记录在插件 KV 中:键 `link:<目标路径>`,值为 `[源文件身份, 目标身份]`,身份为 `lstat` 的 `[st_dev, st_ino, st_size, st_mtime_ns]`。加入大小与修改时间,是因为旧文件删除后 inode 可能被复用。目标是源的硬链接、软链接或大小与修改时间相同的副本,或两个身份都与记录一致时,视为已完成并补写记录:放置后、写记录前协程被取消或进程退出,留下的文件不会被当成冲突。版本升级只替换目标身份与记录一致的文件,用户在原位置放的文件不会被覆盖。
+- 「由插件创建」记录在插件 KV 中:键 `link:<目标路径>`,值为 `[源文件身份, 目标身份]`,身份为 `lstat` 的 `[st_dev, st_ino, st_size, st_mtime_ns]`。加入大小与修改时间,是因为旧文件删除后 inode 可能被复用。目标是源的硬链接、软链接或大小与修改时间相同的副本,或两个身份都与记录一致时,视为已完成并补写记录:放置后、写记录前协程被取消或进程退出,留下的文件不会被当成冲突。版本升级只替换目标身份与记录一致的文件,用户在原位置放的文件不会被覆盖。目标不存在、而记录中的源身份与当前源相同时,表示插件放置后被用户删除:重复投递的事件不重建,只有补链(`restore=True`)重建。
- 目标被占用、跨文件系统按 `skip` 跳过、源不在 `source_root` 下等问题,每个种子合并成一条 `hardlink.failed` 通知,`dedup_key` 为种子 hash。由于至少一次投递,长期存在的冲突在每次重启后会更新同一条通知。
- 补链 `backfill()` 以插件路由 `POST /api/v1/plugins/hardlink/backfill` 提供,只在用户调用时运行,返回 `linked` / `exists` / `conflict` / `failed` 计数,不发通知。它遍历本地 `source_root`,不经过 `path_map`。插件不能 import `module.*`,所以按固定扩展名(`.mp4` / `.mkv` / `.ass` / `.srt`,与 `media_files` 的 core 实现相同)挑选文件。
- **补链按钮推迟到 P6**:设置页的按钮依赖 P6 的 `settings.section` 挂载点,P4 只提供 REST 路由。
@@ -91,7 +91,7 @@
- P4 有两份独立实现。另一个会话的版本(提交 a0706513、12e7e2f5,基于旧 P5 0b6de315)曾推到 `refactor/4.0-p4-organize`,现保留为 `refactor/4.0-p4-organize-cloud`(head 544628f6)。本分支保留本节上文的设计,因为它符合已确认的决策:`pn` / `advance` / `template` 都在内置插件 `rename` 中,坏模板跳过文件并通知、不退回 `pn`,另有 `hardlink`。另一份的 `rename-template` 插件、`BangumiLink`、`ab_sdk.organize` 与由 `PluginManager` 设置进程级总线的做法没有移植。
- 从另一份移植并按本分支改写的内容:
- - **内置插件 `media-server-refresh`**:订阅 `torrent.organized`(另一份订阅 `file.renamed`;前者在重命名方式为 `none` 时也会发布),等待 `delay` 秒,把期间的事件合并成一次 Jellyfin / Emby / Plex 刷新请求。未填写地址或 API Key 时不做任何事,所以与另一份相同,默认启用。由于至少一次投递,已配置时每次重启最多多出一次合并后的刷新。
+ - **内置插件 `media-server-refresh`**:订阅 `torrent.organized`(另一份订阅 `file.renamed`;前者在重命名方式为 `none` 时也会发布),等待 `delay` 秒,把期间的事件合并成一次 Jellyfin / Emby / Plex 刷新请求。未填写地址或 API Key 时不做任何事,所以与另一份相同,默认启用。由于至少一次投递,已配置时每次重启最多多出一次合并后的刷新。它同时订阅 `hardlink` 在新放入文件后发布的 `hardlink.linked`(普通 `Event`,不是通知):两者各自订阅同一个事件,跨盘复制慢于 `delay` 时刷新会早于文件到位,所以文件放好后再刷新一次;刷新请求发出后到达的事件再排一次刷新,不再被忽略。
- **死代码**:删除 `Renamer.rename_file` / `_rename_media_file`、`_lookup_offsets` / `_normalize_path`、`BangumiDatabase.match_by_save_path`、`TorrentDatabase.search_by_qb_hash`、`RenameOperationDatabase.release_replacement_lease`。删除前确认它们在本分支(含 `revision_saga.py`)没有生产调用方。原测试改为经 `rename()` 与 `_batch_lookup_offsets` 驱动。`season_offset` 从 `gen_path` 起整条重命名链路移除,`_batch_lookup_offsets` 的结果从 `(集数偏移, 季度偏移, 类型)` 改为 `(集数偏移, 类型)`:季度偏移已体现在 Season 文件夹,文件名从未使用它。
- **插件配置 422**:`field_validator` 抛出的 `ValueError` 会留在 `ValidationError.errors()` 的 `ctx` 中,无法 JSON 序列化,保存配置返回 500(`hardlink` 的路径校验、`rename` 的模板校验都会触发)。现在以 `include_context=False` 返回 422。
- **用户文档**:`docs/{,en/,ja/}config/manager.md` 增加 `template`、`hardlink`(含 `path_map` 与 Docker 下硬链接不能跨文件系统的说明)与 `media-server-refresh` 三节,按本分支的设计重写;`CHANGELOG.md` 增加 P4 条目。
@@ -109,7 +109,7 @@
slots 解析:
-- `rename_strategy` 读 `slots.rename_strategy`,未登记时仍记录一次日志并按 `none` 处理。`media_files` 读 `slots.media_files`,未登记时退回 `default`。`conflict_policy` 读 `slots.conflict_policy`,未登记时退回 `hold`,不会误删旧版本。后两者不记日志,因为每个文件都会解析一次。
+- `rename_strategy` 读 `slots.rename_strategy`,未登记时仍记录一次日志并按 `none` 处理。`media_files` 读 `slots.media_files`,未登记时退回 `default`。`conflict_policy` 读 `slots.conflict_policy`,未登记时退回 `hold`,不会误删旧版本。后两者不记日志,因为每个文件都会解析一次。插件实现与重命名策略一样经 `plugin_host.call_sync`(runner 的熔断器)调用:抛出异常或返回值无效(`conflict_policy` 不是 `ConflictDecision`,`media_files` 不是三种类别之一)时计入熔断,本次按 `hold` / `default` 处理,不中断这一轮重命名。
- **冲突策略改为两个 Provider**:第 9 节把 `revision_conflict_policy` 迁到 `slots.conflict_policy`,slot 的值就是 Provider id。因此宿主以 `core` 登记 `hold` 与 `replace` 两个 `CoreConflictPolicy`,不再登记 `default`;`ConflictRequest` 删除 P4 加入的 `configured` 字段。`ab_sdk` 升至 0.5.0。`GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选。
迁移器(第 9 节,`module/update/v4.py`):
@@ -148,7 +148,7 @@ slots 解析:
- **保存目录随实例重新生成**:`save_path_for(data, root)`(`downloader/path.py`)在已存的 `save_path` 位于该实例下载目录之下时沿用它,否则按该实例的下载目录重新生成(比较用 `PureWindowsPath`,`\` 与 `/` 都认)。`add_torrent` 每次投递都经过它并写回规则,所以默认实例切换、规则的实例被删除、订阅指向别的实例时,新种子都进目标实例的目录。代价:单实例用户修改下载目录后,存量规则的新种子也进新目录(3.3 沿用旧目录)。只在「属于另一个已配置实例」时重算做不到「实例被删除」的情况,所以按「不在本实例目录之下」判断。
- **按实例匹配与移动**:`TorrentManager` 在每个实例上按两个目录匹配种子:已存的 `save_path`(与之前相同,单实例改过下载目录时仍能找到旧种子),以及 `save_path_for(规则, 该实例目录)`。更新规则且实例不变时,对种子行记录的每个实例(加上规则当前的实例)分别计算新目录并移动。qB RSS 规则只在有匹配种子的实例与规则自己的实例上改写,因为 `rss/setRule` 在别的 qB 上会新建一条启用的自动下载规则。这样「规则未指定实例、种子经订阅进了另一实例」时也会移动。规则自身的实例仍不带订阅后备,因为种子行已记录实际位置。
-- **删除逐实例隔离**:一个实例不可用或删除失败时,其它实例照常删除,最后返回 500 并列出失败的实例。删除规则时种子行已先删除,不可用实例上的种子之后没有记录(见第二部分结尾的推迟项)。
+- **删除逐实例隔离**:一个实例不可用或删除失败时,其它实例照常删除,最后返回 500 并列出失败的实例。删除规则(删除文件)时先删各实例上的种子,全部成功才删种子行与番剧;有实例失败时两者都保留并返回 500,实例恢复后可重试(已删净的实例匹配不到种子,直接跳过)。种子行经外键引用番剧,所以不能只保留失败实例的种子行。不删文件时不连接下载器,与之前相同。
- **整季补全与自动打标跳过不可用实例**:`eps_complete` 跳过该规则(不标记 `eps_collect`,下一轮重试),其它规则照常补全并保存;`POST /downloader/torrents/tag/auto` 改用 `DownloaderPool`,不可用实例跳过。
- **版本替换事务的过滤只在多实例时生效**:只有一个下载器实例时 `list_active_replacements(None)` 返回全部事务,与 3.3 相同;否则改了主机地址(键里含主机哈希)后进行中的事务永远不会恢复。多实例下改主机地址仍有这个问题(见推迟项)。
- **迁移备份不覆盖已有备份**:`.v3.bak` 已存在时依次取 `.v3.bak.1`、`.v3.bak.2` …,失败恢复与日志都用本次写的备份。降级回 3.3 再升级时,第一份备份里的真实下载器凭据因此保留。
@@ -156,10 +156,9 @@ slots 解析:
未实施(推迟):
- 第 10 节验收中的 qb + aria2 并存 **Docker e2e** 未加。以进程内测试替代:两个 mock 实例并列(按规则 / 订阅投递、在另一实例重命名、一个实例不可用、规则换实例后删除),以及 qB 与 aria2 实例各自得到对应后端与不同的 `downloader_type`。Docker 版需要在 `e2e/compose/downloader.yml` 加入固定 digest 的 aria2 镜像。
-- aria2 的 gid ↔ 番剧映射表(`database/aria2.py`)不区分实例;两个 aria2 实例的 gid 相同的概率很低,未处理。
+- **aria2 的 gid ↔ 番剧映射按实例区分**(v27)。`aria2_gid` 主键改为 `(downloader_id, gid)`,SQLite 不能改主键,所以重建表,存量行归属实例 `default`。`Aria2GidDatabase` 构造时绑定实例 id,所有读写(包括按 `dedup_key` 判重)都限定在该实例内,`Database.aria2(downloader_id)` 取得它。实例 id 经 `DownloaderConnection.instance_id`(新增,默认 `"default"`,位置参数构造不受影响)传给下载器工厂;`ab_sdk` 仍为 0.5.0。
- `GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选;插件下载器的 options schema 与按 schema 掩码(见上文)。
- 多实例时修改某个实例的主机地址,该实例上进行中的版本替换事务找不到(`_downloader_type` 键含主机哈希)。可选做法:键改为 `:` 并兼容旧键查询,或按「键不属于任何已配置实例」把孤儿事务交给同类型的唯一实例。
-- 删除规则(删除文件)时某个实例不可用:该实例上的种子不删,种子行却已删除,之后无法重试。可选做法:保留失败实例的种子行,或先删种子再删行(与「仅删数据库不依赖下载器」冲突)。
### 实施中的调整(P6 第一部分:后端与 `@autobangumi/plugin-ui` 包)
@@ -249,7 +248,7 @@ bark / wecom 旧字段别名:
- `docs/dev/plugins.md` 保留为总览与导航(侧边栏链接不变),旧版 529 行的内容拆为 `plugins/` 下的页面:核心概念、配置表单、事件、前端挂载点、命令行、签名与分发、内置插件、示例,以及 `points/` 下每个扩展点一页(16 页:`mcp_tool` 与 `mcp_resource` 合一页,其余一点一页)。每种语言 25 个文件,侧边栏由 `docs/.vitepress/config.ts` 的一张页面表生成。`vitepress build` 通过(含死链检查)。
- **旧文档里与现状不符的地方一并改正**:下载器不再是 `downloader.type` 而是 `plugins.instances[].provider`;`hardlink` 的 `path_map` 已有对象数组表单;补链按钮已在 P6 提供;`file.renamed` / `torrent.organized` 加上 `downloader_id`。
-- 签名与分发页如实写明:WebUI 的设置 → 插件页**还没有**目录浏览与安装按钮(只有 API),以及上架流程只有持有签名私钥的维护者能执行。「提 issue 申请上架」是文档里写的临时约定,没有对应的自动化。
+- 签名与分发页如实写明:WebUI 的设置 → 插件页**还没有**目录浏览与安装按钮(只有 API;4.0 收尾时已补上界面,文档同步改写),以及上架流程只有持有签名私钥的维护者能执行。「提 issue 申请上架」是文档里写的临时约定,没有对应的自动化。
插件作者 skill(`skills/autobangumi-plugin/`):`SKILL.md`(触发描述、流程、扩展点速查表、易错规则)加 `references/` 三份(扩展点细节、测试 / CLI / 清单、前端)。内容面向模型,英文,与文档同源但不逐字复制。
@@ -265,14 +264,34 @@ bark / wecom 旧字段别名:
- **原生扩展扫描与文件监听跳过工具链目录**:`native_files()` 原先遍历整个插件目录,`uv run pytest` 在插件目录里建出的 `.venv`(含 `pydantic_core` 的 `.so`)会让 `validate` / `pack` / `dev` 和宿主加载器都报「含原生扩展」,`dev_mode` 的指纹轮询也每秒遍历 `.venv`。`ab_sdk.manifest.TOOLING_DIRS`(`__pycache__`、`.git`、`.venv`、`dist`、`node_modules`、各类缓存)现在由原生扩展扫描、`pack` 与文件指纹共用;`pack` 仍额外排除 `tests`。
- **v3 迁移不再把 `null` 当作旧字段**:`Settings.save()` 总是把 Bark 的 `token`、WeCom 的 `chat_id` 以 `null` 写回,`old in provider` 因此每次启动都成立,每次启动都新增 `config.json.v3.bak.N` 并改写配置。现在只在旧字段有值时迁移。
- **通用插件与 LLM 插件共用 `config/plugins//` 的两处隔离**:LLM 注册表扫描跳过版本目录里是 `plugin.toml` 的目录(原先每次列举都对它打 `Skipping broken plugin` 警告);通用安装器卸载时要求 `installed.json` 指向的版本目录含 `plugin.toml`,不能再通过 `DELETE /plugins/{id}` 删掉 LLM 插件(其凭据清理在 LLM 安装器里,不会被跳过)。
-- **未修:在线更新 bundle 不含 `ab_sdk`**。`boot_overlay.py` 是镜像自带的稳定脚本,只把 bundle 里的 `backend/src/module` 换进 `/app/module`;只在 `build.yml` 里多拷一份 `ab_sdk` 不会被旧镜像应用,要让 bundle 能更新 SDK 必须改覆盖层的应用方式。选项见 PR 说明,留作后续决定。
+- **在线更新 bundle 带上 `ab_sdk`(PR 说明中的选项 2)**。`build.yml` 把 `backend/src/ab_sdk` 打进 bundle,`min_image_version` 设为 `4.0.0-beta.1`;`boot_overlay.py` 要求已验签 bundle 同时有 `module` 与 `ab_sdk` 两棵树,先换 `/app/ab_sdk` 再换 `/app/module`,缺一棵即不应用。已发布的 3.3 镜像在 beta 通道会选中最新预发布,但 3.3 的更新器在应用时检查 `min_image_version`(与 4.0 的代码相同,已有测试覆盖),拒绝后不留存 bundle,3.3 的 `boot_overlay` 因此不会应用它。两次替换不是一个事务:`ab_sdk` 换完而 `module` 失败时留下新 SDK 与旧 module。
+
+插件目录界面(4.0 收尾):
+
+- 设置 → 插件卡片底部新增「插件目录」区:点「浏览目录」才请求 `GET /plugins/catalog`(要访问 GitHub,不在打开设置页时请求),请求带 `silent`,失败时在区内显示后端给出的原因并可刷新重试,不弹全局提示。每个条目按本机已装版本显示「安装」「更新」或「已安装」;安装与更新走同一接口,成功后用返回的插件列表刷新卡片,并刷新 Provider 下拉与前端挂载点(与启停相同)。
+- 已安装插件的卡片显示「已签名」或「未签名」标记。「卸载」按钮只出现在来源为 `catalog` 的插件上,并经危险确认框。这与后端的卸载前提是同一条件:加载器只把 `installed.json` 指向含 `plugin.toml` 版本目录的插件判为 `catalog`,卸载接口也只接受这种目录。LLM 提供商插件不在 `GET /plugins` 中,仍在 LLM 设置里安装和卸载。目录插件视为已签名,与 `allow_unsigned` 无关,界面上的说明文字写明了这一点。
+- 修复:前端类型的 `PluginInfo.source` 缺少 `catalog`,两种语言都缺少 `source_catalog`,目录安装的插件在卡片上显示为未翻译的 key。
+- 版本比较只看「已装版本是否等于目录版本」,不等即显示「更新」,目录版本更低时也是如此。不在界面上预先检查 `min_ab_version`,由安装接口的 400 返回原因。
未做(第一部分已列出,本部分也没有做):
-- **插件管理页**:设置 → 插件页没有目录浏览、安装 / 卸载按钮,也没有 `dev_mode` 提示;`GET /plugins` 仍不返回 `dev_mode`。任务清单不含它,推迟到发布阶段前补。
+- **插件管理页**(目录部分已补,见下一节):仍没有 `dev_mode` 提示,`GET /plugins` 仍不返回 `dev_mode`。
- **模板仓库**(含前端模板的独立 GitHub 仓库):前端模板已在 `webui/packages/plugin-ui/template/`,`ab-plugin new` 覆盖后端骨架,独立仓库没有创建。
- **脚手架生成的 `pyproject.toml` 依赖 `autobangumi-sdk`** 但没有 uv 源:独立作者要等轮子上了 release 才能 `uv run pytest`。文档的上手步骤用 `uv tool install` 本地轮子文件绕过。
+### 实施中的调整(final)
+
+4.0.0-beta.1 之前的收尾阶段(分支 `refactor/4.0-final`,叠在 P7 上)。各条细节已写在上面对应阶段的条目里,这里只列总表:
+
+- **P7 遗留**:在线更新 bundle 带上 `ab_sdk`,`min_image_version` 为 `4.0.0-beta.1`;删除规则时下载器不可用则保留规则与种子行,返回 500 等待重试;aria2 gid 映射按实例区分(数据库迁移 v27,主键改为 `(downloader_id, gid)`,`DownloaderConnection` 新增 `instance_id`);设置 → 插件新增签名目录的浏览、安装、更新与卸载。
+- **插件运行时与安装器**:两个安装器共用 id 校验与卸载归属检查(LLM 卸载不再能删通用插件目录,未安装时返回 400);清单解码失败与 pip 入口导入失败按加载失败报告;`setup()` 失败或超时会调用 `teardown`,注册阶段失败不调用;订阅者成功会清零熔断计数;停用失败插件后状态显示 `disabled`;过期的熔断不再停用重载后的新实例;`build_plugin_catalog.py` 默认 `min_ab_version` 为 `4.0.0-beta.1`。
+- **升级与配置**:设置向导把 Bark / WeCom 凭据写入 `device_key` / `webhook_url`;`data.json` 检查移到首次启动之前,缺少 `version.info` 时按数据库是否有 `rssitem` 表识别 3.0 数据并要求先升级到 3.1.x;拒绝提示写明先还原 `config.json.v3.bak`;配置迁移失败时把备份移回原文件,重启循环不再堆积 `.v3.bak.N`。
+- **多下载器**:更新规则先在所有实例上匹配种子,任一实例不可用则不做任何修改;WebUI 种子批量操作按实例汇总结果、选择键为 `下载器 id:hash`,下载器页按任一已配置实例判断是否为空。
+- **整理流水线**:`conflict_policy` 与 `media_files` 插件经熔断器调用,失败时退回宿主实现;重命名策略未登记而按 `none` 处理时不发布 `torrent.organized`;带 `ab:renamed` 的种子在每个进程内补发一次 `torrent.organized`(重启后硬链接会补链一次);硬链接不再重建用户删除的文件(`backfill` 仍会恢复);新增内部事件 `hardlink.linked`,媒体库刷新在硬链接放好文件后再刷新一次,刷新请求进行中到达的事件会再触发一次刷新。
+- **秘密字段**:标记写在 `anyOf` / `$ref` 外层或容器(`list` / `dict` / 嵌套模型)上时也掩码;`/config/get` 把插件通知渠道的未声明字段一律视为秘密并按身份还原。已知限制:同一插件类型、只在这些字段上不同的两行渠道身份相同,增删行时返回 400 要求重新输入。
+- **WebUI 插件卡片**:保存后刷新 `allow_unsigned`;只重建服务端配置变化的插件草稿;整数枚举下拉保留数值类型。
+- **清理**(净 −24 行):删除 vulture 白名单中不再需要的 13 项;`ab-plugin` 复用 `ab_sdk.manifest.ID_RE`;删除前端 `OpenAIModel` / `OpenAIType`;改正描述旧行为的注释与文档字符串。
+
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
@@ -824,7 +843,7 @@ organize: downloader.completed → media_files.classify → file_parser
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 已完成:`renamer.py` 拆出 `revision_saga.py`;`rename_strategy` / `media_files` / `conflict_policy` 扩展点与 `file.renamed` / `torrent.organized` 事件;内置插件 `rename`(pn / advance / template,pn / advance / none 输出与 3.3 一致)、`hardlink`(默认停用)与 `media-server-refresh`。`file_parser`、`RenameStrategyContract`、补链设置按钮(P6)推迟,调整见第 0 节 |
| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 |
| **P6 前端插件** | Web Component 挂载点、`AbHost` 桥接、错误边界、`/plugins//web` 静态资源、`@autobangumi/plugin-ui` 包 | 已完成:五个挂载点、`AbHost`、错误边界与 CSP;示例插件「手动选种」(`examples/plugins/manual-pick`)以详情页标签形式可用,内置 `hardlink` 的补链按钮走 `settings.section`;未启用插件的配置表单、对象数组表单、SSE `bus` 帧。调整见第 0 节 |
-| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架。已完成:`autobangumi-sdk` 轮子、`ab-plugin` CLI(new / validate / pack / dev)、`dev_mode` 文件监听、四个契约套件、签名目录来源(`plugins` tag、`catalog` 加载来源、安装 API、发布脚本)、bark / wecom 旧字段迁移;6 个示例插件(CI 逐个运行)、中 / 英 / 日文档(总览加 24 页)、插件作者 skill、release 附带 SDK 轮子与 skill。**管理页的目录浏览 / 安装按钮与独立模板仓库未做**,留到 P8 前补,调整见第 0 节 |
+| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架。已完成:`autobangumi-sdk` 轮子、`ab-plugin` CLI(new / validate / pack / dev)、`dev_mode` 文件监听、四个契约套件、签名目录来源(`plugins` tag、`catalog` 加载来源、安装 API、发布脚本)、bark / wecom 旧字段迁移;6 个示例插件(CI 逐个运行)、中 / 英 / 日文档(总览加 24 页)、插件作者 skill、release 附带 SDK 轮子与 skill。管理页的目录浏览 / 安装 / 更新 / 卸载已在 4.0 收尾时补上;**独立模板仓库与 `dev_mode` 提示未做**,调整见第 0 节 |
| **P8 发布** | beta 测试、性能对比(RSS 刷新耗时、内存)、升级指南、`docs/changelog/4.0.md` | `4.0.0-beta.1` → `4.0.0` |
阶段依赖:P0 → P1 → P2 → (P2.5 ∥ P3 ∥ P4) → P5 → (P6 ∥ P7) → P8。P2.5、P3、P4 可并行,P6 依赖 P5 的 `api_router` 与事件总线。
diff --git a/examples/plugins/manual-pick/README.md b/examples/plugins/manual-pick/README.md
index f3e0cf2c..aecfdaa8 100644
--- a/examples/plugins/manual-pick/README.md
+++ b/examples/plugins/manual-pick/README.md
@@ -1,6 +1,6 @@
# 手动选种(示例插件)
-P6 的验收演示:番剧详情页多一个「手动选种」标签,列出规则的种子,点「选用」记录选择。
+前端挂载点的演示:番剧详情页多一个「手动选种」标签,列出规则的种子,点「选用」记录选择。
用到的能力:
diff --git a/scripts/build_plugin_catalog.py b/scripts/build_plugin_catalog.py
index 57329fb9..97d1bdad 100644
--- a/scripts/build_plugin_catalog.py
+++ b/scripts/build_plugin_catalog.py
@@ -3,7 +3,7 @@
用法(需要更新签名私钥,见 CLAUDE.md 的发布说明)::
uv run --no-project --with cryptography python scripts/build_plugin_catalog.py \
- --key ~/.autobangumi/update-signing-key.pem --min-ab 4.0.0 \
+ --key ~/.autobangumi/update-signing-key.pem \
--out release-assets dist/*.zip
输入是 ``ab-plugin pack`` 打出的 zip。输出目录里有 ``catalog.json``、每个 zip 以及
@@ -24,6 +24,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import load_pem_private_key
CATALOG_SCHEMA = 2
+# semver 中 4.0.0-beta.N < 4.0.0,写 4.0.0 会把全部 4.0 beta 宿主挡在外面
+DEFAULT_MIN_AB = "4.0.0-beta.1"
def _entry(archive: Path, min_ab: str) -> dict:
@@ -48,7 +50,9 @@ def _sign(key: Ed25519PrivateKey, path: Path) -> None:
Path(str(path) + ".sig").write_text(base64.b64encode(signature).decode() + "\n")
-def build(archives: list[Path], out: Path, key_path: Path, min_ab: str) -> Path:
+def build(
+ archives: list[Path], out: Path, key_path: Path, min_ab: str = DEFAULT_MIN_AB
+) -> Path:
key = load_pem_private_key(key_path.read_bytes(), password=None)
if not isinstance(key, Ed25519PrivateKey):
raise SystemExit("签名密钥必须是 ed25519")
@@ -72,6 +76,6 @@ if __name__ == "__main__":
parser.add_argument("archives", nargs="+", type=Path)
parser.add_argument("--key", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path)
- parser.add_argument("--min-ab", default="4.0.0")
+ parser.add_argument("--min-ab", default=DEFAULT_MIN_AB)
args = parser.parse_args()
print(build(args.archives, args.out, args.key, args.min_ab))
diff --git a/skills/autobangumi-plugin/references/extension-points.md b/skills/autobangumi-plugin/references/extension-points.md
index a618f42f..a0ec93a7 100644
--- a/skills/autobangumi-plugin/references/extension-points.md
+++ b/skills/autobangumi-plugin/references/extension-points.md
@@ -8,7 +8,7 @@ All imports come from `ab_sdk`. Constants live in `ab_sdk.points`.
```python
from ab_sdk.downloader import AddResult, DownloaderCapabilities, DownloaderConnection
```
-- Factory is called with `DownloaderConnection(host, username, password, ssl)`; return a client object. Provider `id` is the instance type the user picks (`plugins.instances[].provider`).
+- Factory is called with `DownloaderConnection(host, username, password, ssl, instance_id)`; return a client object. `instance_id` is the user-assigned instance id; key any local state by it, since two instances of the same type can coexist. Provider `id` is the instance type the user picks (`plugins.instances[].provider`).
- Minimum (`CoreDownloaderClient`): class attribute `capabilities = DownloaderCapabilities(can_query, can_rename, can_manage, can_rss_rules)`, `async auth(retry=3) -> bool`, `async logout()`, `async add_torrents(torrent_urls, torrent_files, save_path, category, tags=None) -> AddResult` (`ADDED` / `DUPLICATE` / `FAILED`).
- A declared capability requires its methods: `can_query` -> `torrents_info`, `torrent_exists`, `torrents_files`; `can_rename` -> `torrents_rename_file` (returns `RenameResult`); `can_manage` -> delete/pause/resume/move/category/tag methods. Undeclared operations are skipped and logged.
- Test: subclass `DownloaderContract`; set `behavioral = True` only when `create()` returns a client with a working backend or stub.
diff --git a/webui/src/api/__tests__/plugins.test.ts b/webui/src/api/__tests__/plugins.test.ts
index 6f3bce95..d31d6836 100644
--- a/webui/src/api/__tests__/plugins.test.ts
+++ b/webui/src/api/__tests__/plugins.test.ts
@@ -50,3 +50,31 @@ describe('Plugins API contract (path + HTTP method)', () => {
expect(axios.get).toHaveBeenCalledWith('api/v1/plugins/providers');
});
});
+
+describe('Plugins API contract (signed catalog)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should GET api/v1/plugins/catalog silently when browsing', async () => {
+ (axios.get as any).mockResolvedValue({ data: [] });
+ expect(await apiPlugins.catalog()).toEqual([]);
+ expect(axios.get).toHaveBeenCalledWith('api/v1/plugins/catalog', {
+ silent: true,
+ });
+ });
+
+ it('should POST api/v1/plugins/:id/install with an encoded id', async () => {
+ (axios.post as any).mockResolvedValue({ data: overview });
+ expect(await apiPlugins.install('my plugin')).toEqual(overview);
+ expect(axios.post).toHaveBeenCalledWith(
+ 'api/v1/plugins/my%20plugin/install'
+ );
+ });
+
+ it('should DELETE api/v1/plugins/:id with an encoded id', async () => {
+ (axios.delete as any).mockResolvedValue({ data: overview });
+ expect(await apiPlugins.uninstall('my plugin')).toEqual(overview);
+ expect(axios.delete).toHaveBeenCalledWith('api/v1/plugins/my%20plugin');
+ });
+});
diff --git a/webui/src/api/plugins.ts b/webui/src/api/plugins.ts
index 7dd1d476..a4a80606 100644
--- a/webui/src/api/plugins.ts
+++ b/webui/src/api/plugins.ts
@@ -1,5 +1,5 @@
import type { PluginUiSlot } from '@autobangumi/plugin-ui';
-import type { PluginProviders, PluginsOverview } from '#/plugins';
+import type { CatalogEntry, PluginProviders, PluginsOverview } from '#/plugins';
export const apiPlugins = {
/** 已发现的插件、状态、配置 schema 与(掩码后的)当前配置 */
@@ -28,6 +28,30 @@ export const apiPlugins = {
return data;
},
+ /** 签名目录;目录不可达(502)由调用方在页面内提示,不弹全局 toast */
+ async catalog() {
+ const { data } = await axios.get('api/v1/plugins/catalog', {
+ silent: true,
+ });
+ return data;
+ },
+
+ /** 从签名目录安装或升级插件,后端安装后即启用 */
+ async install(id: string) {
+ const { data } = await axios.post(
+ `api/v1/plugins/${encodeURIComponent(id)}/install`
+ );
+ return data;
+ },
+
+ /** 卸载经签名目录安装的插件(后端拒绝内置、本地、pip 与 LLM 插件) */
+ async uninstall(id: string) {
+ const { data } = await axios.delete(
+ `api/v1/plugins/${encodeURIComponent(id)}`
+ );
+ return data;
+ },
+
/** 已启用插件声明的前端挂载点 */
async ui() {
const { data } = await axios.get('api/v1/plugins/ui');
diff --git a/webui/src/components/setting/__tests__/config-plugins.test.ts b/webui/src/components/setting/__tests__/config-plugins.test.ts
new file mode 100644
index 00000000..05463624
--- /dev/null
+++ b/webui/src/components/setting/__tests__/config-plugins.test.ts
@@ -0,0 +1,268 @@
+import { flushPromises, mount } from '@vue/test-utils';
+import ConfigPlugins from '../config-plugins.vue';
+import type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins';
+import en from '@/i18n/en.json';
+import zhCN from '@/i18n/zh-CN.json';
+import { apiPlugins } from '@/api/plugins';
+
+vi.mock('@/api/plugins', () => ({
+ apiPlugins: {
+ list: vi.fn(),
+ update: vi.fn(),
+ updateSettings: vi.fn(),
+ catalog: vi.fn(),
+ install: vi.fn(),
+ uninstall: vi.fn(),
+ },
+}));
+
+const confirmMock = vi.fn();
+vi.mock('@/hooks/useConfirm', () => ({
+ useConfirm: () => ({ confirm: confirmMock }),
+}));
+vi.mock('@/hooks/useMyI18n', () => ({
+ useMyI18n: () => ({ t: (key: string) => key }),
+}));
+vi.mock('@/hooks/useMessage', () => ({
+ useMessage: () => ({ success: vi.fn(), error: vi.fn() }),
+}));
+vi.mock('@/hooks/usePluginProviders', () => ({
+ refreshPluginProviders: vi.fn(),
+}));
+vi.mock('@/hooks/usePluginUi', () => ({ refreshPluginUi: vi.fn() }));
+const refreshGroupMock = vi.fn();
+vi.mock('@/store/config', () => ({
+ useConfigStore: () => ({ refreshGroup: refreshGroupMock }),
+}));
+
+const api = vi.mocked(apiPlugins);
+
+function plugin(overrides: Partial = {}): PluginInfo {
+ return {
+ id: 'ntfy',
+ name: 'ntfy',
+ version: '0.1.0',
+ source: 'catalog',
+ signed: true,
+ state: 'active',
+ enabled: true,
+ description: '',
+ permissions: [],
+ error: null,
+ config_schema: null,
+ options: {},
+ ...overrides,
+ };
+}
+
+function overview(plugins: PluginInfo[]): PluginsOverview {
+ return { allow_unsigned: false, plugins };
+}
+
+function entry(overrides: Partial = {}): CatalogEntry {
+ return {
+ id: 'ntfy',
+ name: 'ntfy',
+ version: '0.2.0',
+ kind: 'plugin',
+ extension_points: ['notifier'],
+ description: '',
+ min_ab_version: '4.0.0',
+ installed_version: null,
+ ...overrides,
+ };
+}
+
+async function mountPage(plugins: PluginInfo[]) {
+ api.list.mockResolvedValue(overview(plugins));
+ const wrapper = mount(ConfigPlugins, {
+ global: {
+ stubs: {
+ 'ab-fold-panel': { template: '' },
+ AbSwitch: true,
+ },
+ },
+ });
+ await flushPromises();
+ return wrapper;
+}
+
+function button(wrapper: Awaited>, key: string) {
+ return wrapper.findAll('button').filter((b) => b.text() === key);
+}
+
+const sources: PluginInfo['source'][] = ['builtin', 'catalog', 'local', 'pip'];
+
+describe('config-plugins', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each(sources)(
+ 'should have a source label in both locales when source is %s',
+ (source) => {
+ const key = `source_${source}`;
+ expect(en.config.plugins_set).toHaveProperty(key);
+ expect(zhCN.config.plugins_set).toHaveProperty(key);
+ }
+ );
+
+ it.each(sources)(
+ 'should offer uninstall only for catalog plugins when source is %s',
+ async (source) => {
+ const wrapper = await mountPage([plugin({ source })]);
+ expect(button(wrapper, 'config.plugins_set.uninstall')).toHaveLength(
+ source === 'catalog' ? 1 : 0
+ );
+ }
+ );
+
+ it.each([true, false])(
+ 'should uninstall only when the confirm resolves %s',
+ async (confirmed) => {
+ confirmMock.mockResolvedValue(confirmed);
+ api.uninstall.mockResolvedValue(overview([]));
+ const wrapper = await mountPage([plugin()]);
+
+ await button(wrapper, 'config.plugins_set.uninstall')[0].trigger('click');
+ await flushPromises();
+
+ expect(confirmMock).toHaveBeenCalledWith(
+ expect.objectContaining({ danger: true })
+ );
+ expect(api.uninstall).toHaveBeenCalledTimes(confirmed ? 1 : 0);
+ expect(wrapper.text().includes('config.plugins_set.empty')).toBe(
+ confirmed
+ );
+ }
+ );
+
+ it('should load the catalog only when the user browses it', async () => {
+ api.catalog.mockResolvedValue([]);
+ const wrapper = await mountPage([]);
+ expect(api.catalog).not.toHaveBeenCalled();
+
+ await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
+ 'click'
+ );
+ await flushPromises();
+
+ expect(api.catalog).toHaveBeenCalledTimes(1);
+ expect(wrapper.text()).toContain('config.plugins_set.catalog_empty');
+ });
+
+ it('should show the install action for each catalog state', async () => {
+ api.catalog.mockResolvedValue([
+ entry({ id: 'fresh' }),
+ entry({ id: 'stale', installed_version: '0.1.0' }),
+ entry({ id: 'current', installed_version: '0.2.0' }),
+ ]);
+ const wrapper = await mountPage([]);
+ await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
+ 'click'
+ );
+ await flushPromises();
+
+ const rows = wrapper.findAll('.catalog__entry');
+ expect(rows.map((r) => r.find('button').exists())).toEqual([
+ true,
+ true,
+ false,
+ ]);
+ expect(rows[0].find('button').text()).toBe('config.plugins_set.install');
+ expect(rows[1].find('button').text()).toBe('config.plugins_set.update');
+ expect(rows[2].text()).toContain('config.plugins_set.installed');
+ });
+
+ it('should install, apply the returned overview and mark the entry installed', async () => {
+ api.catalog.mockResolvedValue([entry()]);
+ api.install.mockResolvedValue(
+ overview([plugin({ name: 'ntfy-installed', version: '0.2.0' })])
+ );
+ const wrapper = await mountPage([]);
+ await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
+ 'click'
+ );
+ await flushPromises();
+
+ await button(wrapper, 'config.plugins_set.install')[0].trigger('click');
+ await flushPromises();
+
+ expect(api.install).toHaveBeenCalledWith('ntfy');
+ expect(wrapper.text()).toContain('ntfy-installed');
+ expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(0);
+ expect(wrapper.find('.catalog__entry').text()).toContain(
+ 'config.plugins_set.installed'
+ );
+ });
+
+ it('should keep the entry installable when install fails', async () => {
+ api.catalog.mockResolvedValue([entry()]);
+ api.install.mockRejectedValue(new Error('signature invalid'));
+ const wrapper = await mountPage([]);
+ await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
+ 'click'
+ );
+ await flushPromises();
+
+ await button(wrapper, 'config.plugins_set.install')[0].trigger('click');
+ await flushPromises();
+
+ expect(button(wrapper, 'config.plugins_set.install')).toHaveLength(1);
+ });
+
+ it('should show the backend detail inline when the catalog is unavailable', async () => {
+ api.catalog.mockRejectedValue({
+ response: { data: { detail: 'catalog signature verification failed' } },
+ });
+ const wrapper = await mountPage([]);
+ await button(wrapper, 'config.plugins_set.catalog_browse')[0].trigger(
+ 'click'
+ );
+ await flushPromises();
+
+ const alert = wrapper.find('[role="alert"]');
+ expect(alert.text()).toContain('config.plugins_set.catalog_failed');
+ expect(alert.text()).toContain('catalog signature verification failed');
+ });
+
+ it('should refresh allow_unsigned in the config store when the card toggles it', async () => {
+ api.updateSettings.mockResolvedValue({ allow_unsigned: true, plugins: [] });
+ const wrapper = await mountPage([]);
+
+ wrapper
+ .findAllComponents({ name: 'AbSwitch' })[0]
+ .vm.$emit('update:model-value', true);
+ await flushPromises();
+
+ expect(api.updateSettings).toHaveBeenCalledWith(true);
+ expect(refreshGroupMock).toHaveBeenCalledWith(
+ 'plugins',
+ expect.arrayContaining(['allow_unsigned'])
+ );
+ });
+
+ it('should keep unsaved edits of other plugins when one plugin is toggled', async () => {
+ const schema = { properties: { url: { type: 'string' as const } } };
+ const a = plugin({
+ id: 'a',
+ config_schema: schema,
+ options: { url: 'old' },
+ });
+ const b = plugin({ id: 'b', config_schema: schema, enabled: false });
+ api.update.mockResolvedValue(overview([a, { ...b, enabled: true }]));
+ const wrapper = await mountPage([a, b]);
+
+ await wrapper.find('input').setValue('typed');
+ // 第 0 个开关是 allow_unsigned,之后依次是插件 a、b
+ wrapper
+ .findAllComponents({ name: 'AbSwitch' })[2]
+ .vm.$emit('update:model-value', true);
+ await flushPromises();
+
+ expect(api.update).toHaveBeenCalledWith('b', { enabled: true });
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe(
+ 'typed'
+ );
+ });
+});
diff --git a/webui/src/components/setting/__tests__/plugin-schema-form.test.ts b/webui/src/components/setting/__tests__/plugin-schema-form.test.ts
new file mode 100644
index 00000000..11768783
--- /dev/null
+++ b/webui/src/components/setting/__tests__/plugin-schema-form.test.ts
@@ -0,0 +1,25 @@
+import { mount } from '@vue/test-utils';
+import { NSelect } from 'naive-ui';
+import PluginSchemaForm from '../plugin-schema-form.vue';
+import { schemaFields } from '@/utils/plugin-schema';
+
+describe('plugin-schema-form', () => {
+ it('should keep the option type when an integer enum is selected', async () => {
+ const fields = schemaFields({
+ properties: { level: { type: 'integer', enum: [1, 2, 3], default: 1 } },
+ });
+ const model: Record = { level: 1 };
+ const wrapper = mount(PluginSchemaForm, {
+ props: { fields, modelValue: model },
+ });
+
+ const select = wrapper.findComponent(NSelect);
+ expect(select.props('value')).toBe(1);
+ expect(
+ (select.props('options') as { value: unknown }[]).map((o) => o.value)
+ ).toEqual([1, 2, 3]);
+
+ select.vm.$emit('update:value', 2);
+ expect(model.level).toBe(2);
+ });
+});
diff --git a/webui/src/components/setting/config-plugins.vue b/webui/src/components/setting/config-plugins.vue
index 02269688..c7420687 100644
--- a/webui/src/components/setting/config-plugins.vue
+++ b/webui/src/components/setting/config-plugins.vue
@@ -2,11 +2,13 @@
import AbAlert from '../basic/ab-alert.vue';
import AbButton from '../basic/ab-button.vue';
import AbField from '../basic/ab-field.vue';
+import AbSkeleton from '../basic/ab-skeleton.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 type { CatalogEntry, PluginInfo, PluginsOverview } from '#/plugins';
import { apiPlugins } from '@/api/plugins';
+import { useConfirm } from '@/hooks/useConfirm';
import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
@@ -15,24 +17,37 @@ import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
// 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。
const { t } = useMyI18n();
const message = useMessage();
+const { confirm } = useConfirm();
const { refreshGroup } = useConfigStore();
const overview = ref(null);
const drafts = ref>>({});
const busy = ref(null);
+// 签名目录要访问 GitHub,用户点「浏览目录」时才拉取;
+// catalogError 为 '' 表示失败但后端没给出原因
+const catalog = ref(null);
+const catalogLoading = ref(false);
+const catalogError = ref(null);
+
const stateType = {
active: 'success',
disabled: 'neutral',
error: 'danger',
} as const;
+// 只重建服务端配置或 schema 有变化的插件草稿,其它卡片里未保存的编辑保留
function apply(data: PluginsOverview) {
+ const saved = (p?: PluginInfo) =>
+ p && JSON.stringify([p.options, p.config_schema]);
+ const prev = new Map(overview.value?.plugins.map((p) => [p.id, p]));
overview.value = data;
drafts.value = Object.fromEntries(
data.plugins.map((p) => [
p.id,
- fillSchemaDefaults(schemaFields(p.config_schema), p.options),
+ drafts.value[p.id] && saved(prev.get(p.id)) === saved(p)
+ ? drafts.value[p.id]
+ : fillSchemaDefaults(schemaFields(p.config_schema), p.options),
])
);
}
@@ -45,19 +60,24 @@ async function load() {
}
}
-async function run(key: string, action: () => Promise) {
+async function run(
+ key: string,
+ action: () => Promise,
+ failKey = 'save_failed'
+) {
busy.value = key;
try {
apply(await action());
// 只刷新插件卡片保存的字段,保留未保存的下载器实例与 slots 修改
- await refreshGroup('plugins', ['enabled', 'options']);
+ await refreshGroup('plugins', ['allow_unsigned', 'enabled', 'options']);
// 插件启停会增减下载器/通知渠道候选,同步刷新下拉框
await refreshPluginProviders();
// 启停也会增减插件的前端挂载点
await refreshPluginUi();
return true;
} catch {
- message.error(t('config.plugins_set.save_failed'));
+ // 后端的具体原因(如验签失败)由 axios 拦截器另行提示
+ message.error(t(`config.plugins_set.${failKey}`));
return false;
} finally {
busy.value = null;
@@ -79,6 +99,55 @@ async function saveOptions(plugin: PluginInfo) {
if (ok) message.success(t('config.plugins_set.save_success'));
}
+async function loadCatalog() {
+ catalogLoading.value = true;
+ catalogError.value = null;
+ try {
+ catalog.value = await apiPlugins.catalog();
+ } catch (e) {
+ const detail = (e as { response?: { data?: { detail?: unknown } } })
+ .response?.data?.detail;
+ catalogError.value = typeof detail === 'string' ? detail : '';
+ } finally {
+ catalogLoading.value = false;
+ }
+}
+
+async function install(entry: CatalogEntry) {
+ const ok = await run(
+ `install:${entry.id}`,
+ () => apiPlugins.install(entry.id),
+ 'install_failed'
+ );
+ if (!ok) return;
+ entry.installed_version = entry.version;
+ message.success(t('config.plugins_set.install_success'));
+}
+
+// 只有签名目录安装的插件(source 为 catalog)显示卸载按钮:后端卸载的前提
+// (installed.json 指向含 plugin.toml 的版本目录)与加载器判定 catalog 来源
+// 是同一条件。LLM 提供商插件不在此列表中,仍在 LLM 设置里管理。
+async function uninstall(plugin: PluginInfo) {
+ const confirmed = await confirm({
+ title: t('config.plugins_set.uninstall_confirm_title', {
+ name: plugin.name,
+ }),
+ body: t('config.plugins_set.uninstall_confirm_body'),
+ confirmText: t('config.plugins_set.uninstall'),
+ danger: true,
+ });
+ if (!confirmed) return;
+ const ok = await run(
+ `uninstall:${plugin.id}`,
+ () => apiPlugins.uninstall(plugin.id),
+ 'uninstall_failed'
+ );
+ if (!ok) return;
+ const entry = catalog.value?.find((e) => e.id === plugin.id);
+ if (entry) entry.installed_version = null;
+ message.success(t('config.plugins_set.uninstall_success'));
+}
+
onMounted(load);
@@ -126,7 +195,10 @@ onMounted(load);
{{ $t(`config.plugins_set.state_${plugin.state}`) }}
{{ $t(`config.plugins_set.source_${plugin.source}`) }}
-
+
+ {{ $t('config.plugins_set.signed') }}
+
+
{{ $t('config.plugins_set.unsigned') }}
@@ -172,6 +244,95 @@ onMounted(load);
{{ $t('config.plugins_set.options_unavailable') }}
+
+
+ {{ $t('config.plugins_set.uninstall') }}
+
+
+
+
+
+
+
+ {{ catalogError || $t('config.plugins_set.catalog_failed_hint') }}
+
+
+
+ {{ $t('config.plugins_set.catalog_empty') }}
+
+
+
+ -
+
+ {{ entry.name || entry.id }}
+ {{ entry.id }} · v{{ entry.version }}
+
+ {{ entry.description }}
+
+
+ {{
+ $t('config.plugins_set.extension_points', {
+ list: entry.extension_points.join(', '),
+ })
+ }}
+
+
+
+ {{ $t('config.plugins_set.installed') }}
+
+
+
+ {{
+ $t('config.plugins_set.installed_version', {
+ version: entry.installed_version,
+ })
+ }}
+
+
+ {{
+ entry.installed_version
+ ? $t('config.plugins_set.update')
+ : $t('config.plugins_set.install')
+ }}
+
+
+
+
@@ -228,4 +389,48 @@ onMounted(load);
.plugin__save {
margin-top: 12px;
}
+
+.plugin__uninstall {
+ align-self: flex-start;
+}
+
+.plugin__meta {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.catalog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--color-border);
+}
+
+.catalog__list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.catalog__entry {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ background: var(--color-surface-2);
+}
+
+.catalog__action {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/webui/src/components/setting/plugin-schema-form.vue b/webui/src/components/setting/plugin-schema-form.vue
index a21d9484..a16a340e 100644
--- a/webui/src/components/setting/plugin-schema-form.vue
+++ b/webui/src/components/setting/plugin-schema-form.vue
@@ -56,7 +56,9 @@ function setNumber(field: SchemaField, value: string | number) {
diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json
index e2a3b000..121f41bd 100644
--- a/webui/src/i18n/en.json
+++ b/webui/src/i18n/en.json
@@ -178,7 +178,7 @@
},
"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.",
+ "empty": "No plugins found. Install one from the plugin catalog below. Or 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",
@@ -200,7 +200,28 @@
"save_success": "Plugin options saved and applied",
"save_failed": "Failed to save plugin options",
"load_failed": "Failed to load plugins",
- "plugin_provided": "From plugin"
+ "plugin_provided": "From plugin",
+ "source_catalog": "Catalog",
+ "signed": "Signed",
+ "uninstall": "Uninstall",
+ "uninstall_confirm_title": "Uninstall {name}?",
+ "uninstall_confirm_body": "The plugin files are deleted. Its options stay in config.json and apply again if you reinstall it.",
+ "uninstall_success": "Plugin uninstalled",
+ "uninstall_failed": "Failed to uninstall the plugin",
+ "catalog_title": "Plugin catalog",
+ "catalog_hint": "AutoBangumi signs the catalog and every plugin package, and checks the signature and sha256 before install. Catalog plugins count as signed and do not need \"Allow unsigned plugins\".",
+ "catalog_browse": "Browse catalog",
+ "catalog_refresh": "Refresh",
+ "catalog_failed": "Catalog unavailable.",
+ "catalog_failed_hint": "Check the network, then select Refresh.",
+ "catalog_empty": "The catalog has no plugins yet.",
+ "extension_points": "Extension points: {list}",
+ "install": "Install",
+ "update": "Update",
+ "installed": "Installed",
+ "installed_version": "Installed v{version}",
+ "install_success": "Plugin installed and enabled",
+ "install_failed": "Failed to install the plugin"
}
},
"downloader": {
diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json
index c723d1a0..a497069d 100644
--- a/webui/src/i18n/zh-CN.json
+++ b/webui/src/i18n/zh-CN.json
@@ -178,7 +178,7 @@
},
"plugins_set": {
"title": "插件",
- "empty": "未发现插件。把插件目录放进 config/plugins/local/(目录名与插件 id 相同)或用 pip 安装后重启,即可在此出现。",
+ "empty": "未发现插件。从下方的插件目录安装;或把插件目录放进 config/plugins/local/(目录名与插件 id 相同)、用 pip 安装,重启后即可在此出现。",
"allow_unsigned": "允许未签名插件",
"allow_unsigned_hint": "本地目录与 pip 安装的插件未经签名,开启后才会加载。插件与 AutoBangumi 同进程运行,拥有完整权限,只启用你信任的插件。",
"enabled": "启用",
@@ -200,7 +200,28 @@
"save_success": "插件配置已保存并应用",
"save_failed": "保存插件配置失败",
"load_failed": "加载插件列表失败",
- "plugin_provided": "插件提供"
+ "plugin_provided": "插件提供",
+ "source_catalog": "签名目录",
+ "signed": "已签名",
+ "uninstall": "卸载",
+ "uninstall_confirm_title": "卸载 {name}?",
+ "uninstall_confirm_body": "插件文件会被删除。它的配置保留在 config.json 中,重新安装后继续生效。",
+ "uninstall_success": "插件已卸载",
+ "uninstall_failed": "卸载插件失败",
+ "catalog_title": "插件目录",
+ "catalog_hint": "目录与插件包由 AutoBangumi 签名,安装前校验签名与 sha256。从目录安装的插件视为已签名,不需要开启“允许未签名插件”。",
+ "catalog_browse": "浏览目录",
+ "catalog_refresh": "刷新",
+ "catalog_failed": "目录不可用。",
+ "catalog_failed_hint": "检查网络后点击“刷新”重试。",
+ "catalog_empty": "目录中暂时没有插件。",
+ "extension_points": "扩展点:{list}",
+ "install": "安装",
+ "update": "更新",
+ "installed": "已安装",
+ "installed_version": "已安装 v{version}",
+ "install_success": "插件已安装并启用",
+ "install_failed": "安装插件失败"
}
},
"downloader": {
diff --git a/webui/src/pages/index/downloader.vue b/webui/src/pages/index/downloader.vue
index 99a1e4e6..ba685967 100644
--- a/webui/src/pages/index/downloader.vue
+++ b/webui/src/pages/index/downloader.vue
@@ -4,6 +4,7 @@ import AbProgress from '@/components/basic/ab-progress.vue';
import { useConfirm } from '@/hooks/useConfirm';
import type { QbTorrentInfo, TorrentGroup } from '#/downloader';
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
+import { hasConfiguredDownloader, torrentKey } from '@/store/downloader';
definePage({
name: 'Downloader',
@@ -12,13 +13,13 @@ definePage({
const { t } = useMyI18n();
const { config } = storeToRefs(useConfigStore());
const { getConfig } = useConfigStore();
-const { groups, selectedHashes, loading } = storeToRefs(useDownloaderStore());
+const { groups, selectedKeys, loading } = storeToRefs(useDownloaderStore());
const {
getAll,
pauseSelected,
resumeSelected,
deleteSelected,
- toggleHash,
+ toggleKey,
toggleGroup,
clearSelection,
} = useDownloaderStore();
@@ -34,11 +35,9 @@ async function onDeleteSelected() {
if (ok) deleteSelected(false);
}
-const isNull = computed(() => {
- const { slots, instances } = config.value.plugins;
- const downloader = instances.find((i) => i.id === slots.downloader);
- return !downloader?.options.host;
-});
+const isNull = computed(
+ () => !hasConfiguredDownloader(config.value.plugins.instances)
+);
const { connected: sseConnected } = useEventStream();
@@ -120,7 +119,9 @@ function stateType(state: string): string {
}
function isGroupAllSelected(group: TorrentGroup): boolean {
- return group.torrents.every((t) => selectedHashes.value.includes(t.hash));
+ return group.torrents.every((t) =>
+ selectedKeys.value.includes(torrentKey(t))
+ );
}
// 多个下载器实例时标出每个种子所在的实例
@@ -216,22 +217,18 @@ const tableColumnsValue = computed>(() => [
},
]);
-function tableRowKey(row: QbTorrentInfo) {
- return row.hash;
-}
-
function onCheckedChange(group: TorrentGroup, keys: string[]) {
- const groupHashes = group.torrents.map((t) => t.hash);
- const otherSelected = selectedHashes.value.filter(
- (h) => !groupHashes.includes(h)
+ const groupKeys = group.torrents.map(torrentKey);
+ const otherSelected = selectedKeys.value.filter(
+ (k) => !groupKeys.includes(k)
);
- selectedHashes.value = [...otherSelected, ...keys];
+ selectedKeys.value = [...otherSelected, ...keys];
}
function groupCheckedKeys(group: TorrentGroup): string[] {
return group.torrents
- .filter((t) => selectedHashes.value.includes(t.hash))
- .map((t) => t.hash);
+ .map(torrentKey)
+ .filter((k) => selectedKeys.value.includes(k));
}
@@ -307,7 +304,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
-
+
- {{ selectedHashes.length }} {{ $t('downloader.selected') }}
+ {{ selectedKeys.length }} {{ $t('downloader.selected') }}