feat(4.0-P4): ab_sdk 增加 organize 契约与整理事件

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。
This commit is contained in:
Estrella Pan
2026-10-08 10:23:31 +02:00
parent 659d0e0501
commit cf8135874d
5 changed files with 248 additions and 1 deletions

View File

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

View File

@@ -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:<id> 标签;旧种子可能没有
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"

View File

@@ -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"

View File

@@ -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`` 处理。"""
...

View File

@@ -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