mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-06 15:40:01 +08:00
fix(4.0-P4): hardlink 复制中断不再在媒体库留下半个文件
所有放置先写到同目录临时文件再 os.replace 到目标;否则重试时残留文件会被当成非本插件创建的冲突。文档补充 path_map 需在 config.json 填写与首次启用流程。
This commit is contained in:
@@ -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]):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` 通知。
|
||||
- 本插件之前为同一集创建的链接,在版本升级(新版本替换旧种子,规范文件名不变)后会被原子替换为指向新文件的链接。
|
||||
- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。
|
||||
|
||||
@@ -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 实现相同)挑选文件。
|
||||
|
||||
Reference in New Issue
Block a user