From 9502f373fa54eb69d21d646e8f1e7cae82c6ccde Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 10:55:09 +0200 Subject: [PATCH] =?UTF-8?q?docs(4.0-P4):=20=E6=8F=92=E4=BB=B6=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=A1=A5=E5=85=85=E6=95=B4=E7=90=86=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E7=82=B9=E3=80=81=E6=95=B4=E7=90=86=E4=BA=8B=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=20rename=20/=20hardlink=20=E5=86=85=E7=BD=AE=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=20P4=20=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/plugins.md | 99 ++++++++++++++++++- ...26-10-08-plugin-architecture-4.0-design.md | 42 +++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 6170eb09..0da71118 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -17,6 +17,9 @@ | REST 路由 | `points.API_ROUTER` | `fastapi.APIRouter` | 插件启用即生效 | | MCP 工具 | `points.MCP_TOOL` | `McpTool` | 插件启用即生效 | | MCP 资源 | `points.MCP_RESOURCE` | `McpResource` | 插件启用即生效 | +| 重命名方式 | `points.RENAME_STRATEGY` | `RenameStrategy` | 设置 → 番剧管理设置 → 重命名方式 | +| 文件分类 | `points.MEDIA_FILES` | `MediaFiles` | 暂只使用宿主实现(见下文) | +| 版本冲突策略 | `points.CONFLICT_POLICY` | `ConflictPolicy` | 暂只使用宿主实现(见下文) | 以及以下钩子(`@hook`,插件启用即生效): @@ -28,7 +31,7 @@ | `http.request` | `points.HTTP_REQUEST` | transform | 修改 AB 发出的 GET 请求头(私有站 Cookie 等) | | `message_template` | `points.MESSAGE_TEMPLATE` | transform | 改写系统事件推送到外部渠道的文案 | -此外,插件可以订阅系统事件(`@subscribe`),并使用私有的键值存储和数据目录。重命名等整理流水线的扩展点会在后续版本开放。 +此外,插件可以订阅系统事件与整理事件(`@subscribe`,如 `torrent.organized`),并使用私有的键值存储和数据目录。 ## 目录结构 @@ -45,7 +48,7 @@ config/plugins/local/my-plugin/ # 目录名必须与清单中的 id 相同 id = "my-plugin" # 小写字母、数字、连字符 name = "我的插件" version = "0.1.0" -sdk = ">=0.3,<1" # 依赖的 ab_sdk 版本范围 +sdk = ">=0.4,<1" # 依赖的 ab_sdk 版本范围 entry = "my_plugin:MyPlugin" # 模块:Plugin 子类,相对插件目录 description = "一句话说明" permissions = ["network"] # 仅用于向用户展示,不做强制 @@ -118,7 +121,7 @@ class MyPlugin(Plugin[Options]): | --- | --- | | `@provider(point, id=...)` | 工厂方法,返回该扩展点约定的实现 | | `@hook(point, priority=...)` | 挂到宿主声明的 filter / transform 扩展点 | -| `@subscribe(kind)` | 订阅事件,`"*"` 表示全部事件 | +| `@subscribe(kind, timeout=...)` | 订阅事件,`"*"` 表示全部事件;`timeout` 覆盖默认 30 秒的单个事件处理超时 | 扩展点名写错时,插件会加载失败并在插件列表里显示原因,不会被悄悄忽略。 @@ -302,6 +305,91 @@ class MyPlugin(Plugin): `request.kind` 区分番剧与电影,`request.torrent.homepage` 是种子的详情页。返回值是完整的新元数据,通常基于 `request.current` 修改。失败或超时时保留原值,并计入熔断。添加订阅时,「解析器」下拉框会列出插件提供的元数据源。 +### 重命名方式(rename_strategy) + +下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`bangumi_manage.rename_method`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。 + +```python +from ab_sdk import Plugin, points, provider +from ab_sdk.rename import RenameInput, RenameSkipped, pad + + +class JellyfinStyle: + def target_name(self, f: RenameInput) -> str: + language = f".{f.language}" if f.kind == "subtitle" else "" + if f.episode_type == "movie": + return f"{f.bangumi_name}{language}{f.suffix}" + if not f.bangumi_name: + raise RenameSkipped("缺少番剧文件夹名") + return f"{f.bangumi_name} - S{pad(f.season)}E{pad(f.episode)}{language}{f.suffix}" + + +class MyRename(Plugin): + @provider(points.RENAME_STRATEGY, id="jellyfin-style") + def jellyfin(self): + return JellyfinStyle() +``` + +- `RenameInput` 是冻结快照:`kind`(`media` / `subtitle`)、`media_path`(种子内原相对路径)、`title`(解析自文件名)、`bangumi_name`(保存目录的番剧文件夹名)、`season`、`episode`(已应用集数偏移)、`suffix`(含点)、`episode_type`(`episode` / `movie` / `special`)、`language`(字幕语言)、`group`。字幕不再有单独的 `subtitle_*` 方式,按 `kind` 区分。 +- 返回种子内的新相对路径,通常只是文件名(扩展名与字幕语言由策略自己拼上);返回 `f.media_path` 表示不改名。`pad(n, width=2)` 补零且保留半集的小数(`pad(9.5) == "09.5"`)。 +- 抛出 `RenameSkipped(原因)`:该文件保留原名,种子不打「已重命名」标签,每个种子按原因发一条 `rename_skipped` 通知;修正后下一轮自动重试。它表示输入或配置有问题,不计入熔断。 +- 抛出其它异常、返回空串或非字符串:同样保留原名并通知,同时计入熔断。 +- `target_name` 是同步调用,没有超时,请不要在里面做网络或磁盘 IO。 +- 设置里选择的 id 没有登记(如插件被停用或熔断)时,AB 记录一次日志并按 `none` 处理。 + +内置的 `template` 使用 Jinja2 沙箱模板渲染文件名主体,在 设置 → 插件 → 重命名 中填写,例如 `{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(2) }}`。可用变量为 `title`、`bangumi_name`、`season`、`episode`、`episode_type`、`group`、`kind`、`language`。保存时会编译并试渲染一次,不合法的模板直接被拒绝(HTTP 422)。运行时渲染失败或结果为空、含路径分隔符时,该文件保留原名并通知,不会退回 `pn`。 + +### 文件分类与版本冲突(media_files / conflict_policy) + +- `media_files`:`classify(path) -> "media" | "subtitle" | "ignore"`,决定种子内哪些文件按正片、字幕重命名。宿主实现按扩展名判断(`.mp4` / `.mkv` 为正片,`.ass` / `.srt` 为字幕)。 +- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主实现沿用设置项「版本冲突策略」。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。 + +这两个扩展点目前只使用宿主实现(id 为 `default`)。插件可以登记自己的实现,但要等多下载器版本的 `plugins.slots` 提供后才能被选用。 + +### 整理事件(file.renamed / torrent.organized) + +| 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` 为原路径 | + +- 路径是**下载器视角**的绝对路径,保存目录与种子内路径以 `/` 拼接(Windows 下载器的 `\` 也统一为 `/`)。AB 与下载器看到的目录不同(如分别运行在不同容器)时,订阅者需要自己做路径映射。 +- `bangumi_id` 来自种子的 `ab:` 标签,旧种子可能为 None。`downloader_id` 目前固定为 `"default"`,多下载器版本会给出实例 id。 +- `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次,订阅者必须幂等。 +- 这两个事件只发布到事件总线,不进入通知中心。 + +```python +from ab_sdk import subscribe +from ab_sdk.events import TorrentOrganized + +@subscribe("torrent.organized", timeout=600) +async def on_organized(self, event: TorrentOrganized): + for f in event.files: + if f.kind == "media": + await self.refresh_library(f.path) +``` + +### 内置插件:硬链接到媒体库(hardlink) + +内置插件 `hardlink` 默认停用,在 设置 → 插件 中启用。它订阅 `torrent.organized`,把正片与字幕链接到媒体库目录,下载目录原样保留继续做种。 + +| 选项 | 说明 | +| --- | --- | +| `source_root` | 下载根目录(AB 本地路径)。媒体库保持与它相同的目录结构:`library_root / (文件相对 source_root 的路径)` | +| `library_root` | 媒体库目录(AB 本地路径),不能位于 `source_root` 内 | +| `path_map` | `[{downloader, from, to}]`:把下载器路径前缀 `from` 换成 AB 本地路径 `to`。`downloader` 默认为 `default`;按最长前缀匹配,未匹配的路径原样使用 | +| `cross_device` | 硬链接遇到跨文件系统(EXDEV)时:`copy`(默认,复制文件)、`symlink`(创建软链接)、`skip`(跳过并通知) | + +- 媒体库中已有同名文件、但不是本插件创建的:跳过,不覆盖,并为该种子发一条 `hardlink.failed` 通知。 +- 本插件之前为同一集创建的链接,在版本升级(新版本替换旧种子,规范文件名不变)后会被原子替换为指向新文件的链接。 +- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。 +- **删除种子不会删除媒体库中的链接**。 +- 启用前已经下载的文件不会自动处理。调用 `POST /api/v1/plugins/hardlink/backfill` 按需补链:遍历 `source_root` 下的 `.mp4` / `.mkv` / `.ass` / `.srt`,返回 `{"linked", "exists", "conflict", "failed"}` 计数。设置页里的「补链已有文件」按钮将随前端插件挂载点一起提供。 + +::: tip Docker +硬链接不能跨文件系统。在 Docker 中,请把下载目录与媒体库放在同一块盘上,并以**同一个挂载点**映射进 AB 容器(例如把 `/mnt/media` 整体挂载为 `/media`,下载目录与媒体库都在其下),否则两个独立挂载的目录即使在同一块盘上,也会被视为不同文件系统,链接会退化为 `cross_device` 指定的行为。下载器运行在另一个容器里、看到的路径与 AB 不同时,用 `path_map` 做映射。 +::: + ### REST 路由 ```python @@ -380,11 +468,14 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent` | `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 | | `llm_plugin_install_failed` | `LLMPluginInstallFailedEvent` | LLM 插件安装失败 | | `rename_conflict` | `RenameConflictEvent` | 媒体文件重命名遇到目标路径冲突 | +| `rename_skipped` | `RenameSkippedEvent` | 重命名方式无法为种子中的文件给出名字,文件保留原名 | | `plugin.loaded` | `PluginLoaded` | 插件加载成功 | | `plugin.disabled` | `PluginDisabled` | 插件加载失败或被熔断 | | `inbox.changed` | — | 通知中心有新消息、已读或删除(宿主内部使用,供 SSE 推送) | -系统事件的 `kind` 沿用 3.x 的取值(通知中心按它存储和翻译),因此不带 `.` 前缀;插件自己的事件仍必须以 `<插件 id>.` 开头。后续版本会陆续增加流水线事件(如 `torrent.added`、`file.renamed`)。 +系统事件的 `kind` 沿用 3.x 的取值(通知中心按它存储和翻译),因此不带 `.` 前缀;插件自己的事件仍必须以 `<插件 id>.` 开头。整理流水线事件 `file.renamed`、`torrent.organized` 见上文「整理事件」。 + +插件也可以继承 `SystemEvent` 定义自己的可通知事件。用 `self.ctx.bus.publish(...)` 发布后,它与宿主事件走同一条路径:写入通知中心(前端没有对应翻译时显示 `describe()` 的标题与正文),发布到事件总线,再推送到外部通知渠道。`dedup_key()` 相同的事件在通知中心合并为一条。内置插件 `hardlink` 的 `hardlink.failed` 就是这样发出的。 ### 通知文案模板 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 df3fae4e..ef356c4a 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 @@ -45,6 +45,46 @@ - **i18n(第 11 节风险 3)先给出 key**:`SystemEvent.i18n()` 返回 `notifications.kind.` 与 `payload()`,与前端现有文案键一致;外部推送与通知中心的中文兜底文案不变。插件事件的前端翻译依赖 P6 的 `host.i18n`。 - 进程级访问器 `module.plugin.host.get_bus/set_bus`、`get_runner/set_runner`、`publish()`,由 `AppContext` 构造时设置,未设置时为空操作。MCP 的 `tools/list_changed` 通知未实现(客户端重新 list 即可看到插件工具变化)。 +### 实施中的调整(P4) + +拆分与扩展点: + +- **`renamer.py` 拆分**:revision 替换事务(普通改名日志、V1→V2 替换、恢复、操作状态、版本占用者)搬到 `manager/revision_saga.py` 的 `RevisionSaga`,`Renamer` 以 mixin 方式继承它(`class Renamer(RevisionSaga)`),而不是构造函数注入。这样第一个提交是纯搬移,测试仍能访问 `renamer._run_ordinary_rename`、`renamer.events`、`renamer._downloader_type`。`renamer.py` 从 1805 行降到 1061 行(含 P4 新增的扩展点接入)。 +- **重命名方式**:宿主只以 `core` 登记 `none`(`manager/rename_strategies.py` 的 `NoRename`)。`pn`、`advance`、`template` 由内置插件 `rename`(`module/plugins/builtin/rename/`,默认启用)提供。设置值 `bangumi_manage.rename_method` 直接作为 Provider id,`slots` 与迁移器留到 P2.5。id 未登记(插件停用或被熔断)时记录一次日志并按 `none` 处理。 +- **纯搬移的一处例外**:3.3 中未知重命名方式遇到电影文件时会改名为 `{title}{suffix}`;现在未知 id 一律按 `none` 处理,保留原名。 +- **`normal`**:`gen_path` 中已没有 `normal` 分支。`conf/config.py` 中 `normal → none` 的转换属于 3.x 兼容层(第 8.3 节),保留不动。 +- **字幕**:删除 `subtitle_pn` / `subtitle_advance`,类别由 `RenameInput.kind` 给出。因此 `test_renamer.py` 有 5 处调用参数从 `subtitle_*` 改为 `pn` / `advance`(断言未改),`test_plugin_extensions.py::test_providers` 的期望值增加 `rename_strategy: []`。既有测试依赖进程注册表中的 `pn` / `advance`,由 `conftest.py` 的 autouse fixture 登记内置插件 `rename`,与生产默认一致。除此之外,pn / advance / none 的输出与 3.3 逐字节一致。 +- **策略调用是同步的**:`HookRunner.call_provider_sync` 在熔断器内调用策略并校验返回值(非空字符串),没有超时;Jinja2 渲染也没有超时,只受沙箱 `MAX_RANGE` 限制。`RenameSkipped` 不计入熔断;其它异常与非法返回值计入熔断,并同样按跳过处理。 +- **跳过通知**:新增系统事件 `rename_skipped`(`RenameSkippedEvent`,WebUI 的 `KNOWN_KINDS`、i18n 与跳转路由同步增加),不复用 `rename_conflict`。同一 (种子 hash, 原因) 每个进程只通知一次。字幕被跳过同样会阻止打 `ab:renamed` 标签,用户修正后种子会被重试。 +- **模板失效的后果**:已保存的模板若不再通过校验(手工改配置、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` 的种子同样发布。订阅者必须幂等。 +- **WebUI**:重命名方式下拉框经 `usePluginProviders` 合并插件提供的 id(如 `template`),`rename_method` 类型放宽为字符串。`template` 在 设置 → 番剧管理设置 → 重命名方式 中选择,模板在 设置 → 插件 → 重命名 中填写。 +- `ab_sdk` 新增 `ab_sdk.rename`(契约与冻结快照 `RenameInput`、`Revision`、`RevisionTask`、`ConflictRequest`,以及 `pad()`),`SDK_VERSION` 升到 `0.4.0`。 + +插件运行时(为内置插件 `hardlink` 补齐): + +- **清单字段 `default_enabled`**(默认 `true`,只对内置插件生效)。`hardlink` 设为 `false`,内置但默认停用。 +- **`@subscribe(kind, timeout=...)`**:覆盖总线默认 30 秒的单个事件处理超时。跨盘复制一集常超过 30 秒,超时会计入熔断,5 个种子后插件就会被停用。`hardlink` 用 3600 秒,文件操作放在 `asyncio.to_thread` 中执行,不阻塞事件循环。 +- **插件的可通知事件进入通知中心**:P5 中插件发布的 `SystemEvent` 只进事件总线。现在 `PluginBus.publish` 把它交给 `NotificationManager.send_event`,与宿主事件一样写入通知中心、发布到总线、推送外部渠道。前端没有对应翻译的 kind 显示 `describe()` 的中文标题与正文。 + +内置插件 `hardlink`(`module/plugins/builtin/hardlink/`): + +- 插件 id 为 `hardlink`,不是第 4.4 / 7 节示例中的 `hardlink-organizer`。它订阅 `torrent.organized` 而不是第 2.3 节草图中的 `file.renamed`:前者给出种子的完整最终文件集合,重命名方式为 `none` 时也会发布。 +- `source_root`、`library_root` 为必填的绝对路径,`library_root` 不能位于 `source_root` 内;未填写时启用会加载失败,并在插件列表中显示原因。 +- 「由插件创建」记录在插件 KV 中:键 `link:<目标路径>`,值为源文件身份 `[st_dev, st_ino, st_size, st_mtime_ns]`。加入大小与修改时间,是因为复制模式下旧文件删除后 inode 可能被复用。目标存在且与源是同一文件,或记录的身份与当前源一致时,视为已完成。 +- 目标被占用、跨文件系统按 `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 路由。 +- 删除种子不会删除媒体库中的链接。 + +未实施(推迟): + +- 第 10 节 P4 行中的 `file_parser` 扩展点。 +- 第 7 节的 `ab_sdk.testing.RenameStrategyContract` 契约测试套件。 +- 第 3.6 节的 `media-server-refresh` 示例插件。 + ## 1. 背景与目标 AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。 @@ -591,7 +631,7 @@ organize: downloader.completed → media_files.classify → file_parser | **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 | | **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 | | **P3 流水线插件化:ingest** | `torrent.filter`、`title.parsed`、`torrent.adding`、`http.request` 钩子;`metadata_provider`(mikan / tmdb 以 `core` 登记);内置插件 `ingest-filters`(包含过滤) | 已完成;无插件时行为不变(全量测试)。`feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`ranker`、`save_path`、size 过滤、按订阅覆盖推迟,见第 0 节 | -| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 模板重命名;硬链接示例插件 | +| **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`(默认停用)。`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` 包 | 示例插件「手动选种」以详情页标签形式可用 | | **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架 |