diff --git a/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py b/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py index 4b65c0d0..b460a859 100644 --- a/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py +++ b/backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py @@ -113,17 +113,23 @@ def _same(a: Path, b: Path) -> bool: def _make(src: Path, dst: Path, cross_device: str) -> tuple[Status, str]: + """先在同目录的临时文件上建立链接或副本,再原子地移到 ``dst``:复制中断 + (磁盘满、插件重载、进程退出)不会在目标位置留下半个文件,版本升级时也 + 不会出现目标暂时缺失的窗口。""" + tmp = dst.with_name(f".{dst.name}.ab-hardlink") + tmp.unlink(missing_ok=True) try: - os.link(src, dst) + os.link(src, tmp) except OSError as e: if e.errno != errno.EXDEV: raise if cross_device == "skip": return "failed", "与媒体库不在同一文件系统,已跳过" if cross_device == "symlink": - os.symlink(src, dst) + os.symlink(src, tmp) else: - shutil.copy2(src, dst) + shutil.copy2(src, tmp) + os.replace(tmp, dst) return "linked", "" @@ -133,20 +139,15 @@ def place( """在线程中执行的文件操作。``owned`` 是插件上次在 ``dst`` 放置文件时源文件 的身份,None 表示 ``dst`` 不是插件创建的。""" ident = _identity(src) - if not os.path.lexists(dst): + if os.path.lexists(dst): + if _same(src, dst) or owned == ident: + return "exists", "", ident + if owned is None: + return "conflict", "媒体库中已有同名文件且不是本插件创建的", ident + # 否则是版本升级:同一集换成了新文件,替换插件之前创建的链接 + else: dst.parent.mkdir(parents=True, exist_ok=True) - return (*_make(src, dst, cross_device), ident) - if _same(src, dst) or owned == ident: - return "exists", "", ident - if owned is None: - return "conflict", "媒体库中已有同名文件且不是本插件创建的", ident - # 版本升级:同一集换成了新文件。先在同目录生成临时文件,再原子替换 - tmp = dst.with_name(f".{dst.name}.ab-hardlink") - tmp.unlink(missing_ok=True) - status, reason = _make(src, tmp, cross_device) - if status == "linked": - os.replace(tmp, dst) - return status, reason, ident + return (*_make(src, dst, cross_device), ident) class HardlinkPlugin(Plugin[Options]): diff --git a/backend/src/test/test_hardlink.py b/backend/src/test/test_hardlink.py index ebe331b1..fd36039d 100644 --- a/backend/src/test/test_hardlink.py +++ b/backend/src/test/test_hardlink.py @@ -131,6 +131,29 @@ async def test_on_organized_redelivery_does_not_copy_again(roots, monkeypatch): assert ctx.bus.published == [] +async def test_on_organized_interrupted_copy_leaves_no_target(roots, monkeypatch): + downloads, library = roots + plugin, ctx = make_plugin(roots) + src = write(downloads / SEASON / "Anime S01E01.mkv", "v1") + monkeypatch.setattr(os, "link", exdev) + real_copy2 = shutil.copy2 + + def disk_full(a, b): + Path(b).write_text("v") + raise OSError(errno.ENOSPC, "No space left on device") + + monkeypatch.setattr(shutil, "copy2", disk_full) + await plugin.on_organized(organized(src.name)) + monkeypatch.setattr(shutil, "copy2", real_copy2) + # 半个文件不能留在目标位置,否则重试时会被当成「不是本插件创建的」冲突 + await plugin.on_organized(organized(src.name)) + + assert (library / SEASON / src.name).read_text() == "v1" + assert [p.name for p in (library / SEASON).iterdir()] == [src.name] + [failed] = ctx.bus.published + assert "No space left" in failed.files + + async def test_on_organized_revision_upgrade_replaces_own_link(roots): downloads, library = roots plugin, ctx = make_plugin(roots) diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 0da71118..cc803ad2 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -380,6 +380,9 @@ async def on_organized(self, event: TorrentOrganized): | `path_map` | `[{downloader, from, to}]`:把下载器路径前缀 `from` 换成 AB 本地路径 `to`。`downloader` 默认为 `default`;按最长前缀匹配,未匹配的路径原样使用 | | `cross_device` | 硬链接遇到跨文件系统(EXDEV)时:`copy`(默认,复制文件)、`symlink`(创建软链接)、`skip`(跳过并通知) | +- 首次启用时 `source_root` 与 `library_root` 尚未填写,插件会显示加载失败;此时设置表单已出现,填写并保存后插件会重新加载。 +- `path_map` 是对象列表,目前的插件设置表单还不能编辑它(显示为「不支持的字段」),请在 `config/config.json` 的 `plugins.options.hardlink.path_map` 中填写,例如 `[{"from": "/downloads", "to": "/media/downloads"}]`。 +- 链接或副本先写到目标旁的临时文件,再原子地改名到目标位置,复制中断不会留下半个文件。 - 媒体库中已有同名文件、但不是本插件创建的:跳过,不覆盖,并为该种子发一条 `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 ef356c4a..d78867e0 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 @@ -72,7 +72,10 @@ 内置插件 `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` 内;未填写时启用会加载失败,并在插件列表中显示原因。 +- `source_root`、`library_root` 为必填的绝对路径,`library_root` 不能位于 `source_root` 内。插件代码加载过一次后才有设置表单,所以首次启用必然加载失败(缺少必填项),用户随后在出现的表单中填写、保存,插件重新加载。 +- `path_map` 是对象列表,P2 的 JSON Schema 表单不支持对象数组(显示为「不支持的字段」),P4 期间只能在 `config.json` 中填写。表单支持对象数组留到 P6 / P7。 +- 所有放置(新建与版本升级替换)都先写到同目录的临时文件 `.<文件名>.ab-hardlink`,再 `os.replace` 到目标。复制中断不会在目标留下半个文件,否则重试时它会被当成「不是本插件创建的」冲突。 +- `hardlink.failed` 的通知正文只能显示种子 hash,因为 `TorrentOrganized` 不带种子名;补充种子名留到 P2.5 / P6。 - 「由插件创建」记录在插件 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 实现相同)挑选文件。