diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa9fc21..899e7a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ - **新增(媒体库刷新)**:新增内置插件「媒体库刷新」(`media-server-refresh`):配置 Jellyfin / Emby / Plex 地址与 API Key 后,合并一段时间内整理完成的种子,请求刷新媒体库;未配置时不做任何事 - **重构(整理流水线)**:`manager/renamer.py` 拆为编排(`renamer.py`)与 revision 替换事务(`revision_saga.py`);删除仅测试使用的 `Renamer.rename_file` / `_lookup_offsets`、无调用方的 `release_replacement_lease`、`match_by_save_path`、`search_by_qb_hash`,以及在重命名链路中传递却从未使用的 `season_offset` 参数 - **修复(插件配置)**:插件配置模型的校验器(`field_validator`)报错时,保存接口返回 500 而不是 422(错误详情中的异常对象无法序列化) -- **破坏性变更(配置,多下载器)**:下载器改为 `plugins.instances` 中的实例,`plugins.slots.downloader` 指向默认实例;重命名方式与版本冲突策略移到 `plugins.slots.rename_strategy` / `conflict_policy`(另有 `media_files`),插件提供的实现也可以通过 slots 选用。升级后第一次启动时,3.3 的 `downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy` 自动迁移,原文件备份为 `config.json.v3.bak`;迁移失败时从备份恢复配置并拒绝启动,日志写明出错的字段。`AB_DOWNLOADER_*`、`AB_DOWNLOAD_PATH`、`AB_METHOD`、`AB_REVISION_CONFLICT_POLICY` 环境变量照常生效。番剧、电影、RSS 订阅与种子新增 `downloader_id` 列(数据库迁移 v26),存量数据归属 `default`。`ab_sdk` 升至 0.5.0:`ConflictRequest` 删除 `configured`,宿主的冲突策略改为 `hold` / `replace` 两个 Provider +- **破坏性变更(配置,多下载器)**:下载器改为 `plugins.instances` 中的实例,`plugins.slots.downloader` 指向默认实例;重命名方式与版本冲突策略移到 `plugins.slots.rename_strategy` / `conflict_policy`(另有 `media_files`),插件提供的实现也可以通过 slots 选用。升级后第一次启动时,3.3 的 `downloader`、`bangumi_manage.rename_method`、`bangumi_manage.revision_conflict_policy` 自动迁移,原文件备份为 `config.json.v3.bak`;迁移失败时从备份恢复配置并拒绝启动,日志写明出错的字段。`AB_DOWNLOADER_*`、`AB_DOWNLOAD_PATH`、`AB_METHOD`、`AB_REVISION_CONFLICT_POLICY` 环境变量照常生效。番剧、电影、RSS 订阅与种子新增 `downloader_id` 列(数据库迁移 v26):存量种子归属 `default`,规则与订阅为空(跟随默认实例)。`ab_sdk` 升至 0.5.0:`ConflictRequest` 删除 `configured`,宿主的冲突策略改为 `hold` / `replace` 两个 Provider +- **新增(多下载器)**:设置 → 下载器设置 可以添加多个下载器实例(qBittorrent、aria2 或插件提供的下载器)并指定默认实例。规则与 RSS 订阅可以选择下载器,留空时用默认实例;由订阅新建的规则继承订阅的选择。每个种子记录它所在的下载器,重命名、删除都在该下载器上进行,规则改用另一个下载器后已有种子留在原处。重命名逐个下载器运行,连不上的下载器被跳过,并在第一次连不上时通知(`DownloaderUnavailableEvent` 新增 `instance_id`,按实例去重)。下载器页与种子列表标出种子所在的下载器;`GET /api/v1/downloader/torrents` 汇总所有实例,暂停 / 恢复 / 删除 / 打标接口新增 `downloader_id`,新增 `GET /api/v1/downloader/instances` - **破坏性变更(升级路径)**:只支持从 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/test/test_multi_downloader.py b/backend/src/test/test_multi_downloader.py index d5b128c2..ccc87e51 100644 --- a/backend/src/test/test_multi_downloader.py +++ b/backend/src/test/test_multi_downloader.py @@ -63,6 +63,36 @@ def _completed(name: str, torrent_hash: str, save_path: str) -> dict: } +def test_download_client_qb_and_aria2_side_by_side(monkeypatch): + """qB 与 aria2 实例并存:各自的后端、地址与版本替换事务命名空间。""" + from module.downloader.client.aria2_downloader import Aria2Downloader + from module.downloader.client.qb_downloader import QbDownloader + + plugins = Plugins( + instances=[ + PluginInstance( + id="qb", + point="downloader", + provider="qbittorrent", + options={"host": "qb:8080"}, + ), + PluginInstance( + id="ar", + point="downloader", + provider="aria2", + options={"host": "ar:6800"}, + ), + ], + slots=Slots(downloader="qb"), + ) + monkeypatch.setattr(settings, "plugins", plugins) + qb, ar = DownloadClient("qb"), DownloadClient("ar") + assert isinstance(qb.client, QbDownloader) + assert isinstance(ar.client, Aria2Downloader) + assert ar.client.host.endswith("ar:6800") + assert Renamer(qb)._downloader_type() != Renamer(ar)._downloader_type() + + class TestClientCache: def test_download_client_two_instances_separate_cached_clients(self, two_instances): a, b = _mock("a"), _mock("b") diff --git a/docs/config/downloader.md b/docs/config/downloader.md index e29cc42b..4222f7bc 100644 --- a/docs/config/downloader.md +++ b/docs/config/downloader.md @@ -14,6 +14,17 @@ 修改下载器设置后,需要点击底部的 **保存并重启**。保存后下载器连接会重新建立。 +## 多个下载器 + +设置页顶部列出所有下载器实例。点击一个实例即可在下方编辑它;输入新的 id 后点击 **添加** 新建实例,类型可以选 qBittorrent、aria2 或插件提供的下载器。**设为默认** 改变默认实例,默认实例不能删除。 + +- 规则编辑(高级选项)与添加订阅中可以选择下载器,留空跟随默认实例。只有一个下载器时不显示该选项。 +- 新种子投递到规则选择的下载器;规则未选择时用订阅选择的,再退回默认实例。由订阅新建的规则继承订阅的选择。 +- 每个种子记录它被添加到的下载器,之后的重命名、删除都在该下载器上进行。规则改用另一个下载器后,已有的种子留在原下载器的原位置,不会迁移。 +- 重命名按下载器逐个进行。某个下载器连不上时跳过它,并在它第一次连不上时发送「下载器连接异常」通知,其它下载器照常处理。 +- 下载器页与种子列表在有多个下载器时标出每个种子所在的下载器。 +- 删除下载器后,选择它的规则与订阅改用默认实例,其中已有的种子不受影响。 + ## 常见问题 ### 下载器地址 @@ -43,7 +54,7 @@ AB 中的下载路径用于生成保存位置和后续整理路径。请填写** ## `config.json` 配置选项 -下载器是 `plugins.instances` 中 `point` 为 `downloader` 的实例,`plugins.slots.downloader` 是默认实例的 id(默认为 `default`)。设置页编辑的是默认实例: +下载器是 `plugins.instances` 中 `point` 为 `downloader` 的实例,`plugins.slots.downloader` 是默认实例的 id(默认为 `default`)。在设置页新建实例时,id 只能包含字母、数字、`_` 与 `-`: ```json "plugins": { diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index e15c4130..e1f92105 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -356,7 +356,7 @@ class MyRename(Plugin): | `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。 +- `bangumi_id` 来自种子的 `ab:` 标签,旧种子可能为 None。`downloader_id` 是种子所在下载器实例的 id(`plugins.instances`),多个实例的路径视角可能不同。 - `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次,订阅者必须幂等。 - 这两个事件只发布到事件总线,不进入通知中心。 @@ -467,7 +467,7 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent` | `rss_failure` | `RssFailureEvent` | RSS 订阅从正常变为连接异常 | | `download_failure` | `DownloadFailureEvent` | 种子重试后仍添加失败 | | `offset_review` | `OffsetReviewEvent` | 番剧的季度 / 集数偏移需要人工确认 | -| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封 | +| `downloader_unavailable` | `DownloaderUnavailableEvent` | 下载器连不上、凭据错误或 IP 被封;`instance_id` 为下载器实例 id,每个实例从可用变为不可用时发布一次 | | `update_available` | `UpdateAvailableEvent` | 检查到新版本 | | `update_applied` / `update_failed` | `UpdateAppliedEvent` | 在线更新成功 / 失败(`kind` 随 `success` 变化) | | `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 | diff --git a/docs/en/config/downloader.md b/docs/en/config/downloader.md index cb37a1b6..25fb3d39 100644 --- a/docs/en/config/downloader.md +++ b/docs/en/config/downloader.md @@ -14,6 +14,17 @@ After changing downloader settings, click **Save & restart** so the downloader client is recreated. +## Multiple downloaders + +The top of the settings section lists every downloader instance. Click an instance to edit it below. Type a new id and click **Add** to create an instance; its type can be qBittorrent, aria2 or a downloader from a plugin. **Set default** changes the default instance. You cannot delete the default instance. + +- The rule editor (advanced options) and the add-subscription dialog have a downloader field. Leave it empty to use the default instance. The field is hidden when there is only one downloader. +- A new torrent goes to the downloader of its rule. If the rule has none, it goes to the downloader of the subscription, then to the default instance. A rule created from a subscription takes the downloader of the subscription. +- Each torrent records the downloader it was added to. Rename and delete use that downloader. When a rule moves to another downloader, its existing torrents stay where they are. +- Rename runs once per downloader. An unreachable downloader is skipped, and a "Downloader unavailable" notification is sent the first time it becomes unreachable. The other downloaders continue. +- With more than one downloader, the downloader page and the torrent lists show the downloader of each torrent. +- After you delete a downloader, rules and subscriptions that used it use the default instance. Torrents already in the deleted downloader are not touched. + ## Downloader Address ::: warning @@ -35,7 +46,7 @@ Use the path from the downloader's point of view: ## `config.json` -A downloader is an entry in `plugins.instances` whose `point` is `downloader`. `plugins.slots.downloader` is the id of the default instance (`default` by default). The settings page edits the default instance: +A downloader is an entry in `plugins.instances` whose `point` is `downloader`. `plugins.slots.downloader` is the id of the default instance (`default` by default). When you add an instance on the settings page, its id can contain only letters, digits, `_` and `-`: ```json "plugins": { diff --git a/docs/ja/config/downloader.md b/docs/ja/config/downloader.md index 3627af38..bacc91db 100644 --- a/docs/ja/config/downloader.md +++ b/docs/ja/config/downloader.md @@ -14,6 +14,17 @@ 変更後は **保存して再起動** をクリックしてください。 +## 複数のダウンローダー + +設定セクションの上部にすべてのダウンローダーインスタンスが表示されます。インスタンスをクリックすると下で編集できます。新しい id を入力して **追加** をクリックするとインスタンスを作成できます。種類は qBittorrent、aria2、またはプラグインのダウンローダーです。**既定に設定** で既定インスタンスを変更します。既定インスタンスは削除できません。 + +- ルール編集(詳細設定)と購読の追加でダウンローダーを選べます。空欄なら既定インスタンスを使います。ダウンローダーが 1 つだけのときは表示されません。 +- 新しいトレントはルールのダウンローダーに追加されます。ルールに指定がなければ購読のダウンローダー、それもなければ既定インスタンスです。購読から作成されたルールは購読の選択を引き継ぎます。 +- 各トレントは追加先のダウンローダーを記録し、リネームと削除はそのダウンローダーで行います。ルールを別のダウンローダーに変えても、既存のトレントは元の場所に残ります。 +- リネームはダウンローダーごとに実行されます。接続できないダウンローダーはスキップされ、初めて接続できなくなったときに「ダウンローダー接続エラー」が通知されます。他のダウンローダーは通常どおり処理されます。 +- ダウンローダーが複数あるとき、ダウンローダーページとトレント一覧に各トレントのダウンローダーが表示されます。 +- ダウンローダーを削除すると、それを選んでいたルールと購読は既定インスタンスを使います。削除したダウンローダー内の既存のトレントには影響しません。 + ## アドレスの注意 ::: warning @@ -27,7 +38,7 @@ Docker Bridgeモードでは、ダウンローダーとAutoBangumiが同じネ ## `config.json` -ダウンローダーは `plugins.instances` のうち `point` が `downloader` のインスタンスです。`plugins.slots.downloader` は既定インスタンスの id(既定値 `default`)です。設定画面は既定インスタンスを編集します。 +ダウンローダーは `plugins.instances` のうち `point` が `downloader` のインスタンスです。`plugins.slots.downloader` は既定インスタンスの id(既定値 `default`)です。設定画面でインスタンスを追加するとき、id に使えるのは英数字、`_`、`-` だけです。 ```json "plugins": { 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 daec79fc..c6982876 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 @@ -123,10 +123,33 @@ slots 解析: 数据库与 WebUI: -- 迁移 v26 为 `bangumi`、`movie`、`rssitem`、`torrent` 增加 `downloader_id VARCHAR DEFAULT 'default'`,存量行由 SQLite 按默认值填充。每张表一个守卫:列已存在,或表不存在(之后由 `create_all` 按模型建表,自带该列)时跳过。四个表模型的 `downloader_id` 默认为 `"default"`;`BangumiUpdate` / `MovieUpdate` 尚未加入该字段。 +- 迁移 v26 为 `bangumi`、`movie`、`rssitem`、`torrent` 增加 `downloader_id`。每张表一个守卫:列已存在,或表不存在(之后由 `create_all` 按模型建表,自带该列)时跳过。取值见下文第二部分(规则、订阅与电影为空表示跟随默认实例,种子默认 `default`)。 - WebUI:「下载器设置」编辑 `slots.downloader` 所指的实例(`provider` 与 `options`),「番剧管理设置」中的重命名方式与版本冲突策略绑定到 `plugins.slots`。两个分区的未保存标记都以 `plugins` 配置段判断,所以修改其中一个,两个分区都会显示未保存。插件卡片保存后 `refreshGroup('plugins')` 会用服务端的值覆盖整个 `plugins` 段,包括尚未保存的下载器与 slots 修改(之前只影响插件 options)。 - 用户文档 `docs/{,en/,ja/}config/{downloader,manager}.md` 与插件开发文档改为新的配置位置。 +第二部分(路由、整理与 WebUI): + +- **`DownloadClient(instance_id=None)`**,`None` 为默认实例。客户端缓存与凭据闩锁按实例 id 分开;引用计数本来按具体客户端对象计,不变。第 3.5 节的统一工厂 `create(config: PluginConfig)` 未实施:工厂仍接收 P2 契约的 `DownloaderConnection`,实例 `options` 目前就是 `DownloaderOptions` 的字段。 +- **`DownloaderPool`**:一次操作(一轮 RSS、整季补全)内按实例 id 惰性进入 `DownloadClient`,退出时全部释放。进入失败的实例在这次操作内记住,之后直接抛 `ConnectionError`,不再重复登录。`RSSEngine.refresh_rss` 的参数由 `DownloadClient` 改为 `DownloaderPool`,测试改用 `test.factories.SingleClientPool`(27 处调用)。 +- **实例选择**:`resolve_downloader_id(*候选)` 取第一个已配置的实例 id,都为空时用 `slots.downloader`。指向已删除实例的候选记 warning 后跳过,因此删除实例后,选择它的规则与订阅改用默认实例。新种子按 规则 → 订阅 → 默认 选择;由订阅新建的规则在解析时继承订阅的 `downloader_id`(`rss/analyser.py`);手动收集与整季补全按 规则 → 默认。 +- **规则、订阅与电影的 `downloader_id` 为空表示跟随默认实例**。第一部分的 v26 给这三张表 `DEFAULT 'default'`,这样「规则 → 订阅」的继承永远走不到订阅,用户改默认实例后存量规则也仍钉在 `default`,与第 3.5 节「为空时用默认实例」矛盾。v26 尚未发布,所以直接修改它:这三张表加列不带默认值(存量为 `NULL`),`torrent` 仍为 `DEFAULT 'default'`(存量种子都在 3.3 的下载器中)。已经运行过第一部分 v26 的开发库需要手动把这三列置空。`BangumiUpdate`、`MovieUpdate`、`RSSUpdate` 加入该字段,`POST /rss/add` 保存它。 +- **种子行的 `downloader_id`** 由 `DownloadClient.add_torrent` 写入投递的实例;未匹配的孤儿种子保持 `default`。投递时实例不可用:种子不入库、下一轮重试,不发 `DownloadFailureEvent`(不可用由重命名轮次通知)。 +- **重命名逐实例运行**:`manager/renamer.py` 的 `rename_all()` 供 `loops.rename_tick` 与 apply-offset 使用。进入失败(连不上、凭据被拒、Provider 未登记)的实例跳过,其它实例照常处理;`DownloaderUnavailableEvent` 只在「可用 → 不可用」时产生一次(进程内集合),恢复后再次不可用会再通知。`Renamer` 自身抛出的异常仍中断本轮,与之前相同。apply-offset 触发的重命名丢弃事件(之前也丢弃);实例恰好在这一次变为不可用时,这次不可用不会通知。启动等待循环仍只检查默认实例。 +- **`DownloaderUnavailableEvent`** 增加 `instance_id`(默认 `"default"`),`dedup_key` 由 `downloader:` 改为 `downloader:`,payload 增加 `instance`。`ab_sdk` 仍为 0.5.0(本阶段未发布)。 +- **版本替换事务按实例过滤**:`list_active_replacements(downloader_type)` 只返回本实例的事务。否则实例 A 的轮次在 A 上查不到 B 的新种子,会进入破坏性的 `_recover_missing_replacement`。`_downloader_type()` 改读 `self.client.instance`。 +- **offset 查找**:同一 hash 有多条种子行时以本实例的行为准,其它实例的行作为后备。`path_to_bangumi` / `gen_save_path` 增加 `root`(实例的下载目录),重命名与新规则保存目录按实例计算。 +- `FileRenamed` / `TorrentOrganized` 带真实的 `downloader_id`;`hardlink` 的 `path_map` 本来就按它取映射,无需改动。 +- **规则换实例**:删除规则(删除文件)时按种子行记录的实例加上规则当前的实例逐个删除。更新规则时实例变了:旧种子不移动,原实例上的 qB RSS 规则不改,只按新实例的下载目录重算 `save_path`;实例不变且路径变化时才移动(路径不变时不再连接下载器)。 +- **API**:`GET /downloader/torrents` 汇总所有实例、每条带 `downloader_id`,不可用实例跳过,全部不可用时返回 503(原先连接异常直接 500)。暂停 / 恢复 / 删除 / 打标请求带 `downloader_id`(空为默认实例),未知 id 返回 404;自动打标逐实例进行。新增 `GET /downloader/instances`(`id`、`provider` 与默认 id),供规则 / 订阅选择下载器。SSE 的 downloader 帧同样汇总,超时按实例计算。MCP `list_downloads` 汇总所有实例并带 `downloader` 字段,全部不可用时返回空列表(原先抛错)。 +- **WebUI**:「下载器设置」列出所有实例(点击编辑、添加、删除、设为默认,默认实例不能删除;新 id 只接受字母、数字、`_`、`-`,这是前端限制)。规则编辑的高级选项与添加订阅新增下载器选择,留空为默认实例,只有一个实例时不显示;实例列表每次打开时请求,设置页增删实例后无需刷新。下载器页在多实例时加「下载器」列,规则 / 孤儿种子列表在已下载的种子上标出实例。批量操作仍按 hash 选择,按所在实例分组请求;同一 hash 同时在两个实例中时会作用于两个实例。 +- **`refreshGroup` 只刷新给定字段**:插件卡片保存后只刷新 `plugins.enabled` / `options`,不再覆盖未保存的实例与 slots 修改(第一部分记录的问题)。「下载器设置」与「番剧管理设置」仍共用 `plugins` 未保存标记。 + +未实施(推迟): + +- 第 10 节验收中的 qb + aria2 并存 **Docker e2e** 未加。以进程内测试替代:两个 mock 实例并列(按规则 / 订阅投递、在另一实例重命名、一个实例不可用、规则换实例后删除),以及 qB 与 aria2 实例各自得到对应后端与不同的 `downloader_type`。Docker 版需要在 `e2e/compose/downloader.yml` 加入固定 digest 的 aria2 镜像。 +- aria2 的 gid ↔ 番剧映射表(`database/aria2.py`)不区分实例;两个 aria2 实例的 gid 相同的概率很低,未处理。 +- `GET /api/v1/plugins/providers` 仍不列出 `conflict_policy` / `media_files` 的插件候选;插件下载器的 options schema 与按 schema 掩码(见上文)。 + ## 1. 背景与目标 AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。 @@ -673,7 +696,7 @@ organize: downloader.completed → media_files.classify → file_parser | **P0 清理** | 第 8 节:死代码、3.x 兼容层;`renamer.py` 先做纯搬移式拆分(不改行为) | 生产代码行数减少;vulture CI;测试全绿 | | **P1 插件运行时 + SDK 骨架** | `ab_sdk` 包(含 `ab_sdk.testing`)、`module/plugin/`(清单、加载、注册表、runner、熔断、EventBus、插件 KV)、`plugins` 配置段、`GET /api/v1/plugins`;SDK 边界测试 | 本地插件可加载、配置、随配置变更重载;已完成。签名目录来源与 LLM 安装器泛化、`dev_mode` 文件监听移到 P2 | | **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 | -| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 | +| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | 已完成:`plugins.instances` / `slots` 与 3.3 配置迁移器(`update/v4.py`);按实例缓存客户端与路由投递、重命名、删除;重命名逐实例运行,不可用实例跳过并通知;WebUI 多实例管理与规则 / 订阅的下载器选择;单实例行为不变(全量测试)。qb + aria2 Docker e2e 未加,以进程内并存测试替代,调整见第 0 节 | | **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` 等事件 | 已完成:`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 节 |