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 new file mode 100644 index 00000000..92f179a2 --- /dev/null +++ b/docs/plans/2026-10-08-plugin-architecture-4.0-design.md @@ -0,0 +1,504 @@ +# AutoBangumi 4.0 插件化重构设计 + +> 状态:草案 · 目标分支:`4.0-dev` · 基线:`main@3.3.6` + +## 1. 背景与目标 + +AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。 +其余能定制的行为都是静态字典、`if/elif` 或配置里的 `Literal`。 + +| 现有扩展点 | 现状 | 选择方式 | +|---|---|---| +| LLM 提供商 | `BUILTIN` + `PRESET_ADAPTERS` + `config/plugins` 签名插件 | `llm.provider` | +| 通知 | 静态 `PROVIDER_REGISTRY` 字典 | `notification.providers[].type` | +| 下载器 | `download_client.py:150` if/elif(qb/aria2/mock) | `downloader.type` | +| 搜索站点 | `config/search_provider.json` 数据 | 站点 key | +| 元数据(mikan/tmdb) | `rss/analyser.py` if/elif | `RSSItem.parser` | +| 标题解析引擎 | `selector.py` if/else | `rss_parser.engine` | +| 重命名方式 | `renamer.py:132` if/elif | `bangumi_manage.rename_method` | +| 版本冲突策略 | 内联在 `renamer.py:1459` | `revision_conflict_policy` | +| 定时任务 | `AppContext.build()` 固定列表 | 不可配置 | +| 系统事件 | `SystemEvent` 封闭 Union | 不可扩展 | +| MCP 工具 / 资源 | 静态列表 + if/elif | 不可扩展 | + +用户想定制的东西,比如自定义重命名格式、包含过滤、入库后硬链接或刷新 Jellyfin、私有 RSS 站点、新的下载器,现在只能 fork 改源码。 + +**目标** + +1. 所有「用户可能想换掉或追加」的行为都变成**扩展点**,内置实现本身也是插件(自举 / dogfooding)。 +2. 提供**稳定、版本化的插件 SDK**(`ab_sdk`),与内部实现解耦。内部重构不应破坏第三方插件。 +3. 插件可声明配置 schema,WebUI **自动渲染**配置表单,无需改前端。 +4. 插件崩溃或超时**不拖垮主流程**:隔离、熔断、自动禁用。 +5. 借大版本窗口**清理死代码与 3.x 兼容层**(见第 8 节)。 + +**非目标** + +- 不做进程级沙箱。插件与 AB 同进程、同权限,信任靠「签名目录 + 用户显式允许本地插件」。 +- 不做前端插件(自定义 Vue 组件)。4.0 只做 schema 驱动的表单,前端组件扩展留到 4.x。 +- 不改 qB/aria2 的外部 API 语义。 + +## 2. 核心概念 + +### 2.1 两类扩展:Provider 与 Hook + +| 类型 | 语义 | 例子 | +|---|---|---| +| **Provider(槽位)** | 某个能力的一个可选实现,按 id 选择 | 下载器、重命名策略、元数据源、标题解析引擎 | +| **Hook(钩子)** | 在流水线某点插入逻辑,可多个、按优先级串联 | 种子过滤、入库后处理、事件订阅 | + +Hook 再细分三种: + +- **Filter hook** 返回 `accept / reject(reason)`,任一 reject 即短路。 +- **Transform hook** 接收并返回修改后的上下文,用于改写标题或路径。 +- **Observer hook** 只订阅事件,异步执行,失败不影响主流程。 + +### 2.2 插件包结构 + +``` +my-plugin/ +├── plugin.toml # 清单 +├── my_plugin/ +│ ├── __init__.py # 导出 Plugin 子类 +│ └── ... +└── README.md +``` + +```toml +# plugin.toml +[plugin] +id = "hardlink-organizer" # 全局唯一,[a-z0-9-] +name = "硬链接整理" +version = "1.2.0" +sdk = ">=4.0,<5" # 依赖的 ab_sdk 版本范围(替代 min_ab_version) +entry = "my_plugin:HardlinkPlugin" +authors = ["..."] +description = "..." + +[plugin.requires] +python = [] # 额外 pip 依赖(仅 pip/开发模式允许,见 2.4) +permissions = ["fs.write", "downloader.read"] # 声明式,展示给用户,见 6.2 +``` + +### 2.3 插件入口:一个类注册多个扩展 + +```python +from ab_sdk import Plugin, hook, provider +from ab_sdk.rename import RenameStrategy, RenameInput +from ab_sdk.events import FileRenamed +from pydantic import BaseModel + +class Config(BaseModel): + target_root: str = "/media/anime" + mode: Literal["hardlink", "symlink", "copy"] = "hardlink" + +class HardlinkPlugin(Plugin): + config_model = Config # 自动生成 WebUI 表单 + + async def setup(self, ctx): ... # 生命周期:加载 / 配置变更 + async def teardown(self): ... + + @hook("file.renamed", priority=100) # Observer + async def link(self, event: FileRenamed): ... + + @provider("rename_strategy", id="jellyfin-style") + class JellyfinRename(RenameStrategy): + def target_name(self, f: RenameInput) -> str: ... +``` + +不使用 pluggy:它是同步模型,而 AB 全异步,且需要类型化的上下文与超时控制。 +采用自研的轻量类型化注册表,实现集中在 `module/plugin/registry.py`。 + +### 2.4 插件来源与发现 + +按优先级从高到低: + +1. **内置**:`module/plugins/builtin//`,随镜像发布,不可卸载但可禁用。 +2. **签名目录**:泛化现有 `llm_plugins/installer.py`。 + - 目录 tag 从 `llm-plugins` 改为 `plugins`,`catalog.json` 增加 `kind` / `extension_points` 字段。 + - 保留 ed25519 签名、sha256、zip-slip 防护、版本指针 `installed.json`、带版本号的模块名热加载。 +3. **本地目录**:`config/plugins/local//`,用于用户二次开发。 + - 默认关闭。需开启 `plugins.allow_unsigned = true`。 + - WebUI 显示「未签名」警告。 + - 开启开发模式(`plugins.dev_mode`)后监听文件变更,自动热重载。 +4. **pip entry point**:`[project.entry-points."autobangumi.plugins"]`。 + - 面向非 Docker 部署和插件作者本地测试。 + - 只有这一种来源允许额外依赖。 + +同一 id 只能来自一个来源,冲突时以高优先级为准并告警。 + +## 3. 扩展点清单 + +接口签名为 SDK 草案。「现状」列给出要拆出的代码位置(相对 `backend/src/module/`)。 + +### 3.1 来源 / 抓取(Source) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `feed_source` | Provider | `fetch(feed: FeedConfig) -> list[RawItem]` | `network/request_contents.py:15` + `network/site/mikan.py:6`(唯一的通用 RSS 解析) | +| `search_provider` | Provider | `build_query(keywords) -> FeedConfig`;`parse(...)` | `conf/search_provider.py` 数据 + `searcher/provider.py` | +| `http.request` | Transform hook | 修改请求头、cookie、UA,用于私有站或 PT | `network/request_url.py:112` 硬编码 UA | + +`feed_source` 按 `FeedConfig.source`(替代当前语义混乱的 `RSSItem.parser`)选择,默认是 `rss2`。 +内置提供 `rss2`、`mikan`、`dmhy`、`nyaa`、`anibt`:前者是通用实现,后几个负责站点特有字段(homepage、发布组、大小)。 +搜索站点 JSON 配置保留,作为 `generic-search` 内置插件的配置。 + +### 3.2 解析(Parse) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `title_parser` | Provider | `parse(raw: str) -> ParsedRelease \| None` | `parser/analyser/selector.py`(classic / tokenizer) | +| `title.parsed` | Transform hook | 修正 `ParsedRelease`(字幕组别名、季数修正规则) | 无 | +| `llm_provider` | Provider | 现有 `LLMProviderAdapter` 原样迁入 SDK | `parser/analyser/providers/` | +| `file_parser` | Provider | `parse(path, ctx) -> EpisodeFile \| SubtitleFile` | `parser/analyser/torrent_parser.py`(`RULES`、`SUBTITLE_LANG`) | +| `admission_policy` | Provider | `target(release) -> bangumi \| movie \| None` | `parser/release_policy.py:53` | + +LLM 的 `primary` / `fallback` 编排变成 `title_parser` 的一种**组合**:内置 `chain` 解析器,按配置顺序尝试多个解析器。 +`ParserEngine` Literal 改为注册表 id。 + +### 3.3 匹配 / 过滤 / 决策(Match) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `torrent.filter` | Filter hook | `accept(torrent, release, bangumi) -> Verdict` | `rss/engine.py:165`(只有 exclude 正则) | +| `bangumi_matcher` | Provider | `match(torrent, release, rules) -> Bangumi \| None` | `database/bangumi.py:151` | +| `release_ranker` | Provider | `score(release, bangumi) -> tuple` | `rss/engine.py:44` + `_select_preference_skips` | +| `rule.created` | Transform hook | 新番规则入库前修改或拒绝 | `rss/analyser.py:108` + `database/bangumi.py:106` | + +内置 `torrent.filter` 拆为三项:`exclude-regex`(现有行为)、新增 `include-regex`、新增 `size-limit`。 +都可按订阅单独覆盖。按订阅配置挂在 `Bangumi.plugin_options: JSON` 新列上。 + +### 3.4 元数据(Enrich) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `metadata_provider` | Provider(可链) | `enrich(item, hint) -> MetadataPatch` | `rss/analyser.py:25-97` if/elif;`mikan_parser.py`、`tmdb_parser.py` | +| `calendar_provider` | Provider | `fetch() -> list[CalendarItem]`;`match(bangumi, items)` | `parser/analyser/bgm_calendar.py` | +| `offset_advisor` | Provider | `suggest(bangumi, info, latest_ep) -> OffsetSuggestion \| None` | `parser/analyser/offset_detector.py` | +| `poster_provider` | Provider | `fetch(bangumi) -> bytes \| url` | 分散在 mikan / tmdb | + +`metadata_provider` 以链式执行,前一个结果作为 hint 传给下一个,例如先 mikan 再 tmdb 补全年份和海报。 +后续可加 Bangumi.tv、AniList 等插件。 +TMDB 常量(genre 16、`w780`、`gap_months=6`)收进插件配置。 + +### 3.5 下载(Download) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `downloader` | Provider | 现有 `DownloaderClient` Protocol + `DownloaderCapabilities` | `downloader/download_client.py:150` | +| `save_path` | Provider | `save_path(item) -> str`;`parse_save_path(path) -> (name, season)` | `downloader/path.py:91`、`:54` | +| `torrent.adding` | Transform hook | 修改 category、tag、save_path、暂停状态 | `download_client.py:424`(硬编码 `"Bangumi"`、`ab:`) | + +下载器工厂统一为 `create(config: PluginConfig) -> DownloaderClient`。 +这样可以消除各后端构造参数不一致的问题。 +缓存、引用计数、凭证闩锁留在 facade 中,与具体后端无关。 + +### 3.6 整理 / 重命名(Organize) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `rename_strategy` | Provider | `target_name(f: RenameInput) -> str` | `manager/renamer.py:113-167` | +| `media_files` | Provider | `classify(path) -> media \| subtitle \| ignore` | `downloader/path.py:22`(仅 mp4/mkv/ass/srt)、`is_ep` 深度 ≤ 2 | +| `conflict_policy` | Provider | `decide(owner, incoming, files) -> hold \| replace` | `manager/revision_policy.py` + `renamer.py:1441` | +| `file.renamed` | Observer hook | `(bangumi, old_path, new_path, file_kind)` | 无 | +| `torrent.organized` | Observer hook | 整个种子处理完成 | 无 | + +内置 `rename_strategy` 包括 `pn`、`advance`、`none`,并新增 `template`: +- `template` 使用 Jinja2 沙箱模板,例如 `{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(2) }}`。项目已依赖 Jinja2,能覆盖 80% 的定制需求。 +- 字幕不再是 `subtitle_*` 平行方法,改为 `RenameInput.kind`。 +- 删除废弃的 `normal`。 + +`file.renamed` 是最大的新增价值点,可接硬链接、NFO、Jellyfin/Plex/Emby 刷新、rclone 上传。 +内置先提供 `media-server-refresh` 示例插件。 + +`renamer.py` 共 1856 行,借此拆分: +- 编排(拉列表、查 offset、调用策略) +- 策略(插件) +- revision 替换事务(`manager/revision_saga.py`) + +### 3.7 通知 / 事件(Notify) + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `notifier` | Provider(多实例) | `send(message: RenderedMessage)`;`test()` | `notification/providers/*` + 静态 `PROVIDER_REGISTRY` | +| `message_template` | Transform hook | 针对事件类型渲染 title / body | `notification/base.py:89`(只有 4 个变量) | +| 任意事件 | Observer hook | 见 4.2 | — | + +`NotificationProvider` 配置从「所有字段的大并集」改为每个插件自己的 `config_model`。 + +### 3.8 调度 / API / MCP + +| 扩展点 | 类型 | 接口 | 现状 | +|---|---|---|---| +| `scheduled_task` | Provider | `ScheduledTask(name, interval(), enabled(), run(ctx))` | `core/context.py:116` 固定列表;间隔常量在 `:50` | +| `api_router` | Provider | 返回 `APIRouter`,挂载于 `/api/v1/plugins//` | `api/__init__.py` 静态 include | +| `mcp_tool` / `mcp_resource` | Provider | `Tool` 定义 + handler | `mcp/tools.py:216`、`mcp/resources.py:56` if/elif | + +现有 rss / rename / offset_scan / calendar / update_check 都注册为内置 `scheduled_task`。 +间隔可配,修复「6h / 24h 不可改」的问题。 +新增内置 `metadata_refresh` 任务:`refresh_metadata` 当前只能通过 API 触发。 + +## 4. 运行时架构 + +### 4.1 模块布局 + +``` +backend/src/ +├── ab_sdk/ # 稳定公开 API(仅类型、Protocol、基类、装饰器、事件) +│ ├── __init__.py # Plugin, hook, provider, PluginContext +│ ├── models.py # ParsedRelease, Bangumi(只读视图), RawItem, ... +│ ├── events.py +│ ├── source.py / parse.py / match.py / download.py / rename.py / notify.py ... +│ └── testing.py # FakeContext、契约测试套件 +└── module/ + ├── plugin/ # 运行时:发现、加载、注册表、生命周期、隔离 + │ ├── manifest.py + │ ├── loader.py # 由 llm_plugins/loader.py 泛化 + │ ├── installer.py # 由 llm_plugins/installer.py 泛化 + │ ├── registry.py # ExtensionRegistry:providers + hooks + │ ├── runner.py # 调用 hook:优先级、超时、熔断 + │ ├── bus.py # EventBus + │ └── config.py # 插件配置存取 + JSON Schema + ├── plugins/builtin/ # 全部内置实现(qb、aria2、tmdb、mikan、pn、telegram ...) + └── pipeline/ # 流水线编排(只调用扩展点,不含具体策略) + ├── ingest.py # Source → Parse → Match → Download + └── organize.py # 完成种子 → 分类 → 重命名 → 后处理 +``` + +**依赖方向**是 `ab_sdk` ← `module.plugins.*` ← `module.pipeline` ← `module.api/core`。 +`ab_sdk` 不得 import `module.*`。用 import-linter 架构测试强制,对应 07-09 设计文档提出的「架构测试」。 + +### 4.2 事件总线 + +替换封闭的 `SystemEvent` Union 和 producer 返回事件列表的模式: + +```python +bus.publish(TorrentAdded(bangumi_id=..., name=...)) +``` + +- 事件是 `ab_sdk.events` 中的冻结 dataclass,保留现有 `kind / severity / once / dedup_key / describe`。插件可以定义并发布自己的事件,以 `.` 为前缀。 +- 订阅者包括:通知管理器(替代 `send_event`)、inbox 记录、SSE 推送(替代 `api/events.py` 的 1s 轮询 + `inbox_revision`)、插件 observer hook。 +- 投递是 `asyncio` 队列 + 每订阅者独立 task,单个订阅者的异常和超时被隔离。 +- 新增事件:`feed.fetched`、`torrent.added`、`torrent.completed`、`file.renamed`、`rule.created`、`rule.archived`、`settings.reloaded`。 + +### 4.3 PluginContext(插件可见的能力) + +插件**只**通过 `ctx` 访问宿主,不 import `module.*`: + +```python +class PluginContext(Protocol): + plugin_id: str + config: BaseModel # 已校验的插件配置 + log: logging.Logger # 前缀 [plugin:] + http: HttpClient # 共享代理 / UA / 重试 + bus: EventBus + kv: KeyValueStore # 插件私有持久化(plugin_kv 表) + secrets: SecretStore # 由 llm CredentialStore 泛化 + data_dir: Path # config/plugin-data// + bangumi: BangumiReadAPI # 只读查询 + 有限写操作 + downloader: DownloaderFacade +``` + +数据库 session **不暴露**给插件:遵守「session per operation」约定,插件也不应持有 ORM 对象。 + +### 4.4 配置 + +```jsonc +// config.json +{ + "plugins": { + "allow_unsigned": false, + "dev_mode": false, + "enabled": { "hardlink-organizer": true }, + "options": { "hardlink-organizer": { "target_root": "/media" } }, + "slots": { // Provider 选择 + "downloader": "qbittorrent", + "rename_strategy": "pn", + "title_parser": ["tokenizer", "llm"], // 链 + "metadata_provider": ["mikan", "tmdb"] + }, + "hook_order": { "torrent.filter": ["exclude-regex", "include-regex"] } + } +} +``` + +- 多实例插件(多个 Telegram、多个下载器预留)用 `instances: [{id, plugin, options}]`。 +- `GET /api/v1/plugins` 返回每个插件的 `config_model.model_json_schema()`。前端用通用 JSON Schema 表单组件渲染,`config-notification.vue` 等专用页面逐步退化为通用表单。 +- 秘密字段用 `Field(json_schema_extra={"secret": True})` 标注,读 API 不返回,沿用现有「不回传 secret」规则。 +- `AppContext.reload_settings()` 改为:重新校验插件配置,并对受影响插件调用 `teardown` + `setup`。现在在 `context.py:405` 显式 import 的各种 `reset_cache()` 改为插件在 `settings.reloaded` 事件中自行处理。 + +### 4.5 隔离与可靠性 + +- 每次 hook / provider 调用都有超时(默认 30s,可在清单里声明)。 +- 每个插件有熔断器:连续 N 次失败后自动禁用,并发布 `PluginDisabled` 事件进入 inbox。复用 LLM 熔断实现。 +- Provider 失败的处理: + - Filter 默认 **fail-open**(不过滤),可配置为 fail-closed。 + - 下载器、重命名失败沿用现有「跳过并记录」语义。 +- 加载失败(导入错误、SDK 版本不符、清单无效)只禁用该插件,不阻止启动。WebUI 显示原因。 + +## 5. 流水线重写 + +`rss/engine.py` 的 `_refresh_rss` 和 `manager/renamer.py` 的 `rename` 改写为显式阶段,每阶段只调用扩展点: + +``` +ingest: feed_source.fetch → http.request(hook) + → title_parser(chain) → title.parsed(hook) → admission_policy + → bangumi_matcher → torrent.filter(hooks) → release_ranker + → [新标题] metadata_provider(chain) → rule.created(hook) + → save_path → torrent.adding(hook) → downloader.add + → bus: torrent.added + +organize: downloader.completed → media_files.classify → file_parser + → offset 查找 → rename_strategy → conflict_policy + → downloader.rename → bus: file.renamed → torrent.organized +``` + +`parser_engine_snapshot()` 的「一次工作流固定一个引擎」语义,改为在流水线入口快照 slot 配置。 + +## 6. 安全模型 + +### 6.1 信任分级 + +| 来源 | 默认 | 说明 | +|---|---|---| +| 内置 | 启用 | 随镜像签发 | +| 签名目录 | 需用户点「安装」 | 沿用 ed25519 校验 | +| 本地目录 / pip | **默认拒绝** | 需开启 `allow_unsigned`,UI 持续显示警告 | + +### 6.2 权限声明 + +`permissions` 只做**告知**,不做强制(同进程无法强制),在安装确认框中展示。 +例外是 `ctx` 层的有限能力门控,例如没声明 `downloader.write` 时 `ctx.downloader` 只读。 +这样能防止误用,但不防恶意代码。文档要明确写出这一点。 + +### 6.3 其他 + +`api_router` 挂载的路由**强制**经过现有鉴权依赖,插件无法注册匿名端点。 + +## 7. 开发者体验 + +- **SDK 文档**:`docs/dev/plugins/`,包括概念、每个扩展点的参考、事件列表、配置表单约定。 +- **模板仓库**:`autobangumi-plugin-template`(cookiecutter),内含 `pyproject`、清单、示例 hook、pytest 契约测试、打包与签名提交流程。 +- **CLI**:`uv run ab-plugin new | validate | pack | dev`。 + - `validate` 校验清单与 SDK 版本。 + - `dev` 把当前目录软链到 `config/plugins/local/` 并开启热重载。 +- **契约测试**:`ab_sdk.testing` 为每类 Provider 提供参数化测试套件,例如 `DownloaderContract`、`RenameStrategyContract`。内置插件也必须通过。 +- **示例插件**(随 4.0 发布):`template-rename`、`include-filter`、`hardlink-organizer`、`jellyfin-refresh`、`webhook-on-event`、`custom-rss-site`。 + +## 8. 死代码与旧逻辑清理 + +以下由 `vulture` + 全仓引用核对得出(生产代码 0 引用)。 +已剔除误报:FastAPI 路由、`@model_validator` / `@field_validator`、中间件 `dispatch`。 +标「仅测试」的项目要连同对应测试一起删。 + +### 8.1 确认可删(生产 0 引用) + +| 位置 | 项 | 备注 | +|---|---|---| +| `ab_decorator/__init__.py:47` | `api_failed` | | +| `network/site/mikan.py:25` | `mikan_title` | | +| `network/request_contents.py:62` | `post_form_json` | | +| `downloader/path.py:120,125,142` | `gen_movie_save_path`、`movie_rule_name`、`join_path` | | +| `downloader/client/qb_downloader.py:274` | `check_rss` | | +| `manager/renamer.py:71` | `print_result` | | +| `manager/renamer.py:327` | `rename_movie_file` | 电影重命名走 `rename_collection`,此函数无调用方 | +| `rss/engine.py:424` | `download_movie` | | +| `database/bangumi.py` | `update_rss`、`update_poster`、`not_added`(`:527/538/686`) | | +| `database/movie.py:171` | `not_added` | | +| `database/torrent.py` | `update_one_user`(`:36`) | | +| `database/passkey.py:45` | `get_passkey_by_id` | | +| `database/user.py:104` | `set_enabled` | 确认用户管理 API 不需要后删除 | +| `database/rename_operation.py:323` | `release_replacement_lease` | 状态迁移方法(`:300` 一带)提交时已清空 `lease_owner`,租约本身也会过期,大概率冗余;删除前核对 `renamer.py:799`、`:1103` 两处 claim 之后是否有提前 return 的路径 | +| `models/api.py` | `RssLink`、`AddRule`、`ChangeConfig`、`ChangeRule` | 整个文件可能可删 | +| `models/bangumi.py:144` | `SeasonInfo` | | +| `models/user.py:72` | `TokenData` | JWT 时代遗留 | +| `security/api.py:182,192` | `get_token_data`、`update_user_info` | | +| `parser/analyser/raw_parser.py:19` | `_detect_non_episodic_type` | | +| `parser/analyser/tmdb_parser.py:178` | `get_aired_episode_count` | | +| `parser/analyser/tokenizer/classic.py:896` | `_mostly_metadata` | | +| `parser/analyser/tokenizer/candidate.py:178`、`resolver.py:33` | `is_empty`、`selected_candidate_ids` | | +| `update/rss.py` | `update_main_rss`(整个文件) | | +| `update/data_migration.py:24` | `database_migration` | | +| `conf/const.py:152-160` | 未用 ANSI 颜色常量 | | + +### 8.2 仅测试引用(删除或移入测试辅助) + +`offset_scanner.check_single`、`aria2.set_renamed_path`、`bangumi.get_all_title_patterns`、`bangumi.match_poster`、`torrent.search_by_url`、`torrent.update_qb_hash`、`rename_operation.list_retryable`、`renamer.rename_file`、`searcher._fetch_tmdb_poster`、`security/jwt.create_access_token`。 + +`jwt.py` 整体随旧 JWT 兼容一起移除,见 8.3。 + +### 8.3 3.x 兼容层(4.0 断代移除) + +4.0 **只支持从 3.3.x 升级**。更早的版本需先升到 3.3.x。启动时检测 `config/version.info`,低于 3.3 就拒绝启动并给出提示。 + +| 兼容层 | 位置 | 处理 | +|---|---|---| +| 3.0→3.1、3.1→3.2 跨版本迁移 | `update/cross_version.py`(`from_30_to_31`、`from_31_to_32`),`core/context.py:214-226` | 删除 | +| 旧 `data.json` 迁移 | `update/data_migration.py`、`LEGACY_DATA_PATH` | 删除 | +| `Database.migrate()` 删表重建 | `database/combine.py:103` | 删除(只剩上面的调用方) | +| 旧版明文 bearer token 导入 | `update/auth.py`、`database/auth.py:29` | 3.3 已导入,删除 | +| 旧 JWT cookie 兑换 | `security/jwt.py` 及相关 | 兼容期已过,删除 | +| GET 版控制端点(3.2 兼容) | `api/program.py:113-128`(注释已写「下个大版本移除」) | 删除 | +| `GET /auth/refresh_token` | `api/auth.py:94` | 删除 | +| `experimental_openai` 配置段 | `models/config.py:327,424`,`title_parser.py:114` | 配置迁移器转为 `llm` 段后删除 | +| 通知单 provider 旧字段 | `models/config.py:216-246`,`bark.py:22` token,`wecom.py:20` chat_id | 迁移到插件实例配置后删除 | +| `rename_method = "normal"` | `renamer.py:152` | 删除,配置迁移为 `none` | +| `season_offset` 无用参数 | `renamer.py:119` | 删除 | +| 旧 offset 建议模型 | `api/bangumi.py:23` | 核对前端后删除 | +| `Bangumi` 旧 movie 标志 | `models/bangumi.py:137` | 统一为 typed classifier,做 DB 迁移 | +| 「旧版下载器返回 bool」兼容 | `download_client.py:337-349` | Protocol 统一返回 `AddResult` 后删除 | +| tokenizer → `Episode` 投影 | `tokenizer/compat.py`、`raw_parser.py`、`title_parser._project_classic_release` | 流水线改用 `ParsedRelease` 后删除;`Episode` 仅保留为 LLM 输出 schema | +| `llm_plugins/` 独立安装器 | 整个目录 | 并入 `module/plugin/` | +| `LLMParser` 向后兼容属性代理 | `parser/analyser/llm.py` | 删除,同步更新测试 patch 路径 | +| qB < 4.x API 回退 | `qb_downloader.py:551` modern/legacy 双路径 | 定最低 qB 版本(建议 4.5)后评估 | + +### 8.4 清理纪律 + +- 清理放在**独立 PR**,先于功能重构合入(阶段 0)。这样 diff 可审,回归可二分。 +- 每删一项,跑 `uv run pytest` 和 `pnpm test:build`。前端同步删除对应 API 调用(`webui/src/api/`)。 +- 在 CI 中加入 `vulture --min-confidence 80` 白名单检查,防止死代码回流。 + +## 9. 配置与数据迁移(3.3 → 4.0) + +在 `module/update/v4.py` 中实现一次性迁移,迁移前把原配置备份为 `config.json.v3.bak`: + +| 3.x | 4.0 | +|---|---| +| `downloader.type` + 连接字段 | `plugins.slots.downloader` + `plugins.options.` | +| `rss_parser.engine` + `llm.mode` | `plugins.slots.title_parser` 链 | +| `rss_parser.filter` | `exclude-regex` 插件默认选项 | +| `bangumi_manage.rename_method` | `plugins.slots.rename_strategy` | +| `revision_conflict_policy` | `plugins.slots.conflict_policy` | +| `notification.providers[]` | 通知插件实例 | +| `search_provider.json` | `generic-search` 插件选项 | +| `RSSItem.parser` 列 | `rssitem.source` + `metadata` 两列(DB migration) | +| `config/plugins//` | 路径不变,清单从 `plugin.json` 转为 `plugin.toml` | + +数据库新增 `plugin_kv`、`bangumi.plugin_options`,并对 `rssitem` 拆列。 +按 CLAUDE.md 的方式追加 `Migration` 条目。 + +## 10. 分阶段计划 + +每阶段都可独立合入 `4.0-dev`,主干保持可运行、测试全绿。 + +| 阶段 | 内容 | 产出 / 验收 | +|---|---|---| +| **P0 清理** | 第 8 节:死代码、3.x 兼容层;`renamer.py` 先做纯搬移式拆分(不改行为) | 生产代码行数减少;vulture CI;测试全绿 | +| **P1 插件运行时 + SDK 骨架** | `ab_sdk` 包、`module/plugin/`(清单、加载、注册表、runner、配置、EventBus);泛化 LLM installer;import-linter | 空插件可加载、配置、热重载;契约测试框架 | +| **P2 迁移已有注册表** | LLM、通知、下载器、搜索、定时任务改为内置插件;`/api/v1/plugins` + 通用 JSON Schema 表单 | 行为与 3.3.6 一致(e2e 回归);配置迁移器 | +| **P3 流水线插件化:ingest** | `feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`torrent.filter`、`ranker`、`metadata_provider` 链、`save_path`、`torrent.adding` | 新增 include / size 过滤;私有站 headers | +| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 模板重命名;硬链接示例插件 | +| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 删掉 SSE 轮询;插件 MCP 工具 | +| **P6 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个示例插件上架 | +| **P7 发布** | beta 测试、性能对比(RSS 刷新耗时、内存)、升级指南、`docs/changelog/4.0.md` | `4.0.0-beta.1` → `4.0.0` | + +阶段依赖:P0 → P1 → P2 → (P3 ∥ P4) → P5 → P6 → P7。P3 和 P4 可并行。 + +## 11. 风险与待决问题 + +1. **SDK 稳定性承诺**。`ab_sdk` 一旦发布就要遵守 semver。建议 4.0 期间标为 `0.x`(beta),4.1 冻结 1.0。 +2. **性能**。每个种子都要经过多段 hook,RSS 一次可能有几百条。Filter hook 需要支持批量接口 `accept_many`,并加基准测试。 +3. **本地插件的依赖**。Docker 镜像中无法 pip 安装。是否提供 `config/plugins//vendor/` 预打包依赖?倾向于允许纯 Python vendor,不允许 C 扩展。 +4. **前端插件**。部分插件需要自定义 UI(例如手动选种界面),4.0 只给 JSON Schema 表单。是否在 4.x 引入 iframe 或 Web Component 方案,待定。 +5. **多下载器并存**。`slots.downloader` 是单选。是否允许按订阅选择下载器?会牵动 renamer 的「列出完成种子」逻辑。建议 4.0 预留数据结构,暂不实现。 +6. **`release_replacement_lease` 无调用方**。初步判断冗余:状态迁移会清租约,租约也有过期时间。P0 删除前,确认 claim 之后的提前退出路径只靠过期回收是否可以接受(最长占用一个租约周期)。 +7. **翻译**。事件 `describe()` 当前硬编码中文。插件化后要走 i18n key,否则第三方插件消息无法翻译。