mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-04 06:38:05 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
133
backend/src/ab_sdk/rename.py
Normal file
133
backend/src/ab_sdk/rename.py
Normal 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`` 处理。"""
|
||||
...
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user