From cf8135874da0ff322e88072cfe2a8c2b931ffd20 Mon Sep 17 00:00:00 2001 From: Estrella Pan Date: Thu, 8 Oct 2026 10:23:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(4.0-P4):=20ab=5Fsdk=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20organize=20=E5=A5=91=E7=BA=A6=E4=B8=8E=E6=95=B4=E7=90=86?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ab_sdk.rename:RenameStrategy / RenameInput(kind 区分正片与字幕)/ RenameSkipped、 MediaFiles、ConflictPolicy 及其冻结快照,pad 补零格式化;points 增加 rename_strategy / media_files / conflict_policy;ab_sdk.events 增加 FileRenamed、 TorrentOrganized 与 RenameSkippedEvent。SDK 升至 0.4.0。 --- backend/src/ab_sdk/__init__.py | 2 +- backend/src/ab_sdk/events.py | 78 ++++++++++++++++ backend/src/ab_sdk/points.py | 17 ++++ backend/src/ab_sdk/rename.py | 133 ++++++++++++++++++++++++++++ backend/src/test/test_plugin_sdk.py | 19 ++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 backend/src/ab_sdk/rename.py diff --git a/backend/src/ab_sdk/__init__.py b/backend/src/ab_sdk/__init__.py index b586c576..e1dc0171 100644 --- a/backend/src/ab_sdk/__init__.py +++ b/backend/src/ab_sdk/__init__.py @@ -12,7 +12,7 @@ from .events import Event, PluginDisabled, PluginLoaded from .hooks import Verdict, hook, provider, subscribe from .plugin import Plugin -SDK_VERSION = "0.3.0" +SDK_VERSION = "0.4.0" __all__ = [ "SDK_VERSION", diff --git a/backend/src/ab_sdk/events.py b/backend/src/ab_sdk/events.py index 2576ed99..2faeb769 100644 --- a/backend/src/ab_sdk/events.py +++ b/backend/src/ab_sdk/events.py @@ -7,6 +7,8 @@ from dataclasses import dataclass from typing import ClassVar +from .rename import FileKind + @dataclass(frozen=True, slots=True) class Event: @@ -334,3 +336,79 @@ class RenameConflictEvent(SystemEvent): f"种子:{self.torrent_name}\n目标:{self.target_path}\n" f"原因:{self.reason}", ) + + +@dataclass(frozen=True, slots=True) +class RenameSkippedEvent(SystemEvent): + """重命名策略无法为种子中的文件给出名字(如模板渲染失败),文件保留原名。 + + 同一种子、同一原因每个进程只通知一次;修正配置后下一轮会自动重试。 + """ + + kind: ClassVar[str] = "rename_skipped" + severity: ClassVar[str] = "warning" + once: ClassVar[bool] = False + + task_id: str + torrent_name: str + strategy: str + reason: str + + def dedup_key(self) -> str | None: + return f"rename_skipped:{self.task_id}" + + def payload(self) -> dict: + return { + "task_id": self.task_id, + "torrent_name": self.torrent_name, + "strategy": self.strategy, + "reason": self.reason, + } + + def describe(self) -> tuple[str, str]: + return ( + "文件未重命名", + f"种子:{self.torrent_name}\n重命名方式:{self.strategy}\n" + f"原因:{self.reason}\n文件已保留原名,修正后会自动重试。", + ) + + +# --- P4:organize 流水线事件(只发布到事件总线,不进通知中心) ------------------- +# +# 路径均为下载器视角的绝对路径:保存目录与种子内相对路径以 "/" 拼接 +# (Windows 下载器的 "\" 也统一为 "/")。AutoBangumi 与下载器不在同一文件系统 +# 视图时(容器挂载不同),订阅者需要自行做路径映射。 + + +@dataclass(frozen=True, slots=True) +class FileRenamed(Event): + """下载器中的一个文件被重命名到规范名(正片或字幕)。""" + + kind: ClassVar[str] = "file.renamed" + bangumi_id: int | None # 来自种子的 ab: 标签;旧种子可能没有 + old_path: str + new_path: str + file_kind: FileKind + downloader_id: str = "default" + + +@dataclass(frozen=True, slots=True) +class OrganizedFile: + path: str + kind: FileKind + + +@dataclass(frozen=True, slots=True) +class TorrentOrganized(Event): + """一个种子整理完成:顶层正片都已在最终位置(重命名方式为 none 时即原位置)。 + + ``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:未打 + ``ab:renamed`` 标签的种子(如重命名方式为 none)每次进程重启后会再发布一次, + 订阅者须保证幂等。 + """ + + kind: ClassVar[str] = "torrent.organized" + torrent_hash: str + bangumi_id: int | None + files: tuple[OrganizedFile, ...] + downloader_id: str = "default" diff --git a/backend/src/ab_sdk/points.py b/backend/src/ab_sdk/points.py index 04552cee..805746e1 100644 --- a/backend/src/ab_sdk/points.py +++ b/backend/src/ab_sdk/points.py @@ -54,3 +54,20 @@ API_ROUTER = "api_router" MCP_TOOL = "mcp_tool" MCP_RESOURCE = "mcp_resource" MESSAGE_TEMPLATE = "message_template" + +# --- P4 organize(契约见 :mod:`ab_sdk.rename`) --- +# +# Provider 扩展点: +# +# - ``RENAME_STRATEGY``:返回 :class:`ab_sdk.rename.RenameStrategy`,id 即 +# ``bangumi_manage.rename_method`` 的取值。宿主自带 ``none``(不改名), +# 内置插件 ``rename`` 提供 ``pn``、``advance``、``template`` +# - ``MEDIA_FILES``:返回 :class:`ab_sdk.rename.MediaFiles` +# - ``CONFLICT_POLICY``:返回 :class:`ab_sdk.rename.ConflictPolicy` +# +# 事件(``@subscribe``):``file.renamed``(:class:`ab_sdk.events.FileRenamed`)、 +# ``torrent.organized``(:class:`ab_sdk.events.TorrentOrganized`) + +RENAME_STRATEGY = "rename_strategy" +MEDIA_FILES = "media_files" +CONFLICT_POLICY = "conflict_policy" diff --git a/backend/src/ab_sdk/rename.py b/backend/src/ab_sdk/rename.py new file mode 100644 index 00000000..c842dfa9 --- /dev/null +++ b/backend/src/ab_sdk/rename.py @@ -0,0 +1,133 @@ +"""organize 流水线(下载完成 → 分类 → 重命名 → 后处理)的插件契约。 + +扩展点一览(常量见 :mod:`ab_sdk.points`): + +- ``rename_strategy``(Provider):返回 :class:`RenameStrategy`,id 即设置项 + ``bangumi_manage.rename_method`` 的取值 +- ``media_files``(Provider):返回 :class:`MediaFiles`,判断种子内文件的类别 +- ``conflict_policy``(Provider):返回 :class:`ConflictPolicy`,目标路径已被 + 另一个种子占用时决定保留还是替换 + +``media_files`` 与 ``conflict_policy`` 目前只使用宿主自带的实现(id +:data:`CORE_ID`),插件实现的选择随 P2.5 的 ``plugins.slots`` 一起提供。 + +整理完成后宿主发布 :class:`ab_sdk.events.FileRenamed` 与 +:class:`ab_sdk.events.TorrentOrganized`,插件用 ``@subscribe`` 接收。 +""" + +from dataclasses import dataclass +from typing import Literal, Protocol + +FileKind = Literal["media", "subtitle"] +MediaKind = Literal["media", "subtitle", "ignore"] +ConflictAction = Literal["hold", "replace"] + +# media_files / conflict_policy 的宿主实现 id +CORE_ID = "default" + + +def pad(value: int | float, width: int = 2) -> str: + """把季号 / 集数的整数部分补零到 ``width`` 位,小数部分原样保留。 + + ``pad(5) == "05"``、``pad(12) == "12"``、``pad(9.5) == "09.5"``、 + ``pad(12.0) == "12"``。总集篇等半集(12.5)必须保留小数,否则会覆盖同季的 + 整数集 (#667)。内置 pn / advance 与模板的 ``pad`` 过滤器都用它。 + """ + if isinstance(value, float) and value.is_integer(): + value = int(value) + whole, dot, fraction = str(value).partition(".") + return whole.zfill(width) + dot + fraction + + +@dataclass(frozen=True, slots=True) +class RenameInput: + """一个待重命名文件。 + + ``episode`` 已应用番剧的集数偏移;``season`` 来自保存目录(已含季度偏移)。 + ``title`` 解析自文件名,``bangumi_name`` 是保存目录的番剧文件夹名(电影为 + ``Title (Year)``)。两者都是单个路径分量,宿主不做保留字符清洗。 + """ + + kind: FileKind + media_path: str # 种子内的原相对路径 + title: str + bangumi_name: str + season: int + episode: int | float + suffix: str # 含点,如 ".mkv" + episode_type: str = "episode" # episode | movie | special + language: str = "" # 字幕语言(kind == "subtitle"),如 "zh"、"zh-tw" + group: str | None = None + + +class RenameSkipped(Exception): + """策略无法为该文件给出名字时抛出:宿主保留原文件名,并为该种子发一条通知。 + + 与其它异常不同,它表示「输入或用户配置有问题」,不计入插件熔断。 + """ + + +class RenameStrategy(Protocol): + def target_name(self, f: RenameInput) -> str: + """返回种子内的新相对路径(通常只是文件名,即把文件移到种子根目录)。 + + 返回原路径 ``f.media_path`` 表示不改名。须为非空字符串,否则按插件 + 失败处理(计入熔断)并保留原文件名。 + """ + ... + + +class MediaFiles(Protocol): + def classify(self, path: str) -> MediaKind: + """种子内文件的类别:``media`` 重命名为正片,``subtitle`` 随正片重命名, + ``ignore`` 不处理。""" + ... + + +@dataclass(frozen=True, slots=True) +class Revision: + """严格解析出的发布身份;只有同一身份、版本号更高时才算版本升级。""" + + bangumi_id: int + media_type: str + season: int + episode: int | float + group: str + resolution: str + revision: int + + +@dataclass(frozen=True, slots=True) +class RevisionTask: + """下载器中的一个种子任务。""" + + hash: str + name: str + file_count: int + revision: Revision | None # 标题解析不完整时为 None + + +@dataclass(frozen=True, slots=True) +class ConflictRequest: + """新种子(``incoming``)的规范目标路径已被其它任务(``owners``)占用。""" + + target_path: str + incoming: RevisionTask + owners: tuple[RevisionTask, ...] + # 用户在设置中选择的版本冲突策略(bangumi_manage.revision_conflict_policy) + configured: ConflictAction + # 唯一占用者与新种子是同一发布、且新种子版本号更高 + strict_upgrade: bool + + +@dataclass(frozen=True, slots=True) +class ConflictDecision: + action: ConflictAction + reason: str = "" # hold 时展示给用户的原因 + + +class ConflictPolicy(Protocol): + def decide(self, request: ConflictRequest) -> ConflictDecision: + """``replace`` 会删除旧任务及其文件。宿主只在「唯一占用者、双方都是 + 单文件种子、双方身份完整」时执行替换,否则一律按 ``hold`` 处理。""" + ... diff --git a/backend/src/test/test_plugin_sdk.py b/backend/src/test/test_plugin_sdk.py index fdc8cfcf..446b06c7 100644 --- a/backend/src/test/test_plugin_sdk.py +++ b/backend/src/test/test_plugin_sdk.py @@ -3,10 +3,12 @@ import ast from pathlib import Path +import pytest from pydantic import BaseModel import ab_sdk from ab_sdk import Plugin, Verdict, hook, subscribe +from ab_sdk.rename import pad from ab_sdk.testing import create_plugin SDK_ROOT = Path(ab_sdk.__file__).parent @@ -58,3 +60,20 @@ async def test_create_plugin_validates_options_and_drives_plugin(tmp_path): ctx.bus.subscribe("*", plugin.remember) await ctx.bus.deliver(ab_sdk.PluginLoaded(plugin_id="x", version="1")) assert await ctx.kv.get("last") == "plugin.loaded" + + +@pytest.mark.parametrize( + ("value", "width", "expected"), + [ + (5, 2, "05"), + (0, 2, "00"), + (12, 2, "12"), + (123, 2, "123"), + (12.0, 2, "12"), + (9.5, 2, "09.5"), + (12.5, 2, "12.5"), + (7, 3, "007"), + ], +) +def test_pad_number_keeps_fraction_and_zero_pads_whole(value, width, expected): + assert pad(value, width) == expected