fix(4.0): 已打 ab:renamed 的种子在本进程未发布过时补发 torrent.organized

标签在发布之前打上,之后的轮次不再检查该种子:订阅者处理中被取消
(重启、插件重载)的事件永远不会再投递,硬链接漏掉这一集。现在每个
进程按当前文件名为已打标签的种子补发一次,丢失的事件在下次启动时
重新投递,投递恢复为至少一次;没有事件总线时不查询文件。
test_renamed_tag_skips_before_file_and_offset_queries 改为在已发布的
稳态下断言。文档与 TorrentOrganized 说明同步更新。
This commit is contained in:
Estrella Pan
2026-10-08 16:42:35 +02:00
parent 93520e5b03
commit 045bdbd407
15 changed files with 81 additions and 20 deletions

View File

@@ -406,9 +406,9 @@ class OrganizedFile:
class TorrentOrganized(Event):
"""一个种子整理完成:顶层正片都已在最终位置(重命名方式为 none 时即原位置)。
``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:未打
``ab:renamed`` 标签的种子(如重命名方式为 none)每次进程重启后会再发布一次,
订阅者须保证幂等。
``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:下载器中已整理
的种子(含已打 ``ab:renamed`` 标签的)每次进程重启后会再发布一次,订阅者
须保证幂等。选中的重命名方式未登记时不发布:原名不是最终文件名。
"""
kind: ClassVar[str] = "torrent.organized"

View File

@@ -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, 原因)
@@ -638,6 +639,21 @@ class Renamer(RevisionSaga):
)
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]
) -> dict[str, tuple[int, str]]:
@@ -802,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")

View File

@@ -3,8 +3,8 @@
收到第一个事件后等待 ``delay`` 秒,期间整理完成的所有种子合并成一次刷新请求。
未填写服务器地址或 API Key 时什么也不做,所以默认启用也无副作用。
``torrent.organized`` 投递为「至少一次」:重命名方式为 none 的种子每次进程
重启后会再发布一次,已配置时重启后最多多出一次(合并后的)刷新。
``torrent.organized`` 投递为「至少一次」:下载器中已整理的种子每次进程重启后
会再发布一次,已配置时重启后最多多出一次(合并后的)刷新。
"""
import asyncio

View File

@@ -195,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")

View File

@@ -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() == []

View File

@@ -82,7 +82,7 @@
- 版本升级(新版本种子替换旧种子,文件名不变)后,本插件之前创建的链接会被原子替换为指向新文件的链接。
- 删除种子不会删除媒体库中的链接。
- 本插件放置后被你从媒体库删除的文件不会被重新链接;补链会重建它们。
- 启用前已经下载的文件不会自动处理,需要按需补链:调用 `POST /api/v1/plugins/hardlink/backfill`(设置页按钮将在后续版本提供)。
- 启用前已经下载的文件:仍在下载器中的已整理种子在 AB 下次重启后被链接,其它文件需要按需补链:调用 `POST /api/v1/plugins/hardlink/backfill`(设置页按钮将在后续版本提供)。
- `path_map` 目前的设置表单还不能编辑,请在 `config/config.json` 的 `plugins.options.hardlink.path_map` 中填写,例如 `[{"from": "/downloads", "to": "/media/downloads"}]`。
::: tip Docker

View File

@@ -47,7 +47,7 @@
- 链接或副本先写到目标旁的临时文件,再原子地改名,复制中断不会留下半个文件。
- 媒体库中已有同名文件、但不是本插件创建的:跳过,不覆盖,并发一条 `hardlink.failed` 通知。
- 本插件之前为同一集创建的链接,在版本升级(新版本替换旧种子,规范文件名不变)后被原子替换为指向新文件的链接。
- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。
- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。插件放置后被用户删除的文件,再次收到事件时也不重建,只有补链会重建。
- **删除种子不会删除媒体库中的链接。**
- 启用前已经下载的文件不会自动处理。在插件的设置分区点「补链已有文件」,或调用 `POST /api/v1/plugins/hardlink/backfill`:遍历 `source_root` 下的 `.mp4` / `.mkv` / `.ass` / `.srt`,返回 `{"linked", "exists", "conflict", "failed"}` 计数。

View File

@@ -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:<id>` 标签,旧种子可能为 `None`。`downloader_id` 是种子所在下载器实例的 id;多个实例的路径视角可能不同。
- `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次。订阅者必须幂等。
- `torrent.organized` 的投递是**至少一次**:下载器中已整理的种子(包括已打「已重命名」标签的)在每次 AB 重启后会再发布一次。订阅者必须幂等。
- 这两个事件只发布到事件总线,不进入通知中心。
## 自定义事件

View File

@@ -66,7 +66,7 @@ The built-in `hardlink` plugin is **disabled by default**. Enable it under **Set
- 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. To link them, call `POST /api/v1/plugins/hardlink/backfill` (a settings button follows in a later release).
- Files downloaded before you enabled the plugin: the plugin links the organized torrents that are still in the downloader after the next AB restart. To link other files, call `POST /api/v1/plugins/hardlink/backfill` (a settings button follows in a later release).
- The settings form cannot edit `path_map` yet. Set it in `config/config.json` under `plugins.options.hardlink.path_map`, for example `[{"from": "/downloads", "to": "/media/downloads"}]`.
::: tip Docker

View File

@@ -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"}`.

View File

@@ -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:<id>` 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

View File

@@ -64,7 +64,7 @@
- リビジョン更新(新しいリリースが同じファイル名で古い Torrent を置き換える)の後、プラグインが以前作成したリンクは新しいファイルへのリンクにアトミックに置き換えられます。
- Torrent を削除しても、ライブラリのリンクは削除されません。
- プラグインが配置した後にライブラリから削除したファイルは、再びリンクされません。バックフィルを実行すると再作成されます。
- 有効化する前にダウンロード済みのファイルは自動ではリンクされません。`POST /api/v1/plugins/hardlink/backfill` を呼び出すとリンクします(設定画面のボタンは今後のリリースで提供予定)。
- 有効化する前にダウンロード済みのファイル:ダウンローダーに残っている整理済みトレントは、次に AB を再起動したときにリンクされます。それ以外のファイルは `POST /api/v1/plugins/hardlink/backfill` を呼び出すとリンクします(設定画面のボタンは今後のリリースで提供予定)。
- `path_map` は設定フォームでまだ編集できません。`config/config.json` の `plugins.options.hardlink.path_map` に記入してください(例:`[{"from": "/downloads", "to": "/media/downloads"}]`)。
::: tip Docker

View File

@@ -47,7 +47,7 @@
- リンクまたはコピーは、まず保存先の隣の一時ファイルに作り、その後アトミックにリネームします。コピーが中断しても、中途半端なファイルは残りません。
- ライブラリに同名のファイルがあり、このプラグインが作ったものでない場合は、スキップして上書きせず、`hardlink.failed` 通知を送ります。
- このプラグインが同じ話のために作ったリンクは、バージョンアップ後(新バージョンが古いトレントを置換し、正規のファイル名が同じ場合)にアトミックに置き換えられ、新しいファイルを指します。
- 既にリンク済みのファイルのイベントを受け取っても、何もしません。作成したパスはプラグイン自身のキーバリューストアに記録されます。
- 既にリンク済みのファイルのイベントを受け取っても、何もしません。作成したパスはプラグイン自身のキーバリューストアに記録されます。プラグインが配置した後にユーザーが削除したファイルは、イベントを再び受け取っても再作成されません。バックフィルだけが再作成します。
- **トレントを削除しても、ライブラリのリンクは削除されません。**
- プラグインを有効にする前にダウンロードしたファイルは自動では処理されません。プラグインの設定セクションで「既存ファイルをリンク」をクリックするか、`POST /api/v1/plugins/hardlink/backfill` を呼びます。`source_root` 以下の `.mp4` / `.mkv` / `.ass` / `.srt` を走査し、`{"linked", "exists", "conflict", "failed"}` の件数を返します。

View File

@@ -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:<id>` タグに由来します。古いトレントでは `None` の場合があります。`downloader_id` はトレントがあるダウンローダーインスタンスの id です。インスタンスごとにパスの見え方が異なることがあります。
- `torrent.organized` の配信は**少なくとも 1 回**(at-least-once)です。「リネーム済み」タグのないトレント(リネーム方式が `none` の場合など)は、AB を再起動するたびにもう一度発行されます。購読者は冪等にしてください。
- `torrent.organized` の配信は**少なくとも 1 回**(at-least-once)です。ダウンローダーにある整理済みのトレントは、「リネーム済み」タグがあっても、AB を再起動するたびにもう一度発行されます。購読者は冪等にしてください。
- この 2 つのイベントはイベントバスにだけ発行されます。通知センターには入りません。
## 独自イベント

View File

@@ -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` 的种子同样发布;选中的策略未登记而按 `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`。