Merge pull request #1126 from EstrellaXD/refactor/4.0-p4-organize

feat(4.0-P4): 整理扩展点、内置 rename / hardlink 插件与 renamer 拆分
This commit is contained in:
Xingyu (Estrella) Pan
2026-10-08 17:53:43 +02:00
committed by GitHub
54 changed files with 3795 additions and 1592 deletions

View File

@@ -10,6 +10,11 @@
- **优化(通知中心推送)**:SSE 的 `notification` 帧改为订阅事件总线,通知中心有新消息、已读或删除时立即推送(原先每 3 秒比较一次修订号),推送格式不变
- **修复(搜索)**:`GET /api/v1/search/provider` 读取的是导入时的站点快照,新保存的搜索站点要重启后才会出现在列表中;现在实时读取
- **修复(MCP)**:MCP 客户端每次断开 SSE 连接后,日志都会出现 `AssertionError: Unexpected message http.response.start`(3.x 起存在)。原因是 `/sse` 端点在 SSE 响应结束后又返回了一个空 `Response`,现在改为裸 ASGI 端点
- **新增(整理流水线)**:重命名方式改为 `rename_strategy` 扩展点:`pn` / `advance` / `template` 由默认启用的内置插件「重命名」(`rename`)提供,`none` 由宿主提供;`pn` / `advance` 的输出与此前逐字一致。字幕不再使用 `subtitle_*` 平行方式,同一方式按文件类型生成。`template` 用 Jinja2 沙箱模板自定义文件名(默认模板与 `pn` 相同),保存时试渲染、不合法直接拒绝;运行时渲染失败的文件保留原名并发送「文件未重命名」通知,不会退回 `pn`。文件分类(`media_files`)与版本冲突策略(`conflict_policy`)改为宿主 Provider。重命名后向事件总线发布 `file.renamed` / `torrent.organized`(`ab_sdk.events`)。`ab_sdk` 升至 0.4.0
- **新增(硬链接)**:新增默认停用的内置插件「硬链接到媒体库」(`hardlink`):种子整理完成后把正片与字幕链接到媒体库目录,按下载器实例做路径映射(`path_map`),跨文件系统时默认复制;不覆盖不是它创建的文件,版本升级后替换自己创建的链接;已有文件经 `POST /api/v1/plugins/hardlink/backfill` 按需补链
- **新增(媒体库刷新)**:新增内置插件「媒体库刷新」(`media-server-refresh`):配置 Jellyfin / Emby / Plex 地址与 API Key 后,合并一段时间内整理完成的种子,请求刷新媒体库;未配置时不做任何事
- **重构(整理流水线)**:`manager/renamer.py` 拆为编排(`renamer.py`)与 revision 替换事务(`revision_saga.py`);删除仅测试使用的 `Renamer.rename_file` / `_lookup_offsets`、无调用方的 `release_replacement_lease`、`match_by_save_path`、`search_by_qb_hash`,以及在重命名链路中传递却从未使用的 `season_offset` 参数
- **修复(插件配置)**:插件配置模型的校验器(`field_validator`)报错时,保存接口返回 500 而不是 422(错误详情中的异常对象无法序列化)
- **破坏性变更(升级路径)**:只支持从 3.3.x 升级。检测到更早版本的数据(`config/version.info` 低于 3.3,或残留 2.x 的 `data/data.json`)时拒绝启动,并提示先升级到最新 3.3.x 启动一次完成迁移
- **破坏性变更(API)**:移除 3.2 兼容的 GET 控制端点(`/api/v1/restart`、`/start`、`/stop`、`/shutdown`)和 `GET /api/v1/auth/refresh_token`,请改用 POST
- **破坏性变更(配置)**:移除旧版 `experimental_openai` 配置节和通知的单 provider 旧字段(`type` / `token` / `chat_id`),3.3 已把它们迁移到 `llm` 与 `notification.providers`;移除废弃的 `normal` 重命名方式,已有配置自动改为语义相同的 `none`

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

@@ -32,6 +32,7 @@ class ProviderSpec:
@dataclass(frozen=True, slots=True)
class SubscribeSpec:
kind: str
timeout: float | None = None
@dataclass(frozen=True, slots=True)
@@ -83,15 +84,16 @@ def provider(point: str, *, id: str):
return mark
def subscribe(kind: str):
def subscribe(kind: str, *, timeout: float | None = None):
"""把方法声明为事件订阅者,参数为事件对象。
订阅者在独立队列中按发布顺序异步执行,失败或超时不影响发布方。
``kind`` 为 ``"*"`` 时接收全部事件。
``kind`` 为 ``"*"`` 时接收全部事件。``timeout`` 覆盖宿主默认的单个事件
处理超时(秒),适合复制大文件等耗时操作。
"""
def mark(func: F) -> F:
setattr(func, SUBSCRIBE_ATTR, SubscribeSpec(kind))
setattr(func, SUBSCRIBE_ATTR, SubscribeSpec(kind, timeout))
return func
return mark

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

@@ -100,7 +100,9 @@ async def update_plugin(
ctx.plugins.validate_options(plugin_id, options)
except ValidationError as e:
raise HTTPException(
status_code=422, detail=e.errors(include_url=False)
status_code=422,
# ctx 里可能带 ValueError 等不可 JSON 序列化的对象
detail=e.errors(include_url=False, include_context=False),
) from None
conf.options[plugin_id] = options
if body.enabled is not None:
@@ -119,5 +121,6 @@ async def list_plugin_providers():
points.NOTIFIER,
points.SEARCH_SITE,
points.METADATA_PROVIDER,
points.RENAME_STRATEGY,
)
}

View File

@@ -79,8 +79,7 @@ def _all_title_patterns(bangumi: Bangumi) -> list[str]:
def normalize_save_path(save_path: str | None) -> str:
"""Normalize a save_path so equivalent paths compare equal.
Collapses the separator/trailing-slash variations that
`BangumiDatabase.match_by_save_path` used to try one at a time.
Collapses separator (``\\`` vs ``/``) and trailing-slash variations.
"""
if not save_path:
return ""
@@ -689,55 +688,6 @@ class BangumiDatabase:
logger.debug("Unarchived bangumi id: %s.", _id)
return True
async def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:
"""Find bangumi by save_path to get offset.
Tries exact match first, then falls back to matching with/without trailing slashes
and different path separators.
Note: When multiple subscriptions share the same save_path (e.g., different RSS
sources for the same anime), this returns the first match. Use match_torrent()
for more accurate matching when torrent_name is available.
"""
if not save_path:
return None
# Try exact match first
statement = select(Bangumi).where(
and_(Bangumi.save_path == save_path, Bangumi.deleted == false())
)
result = await self.session.execute(statement)
bangumi = result.scalars().first()
if bangumi:
return bangumi
# Normalize the input path and try variations
normalized = save_path.replace("\\", "/").rstrip("/")
variations = [
normalized,
normalized + "/",
save_path.rstrip("/"),
save_path.rstrip("\\"),
]
# Remove duplicates while preserving order
seen = {save_path}
unique_variations = []
for v in variations:
if v not in seen:
seen.add(v)
unique_variations.append(v)
for variant in unique_variations:
statement = select(Bangumi).where(
and_(Bangumi.save_path == variant, Bangumi.deleted == false())
)
result = await self.session.execute(statement)
bangumi = result.scalars().first()
if bangumi:
return bangumi
return None
async def get_needs_review(self) -> list[Bangumi]:
"""Get all bangumi that need review for offset mismatch."""
statement = select(Bangumi).where(

View File

@@ -292,7 +292,14 @@ class RenameOperationDatabase:
retry_at: datetime | None = None,
last_error: str | None = None,
) -> RenameOperation | None:
"""Advance a replacement only while the caller still owns its lease."""
"""Advance a replacement only while the caller still owns its lease.
The same UPDATE clears the lease, so every saga step that ends in a
state write releases it atomically; there is no separate "release"
call. A step aborted by an exception keeps the lease until it expires
(``claim_replacement_lease``'s ``lease_for``, equal to the retry
cooldown), which only delays the next attempt.
"""
if operation_id is None:
return None
@@ -320,23 +327,6 @@ class RenameOperationDatabase:
return None
return await self.get(operation_id)
async def release_replacement_lease(
self, operation_id: int | None, *, owner: str
) -> bool:
if operation_id is None:
return False
result = await self.session.execute(
update(RenameOperation)
.where(
col(RenameOperation.id) == operation_id,
col(RenameOperation.lease_owner) == owner,
)
.values(lease_owner=None, lease_expires_at=None, updated_at=utc_now())
.execution_options(synchronize_session="fetch")
)
await self.session.commit()
return bool(result.rowcount) # type: ignore[attr-defined]
async def mark_notified(
self, operation_id: int | None, when: datetime | None = None
) -> bool:

View File

@@ -58,13 +58,6 @@ class TorrentDatabase:
existing_urls = set(result.scalars().all())
return [t for t in torrents_list if t.url not in existing_urls]
async def search_by_qb_hash(self, qb_hash: str) -> Torrent | None:
"""Find torrent by qBittorrent hash."""
result = await self.session.execute(
select(Torrent).where(Torrent.qb_hash == qb_hash)
)
return result.scalar_one_or_none()
async def search_by_qb_hashes(self, qb_hashes: list[str]) -> list[Torrent]:
"""Find torrents by multiple qBittorrent hashes (batch query)."""
if not qb_hashes:

View File

@@ -3,9 +3,12 @@ import re
from os import PathLike
from pathlib import PureWindowsPath
from ab_sdk import points
from ab_sdk.rename import CORE_ID, MediaFiles, MediaKind
from module.conf import PLATFORM, settings
from module.models import Bangumi, BangumiUpdate
from module.models.movie import Movie, MovieUpdate
from module.plugin import host as plugin_host
logger = logging.getLogger(__name__)
@@ -38,15 +41,30 @@ def sanitize_path_fragment(name: str) -> str:
return cleaned.rstrip(". ")
class SuffixMediaFiles:
"""宿主自带的 ``media_files`` Provider:按扩展名区分正片与字幕。"""
def classify(self, path: str) -> MediaKind:
suffix = Path(path).suffix.lower()
if suffix in _MEDIA_SUFFIXES:
return "media"
if suffix in _SUBTITLE_SUFFIXES:
return "subtitle"
return "ignore"
def check_files(files: list[dict]):
media_files: MediaFiles = (
plugin_host.get_registry().providers(points.MEDIA_FILES)[CORE_ID].factory()
)
media_list = []
subtitle_list = []
for f in files:
file_name = f["name"]
suffix = Path(file_name).suffix.lower()
if suffix in _MEDIA_SUFFIXES:
kind = media_files.classify(file_name)
if kind == "media":
media_list.append(file_name)
elif suffix in _SUBTITLE_SUFFIXES:
elif kind == "subtitle":
subtitle_list.append(file_name)
return media_list, subtitle_list

View File

@@ -0,0 +1,14 @@
"""宿主自带的重命名方式 ``none``:保留原路径。
``pn`` / ``advance`` / ``template`` 由内置插件 ``rename`` 提供
(module/plugins/builtin/rename/)。
"""
from ab_sdk.rename import RenameInput
NO_RENAME = "none"
class NoRename:
def target_name(self, f: RenameInput) -> str:
return f.media_path

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import unicodedata
from dataclasses import dataclass
from pathlib import PurePosixPath
from ab_sdk.rename import ConflictDecision, ConflictRequest, Revision
from module.parser.analyser.selector import parse_configured_release_title
from module.parser.analyser.tokenizer import MediaType
from module.parser.release_policy import preference_identity, preference_revision
@@ -91,6 +92,47 @@ def same_release_identity(old: RevisionIdentity, new: RevisionIdentity) -> bool:
)
def revision_snapshot(identity: RevisionIdentity | None) -> Revision | None:
"""交给 conflict_policy 的只读快照。"""
if identity is None:
return None
return Revision(
bangumi_id=identity.bangumi_id,
media_type=identity.media_type.value,
season=identity.season,
episode=identity.episode,
group=identity.group,
resolution=identity.resolution,
revision=identity.revision,
)
class CoreConflictPolicy:
"""宿主自带的 ``conflict_policy``:只在唯一占用者与新种子都是单文件、
且新种子是严格的版本升级时,按用户设置决定是否替换。"""
def decide(self, request: ConflictRequest) -> ConflictDecision:
if len(request.owners) != 1:
return ConflictDecision(
"hold", "canonical path has more than one downloader owner"
)
owner = request.owners[0]
if request.incoming.file_count != 1 or owner.file_count != 1:
return ConflictDecision(
"hold", "automatic replacement requires two single-file torrents"
)
if request.incoming.revision is None or owner.revision is None:
return ConflictDecision("hold", "revision identity is incomplete")
if not request.strict_upgrade:
return ConflictDecision(
"hold",
"existing and incoming releases are not a strict revision upgrade",
)
if request.configured == "replace":
return ConflictDecision("replace")
return ConflictDecision("hold", "revision conflict policy is hold")
def replacement_staged_path(
target_path: str, *, old_task_id: str, old_revision: int
) -> str:

View File

@@ -0,0 +1,988 @@
"""重命名事务:普通重命名的「同一目标只执行一次」与 V1 → V2 revision 替换 saga。
每一步外部操作(下载器重命名、删除)都先校验、再持久化到 ``rename_operation``
表,进程崩溃后下一轮可以从记录的状态继续推进。由 :class:`Renamer` 继承,
命名策略与编排留在 ``renamer.py``。
"""
import asyncio
import hashlib
import json
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from sqlalchemy.exc import IntegrityError
from ab_sdk.rename import FileKind
from module.conf import settings
from module.database import Database
from module.database.bangumi import normalize_save_path
from module.downloader import DownloadClient, RenameOutcome, RenameResult
from module.downloader.path import path_to_bangumi
from module.models import EpisodeFile, Notification, RenameOperation, SubtitleFile
from module.notification import RenameConflictEvent, SystemEvent
from .revision_policy import (
RevisionIdentity,
parse_revision_identity,
replacement_staged_path,
)
logger = logging.getLogger(__name__)
_PENDING_RENAME_COOLDOWN = 300 # 5 minutes cooldown before retrying same rename
# 处理完成标记:供外部脚本(filebot、hlink 等)过滤 AB 已重命名的任务 (#147)。
# 语义 = 顶层媒体文件全部就位;字幕在同一轮循环里紧随其后重命名,深层
# 嵌套文件(特典/花絮)设计上从不重命名——两者都不阻塞打标
_RENAMED_TAG = "ab:renamed"
_replacement_locks: dict[tuple[str, str, str], asyncio.Lock] = {}
@dataclass(frozen=True, slots=True)
class PreparedMediaRename:
episode: EpisodeFile
source_path: str
target_path: str
@dataclass(frozen=True, slots=True)
class MediaRenameReport:
result: RenameResult
prepared: PreparedMediaRename | None = None
notification: Notification | None = None
@dataclass(frozen=True, slots=True)
class RevisionOwner:
info: dict
files: list[dict]
identity: RevisionIdentity | None
class RevisionSaga:
"""由 Renamer 继承;子类提供下载器门面 ``client``、冲突事件列表 ``events``
与本轮重命名记录 ``_moves``。"""
client: DownloadClient
events: list[SystemEvent]
_moves: dict[str, list[tuple[str, str, FileKind]]]
def _record_move(self, _hash: str, old: str, new: str, kind: FileKind) -> None:
"""记录一次已生效的重命名,供本轮结束时发布 file.renamed / torrent.organized。"""
self._moves.setdefault(_hash, []).append((old, new, kind))
@staticmethod
def _adjust_episode(original: int | float, episode_offset: int) -> int | float:
if original == 0 and episode_offset != 0:
# Episode 0 is a special/OVA — never apply offset to avoid
# overwriting regular episodes (see issue #977)
return 0
adjusted = original + episode_offset
# An offset producing a non-positive result (e.g., EP5 + offset -10)
# is almost always a misconfiguration, so revert to original.
if adjusted < 0 or (adjusted == 0 and original > 0):
logger.warning(
f"Episode offset {episode_offset} would make episode {original} non-positive, ignoring offset"
)
return original
return adjusted
async def _mark_renamed(self, _hash: str, existing_tags: str | None) -> None:
"""给处理完成的种子打 ``ab:renamed`` 标签;已带标签时不再调 API。
打标失败绝不能影响重命名主流程(重命名已经成功、通知必须发出、
本轮其余种子必须继续处理)——吞掉异常,下一轮会自动补打。
"""
if _RENAMED_TAG in (t.strip() for t in (existing_tags or "").split(",")):
return
try:
await self.client.add_tag(_hash, _RENAMED_TAG)
except Exception as e:
logger.warning("Failed to tag %s as renamed: %s", _hash[:8], e)
async def _execute_media_rename(
self,
*,
prepared: PreparedMediaRename,
bangumi_name: str,
_hash: str,
episode_offset: int,
) -> MediaRenameReport:
if prepared.source_path == prepared.target_path:
return MediaRenameReport(
result=RenameResult(RenameOutcome.ALREADY_APPLIED),
prepared=prepared,
)
result = await self.client.rename_torrent_file(
_hash=_hash,
old_path=prepared.source_path,
new_path=prepared.target_path,
)
notification = None
if result.outcome is RenameOutcome.RENAMED:
self._record_move(
_hash, prepared.source_path, prepared.target_path, "media"
)
notification = Notification(
official_title=bangumi_name,
season=prepared.episode.season,
episode=self._adjust_episode(prepared.episode.episode, episode_offset),
)
return MediaRenameReport(
result=result,
prepared=prepared,
notification=notification,
)
@staticmethod
def _parse_bangumi_id_from_tags(tags: str | None) -> int | None:
"""Extract bangumi_id from torrent tags.
Tags are comma-separated, and we look for 'ab:ID' format.
"""
if not tags:
return None
for tag in tags.split(","):
tag = tag.strip()
if tag.startswith("ab:"):
try:
return int(tag[3:])
except ValueError:
pass
return None
@staticmethod
def _retry_at() -> datetime:
return datetime.now(timezone.utc) + timedelta(seconds=_PENDING_RENAME_COOLDOWN)
@staticmethod
def _retry_is_due(value: datetime | None) -> bool:
if value is None:
return True
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value <= datetime.now(timezone.utc)
def _downloader_type(self) -> str:
configured = settings.downloader.type
downloader_type = (
configured
if isinstance(configured, str)
else type(self.client.client).__name__.lower()
)
host = settings.downloader.host
if not isinstance(host, str) or not host:
return downloader_type
instance_hash = hashlib.sha256(host.strip().lower().encode()).hexdigest()[:12]
return f"{downloader_type}:{instance_hash}"
async def _find_revision_owners(
self,
*,
incoming: dict,
target_path: str,
all_infos: list[dict],
episode_offset: int,
) -> tuple[RevisionIdentity | None, list[RevisionOwner]]:
"""Find downloader tasks that already own an incoming canonical path."""
incoming_id = self._parse_bangumi_id_from_tags(incoming.get("tags"))
_, incoming_season = path_to_bangumi(
incoming.get("save_path", ""), incoming.get("name", "")
)
incoming_identity = parse_revision_identity(
incoming.get("name", ""),
bangumi_id=incoming_id,
default_season=incoming_season,
episode_offset=episode_offset,
)
if incoming_id is None:
return incoming_identity, []
save_path = normalize_save_path(incoming.get("save_path", ""))
candidates = [
info
for info in all_infos
if info.get("hash") != incoming.get("hash")
and normalize_save_path(info.get("save_path", "")) == save_path
and self._parse_bangumi_id_from_tags(info.get("tags")) == incoming_id
]
if not candidates:
return incoming_identity, []
candidate_files = await asyncio.gather(
*[self.client.get_torrent_files(info["hash"]) for info in candidates]
)
owners: list[RevisionOwner] = []
normalized_target = target_path.replace("\\", "/")
for info, files in zip(candidates, candidate_files):
# Count every downloader task that references the canonical path
# before applying the destructive single-file guard. A collection
# owner must make replacement ineligible, not disappear from the
# owner count.
if not any(
str(item.get("name", "")).replace("\\", "/") == normalized_target
for item in files
):
continue
_, old_season = path_to_bangumi(
info.get("save_path", ""), info.get("name", "")
)
owners.append(
RevisionOwner(
info=info,
files=files,
identity=parse_revision_identity(
info.get("name", ""),
bangumi_id=incoming_id,
default_season=old_season,
episode_offset=episode_offset,
),
)
)
return incoming_identity, owners
def _build_operation(
self,
*,
info: dict,
prepared: PreparedMediaRename,
identity: RevisionIdentity | None,
kind: str,
state: str,
owner: RevisionOwner | None = None,
reason: str | None = None,
) -> RenameOperation:
owner_identity = owner.identity if owner else None
metadata = {
"new_torrent_name": info.get("name", ""),
"old_torrent_name": owner.info.get("name", "") if owner else None,
}
return RenameOperation(
downloader_type=self._downloader_type(),
kind=kind,
state=state,
new_task_id=info["hash"],
old_task_id=owner.info["hash"] if owner else None,
save_path=normalize_save_path(info.get("save_path", "")),
source_path=prepared.source_path,
target_path=prepared.target_path,
staged_path=(
replacement_staged_path(
prepared.target_path,
old_task_id=owner.info["hash"],
old_revision=owner_identity.revision,
)
if owner and owner_identity
else None
),
bangumi_id=identity.bangumi_id if identity else None,
media_type=identity.media_type.value if identity else None,
season=identity.season if identity else None,
episode=float(identity.episode) if identity else None,
group_name=identity.group if identity else None,
resolution=identity.resolution if identity else None,
old_revision=owner_identity.revision if owner_identity else None,
new_revision=identity.revision if identity else None,
revision_metadata=json.dumps(metadata, ensure_ascii=False),
last_error=reason,
)
async def _emit_conflict_once(
self,
operation: RenameOperation,
*,
torrent_name: str,
reason: str,
) -> None:
async with Database() as db:
claimed = await db.rename_operation.mark_notified(operation.id)
if claimed:
self.events.append(
RenameConflictEvent(
task_id=operation.new_task_id,
torrent_name=torrent_name,
target_path=operation.target_path,
reason=reason,
)
)
async def _persist_conflict(
self,
*,
info: dict,
prepared: PreparedMediaRename,
identity: RevisionIdentity | None,
reason: str,
owner: RevisionOwner | None = None,
) -> RenameOperation | None:
operation = self._build_operation(
info=info,
prepared=prepared,
identity=identity,
kind="conflict",
state="conflict",
owner=owner,
reason=reason,
)
async with Database() as db:
active = await db.rename_operation.get_by_target(
downloader_type=operation.downloader_type,
save_path=operation.save_path,
target_path=operation.target_path,
)
if active is not None and active.new_task_id != operation.new_task_id:
logger.warning(
"Rename target already reserved by task %s: %s",
active.new_task_id[:8],
operation.target_path,
)
return active
try:
row, _ = await db.rename_operation.upsert_conflict(operation)
except IntegrityError:
row = await db.rename_operation.get_by_target(
downloader_type=operation.downloader_type,
save_path=operation.save_path,
target_path=operation.target_path,
)
if row is not None and row.new_task_id == info["hash"]:
await self._emit_conflict_once(
row, torrent_name=info.get("name", ""), reason=reason
)
return row
async def _set_operation_state(
self,
operation: RenameOperation,
state: str,
*,
retry: bool = False,
error: str | None = None,
) -> RenameOperation:
async with Database() as db:
if operation.kind == "replacement" and operation.lease_owner:
updated = await db.rename_operation.set_state_claimed(
operation.id,
owner=operation.lease_owner,
state=state, # type: ignore[arg-type]
retry_at=self._retry_at() if retry else None,
last_error=error,
)
if updated is None:
raise RuntimeError("replacement lease was lost before state commit")
else:
updated = await db.rename_operation.set_state(
operation.id,
state, # type: ignore[arg-type]
retry_at=self._retry_at() if retry else None,
last_error=error,
)
return updated or operation
async def _replacement_conflict(
self,
operation: RenameOperation,
*,
info: dict,
reason: str,
) -> None:
operation = await self._set_operation_state(operation, "conflict", error=reason)
await self._emit_conflict_once(
operation,
torrent_name=info.get("name", ""),
reason=reason,
)
async def _advance_replacement(
self,
*,
operation: RenameOperation,
info: dict,
prepared: PreparedMediaRename,
all_infos: list[dict],
bangumi_name: str,
episode_offset: int,
existing_tags: str | None,
) -> Notification | None:
"""Advance a staged V1 -> V2 replacement saga as far as possible."""
key = (
operation.downloader_type,
operation.save_path,
operation.target_path,
)
lock = _replacement_locks.setdefault(key, asyncio.Lock())
async with lock:
async with Database() as db:
current = await db.rename_operation.get(operation.id)
if current is None:
return None
operation = current
if not self._retry_is_due(operation.retry_at):
return None
info_by_hash = {item["hash"]: item for item in all_infos}
staged_path = operation.staged_path
if not staged_path or not operation.old_task_id:
await self._replacement_conflict(
operation,
info=info,
reason="replacement operation is missing old owner metadata",
)
return None
old_task_id = operation.old_task_id
# A bounded loop lets the normal successful path finish in one tick,
# while every external action is verified and persisted separately.
for _ in range(6):
state = operation.state
if state in {
"planned",
"old_staged",
"new_promoted",
"old_removed",
}:
async with Database() as db:
claimed = await db.rename_operation.claim_replacement_lease(
operation.id,
owner=uuid.uuid4().hex,
)
if claimed is None:
return None
operation = claimed
state = operation.state
if state == "conflict":
await self._emit_conflict_once(
operation,
torrent_name=info.get("name", ""),
reason=operation.last_error or "rename conflict",
)
return None
if state == "planned":
old_info = info_by_hash.get(old_task_id)
if old_info is None:
await self._replacement_conflict(
operation,
info=info,
reason="old revision task disappeared before staging",
)
return None
old_files = await self.client.get_torrent_files(old_task_id)
old_names = {
str(item.get("name", "")).replace("\\", "/")
for item in old_files
}
if staged_path in old_names:
operation = await self._set_operation_state(
operation, "old_staged"
)
continue
if operation.target_path not in old_names:
await self._replacement_conflict(
operation,
info=info,
reason="old revision no longer owns the canonical path",
)
return None
result = await self.client.rename_torrent_file(
_hash=old_task_id,
old_path=operation.target_path,
new_path=staged_path,
)
if result.succeeded:
operation = await self._set_operation_state(
operation, "old_staged"
)
continue
if result.outcome is RenameOutcome.DESTINATION_EXISTS:
await self._replacement_conflict(
operation,
info=info,
reason="temporary staging path already exists",
)
return None
operation = await self._set_operation_state(
operation,
"planned",
retry=True,
error=result.detail or "failed to stage old revision",
)
return None
if state == "old_staged":
new_files = await self.client.get_torrent_files(
operation.new_task_id
)
new_names = {
str(item.get("name", "")).replace("\\", "/")
for item in new_files
}
if operation.target_path in new_names:
operation = await self._set_operation_state(
operation, "new_promoted"
)
continue
if operation.source_path not in new_names:
rollback = await self.client.rename_torrent_file(
_hash=old_task_id,
old_path=staged_path,
new_path=operation.target_path,
)
if rollback.succeeded:
await self._replacement_conflict(
operation,
info=info,
reason=(
"new revision source disappeared after "
"staging; V1 was restored"
),
)
else:
await self._set_operation_state(
operation,
"old_staged",
retry=True,
error=(
"new revision source disappeared and V1 "
"rollback needs retry: "
f"{rollback.detail or rollback.outcome.value}"
),
)
return None
result = await self.client.rename_torrent_file(
_hash=operation.new_task_id,
old_path=operation.source_path,
new_path=operation.target_path,
)
if result.outcome is RenameOutcome.RENAMED:
self._record_move(
operation.new_task_id,
operation.source_path,
operation.target_path,
"media",
)
if result.succeeded:
operation = await self._set_operation_state(
operation, "new_promoted"
)
continue
# Reconcile once more before rollback: a transport failure
# may happen after the downloader applied the promotion.
new_files = await self.client.get_torrent_files(
operation.new_task_id
)
if any(
str(item.get("name", "")).replace("\\", "/")
== operation.target_path
for item in new_files
):
self._record_move(
operation.new_task_id,
operation.source_path,
operation.target_path,
"media",
)
operation = await self._set_operation_state(
operation, "new_promoted"
)
continue
rollback = await self.client.rename_torrent_file(
_hash=old_task_id,
old_path=staged_path,
new_path=operation.target_path,
)
if rollback.succeeded:
if result.outcome is RenameOutcome.DESTINATION_EXISTS:
await self._replacement_conflict(
operation,
info=info,
reason=(
"V2 promotion target is owned by an unknown "
"file; V1 was restored"
),
)
return None
operation = await self._set_operation_state(
operation,
"planned",
retry=True,
error=result.detail
or "promotion failed and V1 was restored",
)
return None
if rollback.outcome is RenameOutcome.DESTINATION_EXISTS:
await self._replacement_conflict(
operation,
info=info,
reason=(
"V2 promotion and V1 rollback both found an "
"occupied canonical target"
),
)
return None
operation = await self._set_operation_state(
operation,
"old_staged",
retry=True,
error=(
"promotion failed and rollback needs retry: "
f"{rollback.detail or rollback.outcome.value}"
),
)
return None
if state == "new_promoted":
old_exists = await self.client.torrent_exists(old_task_id)
if old_exists is None:
operation = await self._set_operation_state(
operation,
"new_promoted",
retry=True,
error=(
"new revision is live; old task existence could "
"not be verified"
),
)
return None
if old_exists:
removed = await self.client.delete_torrent(
old_task_id, delete_files=True
)
if not removed:
operation = await self._set_operation_state(
operation,
"new_promoted",
retry=True,
error="new revision is live; old task cleanup failed",
)
return None
old_exists = await self.client.torrent_exists(old_task_id)
if old_exists is not False:
operation = await self._set_operation_state(
operation,
"new_promoted",
retry=True,
error=(
"old task deletion was accepted but is not "
"yet observable"
),
)
return None
operation = await self._set_operation_state(
operation, "old_removed"
)
continue
if state == "old_removed":
operation = await self._set_operation_state(operation, "done")
continue
if state == "done":
await self._mark_renamed(info["hash"], existing_tags)
async with Database() as db:
notify = await db.rename_operation.mark_notified(operation.id)
if not notify:
return None
return Notification(
official_title=bangumi_name,
season=prepared.episode.season,
episode=self._adjust_episode(
prepared.episode.episode, episode_offset
),
)
# `retry` is only used by ordinary renames. A replacement row
# reaching it is malformed and must stop rather than guessing.
await self._replacement_conflict(
operation,
info=info,
reason=f"unexpected replacement state: {state}",
)
return None
return None
async def _start_replacement(
self,
*,
info: dict,
prepared: PreparedMediaRename,
identity: RevisionIdentity,
owner: RevisionOwner,
all_infos: list[dict],
bangumi_name: str,
episode_offset: int,
existing_tags: str | None,
) -> Notification | None:
operation = self._build_operation(
info=info,
prepared=prepared,
identity=identity,
kind="replacement",
state="planned",
owner=owner,
)
async with Database() as db:
try:
row, _ = await db.rename_operation.get_or_create(operation)
except IntegrityError:
row = await db.rename_operation.get_by_target(
downloader_type=operation.downloader_type,
save_path=operation.save_path,
target_path=operation.target_path,
)
if row is None or row.new_task_id != info["hash"]:
await self._persist_conflict(
info=info,
prepared=prepared,
identity=identity,
owner=owner,
reason="canonical target is reserved by another rename operation",
)
return None
return await self._advance_replacement(
operation=row,
info=info,
prepared=prepared,
all_infos=all_infos,
bangumi_name=bangumi_name,
episode_offset=episode_offset,
existing_tags=existing_tags,
)
async def _recover_missing_replacement(
self, operation: RenameOperation, all_infos: list[dict]
) -> None:
"""Recover an active saga whose incoming task left the normal snapshot."""
if any(info.get("hash") == operation.new_task_id for info in all_infos):
return
async with Database() as db:
claimed = await db.rename_operation.claim_replacement_lease(
operation.id,
owner=uuid.uuid4().hex,
)
if claimed is None:
return
operation = claimed
try:
metadata = json.loads(operation.revision_metadata or "{}")
except json.JSONDecodeError:
metadata = {}
info = {
"hash": operation.new_task_id,
"name": metadata.get("new_torrent_name") or operation.new_task_id,
}
if operation.state == "old_staged" and operation.old_task_id:
rollback = await self.client.rename_torrent_file(
_hash=operation.old_task_id,
old_path=operation.staged_path or "",
new_path=operation.target_path,
)
if rollback.succeeded:
await self._replacement_conflict(
operation,
info=info,
reason="incoming revision task disappeared; V1 was restored",
)
return
if rollback.outcome is RenameOutcome.DESTINATION_EXISTS:
await self._replacement_conflict(
operation,
info=info,
reason=(
"incoming revision task disappeared while the canonical "
"path remained occupied"
),
)
return
await self._set_operation_state(
operation,
"old_staged",
retry=True,
error=(
"incoming revision task disappeared and V1 rollback needs "
f"retry: {rollback.detail or rollback.outcome.value}"
),
)
return
if operation.state == "old_removed":
await self._set_operation_state(operation, "done")
return
await self._replacement_conflict(
operation,
info=info,
reason=(
"incoming revision task disappeared during replacement; "
"automatic deletion is stopped"
),
)
async def _run_ordinary_rename(
self,
*,
info: dict,
prepared: PreparedMediaRename,
identity: RevisionIdentity | None,
bangumi_name: str,
episode_offset: int,
) -> MediaRenameReport:
"""Claim and execute one non-replacement rename exactly once at a time."""
template = self._build_operation(
info=info,
prepared=prepared,
identity=identity,
kind="conflict",
state="retry",
)
key = (template.downloader_type, template.save_path, template.target_path)
lock = _replacement_locks.setdefault(key, asyncio.Lock())
async with lock:
async with Database() as db:
active = await db.rename_operation.get_by_target(
downloader_type=template.downloader_type,
save_path=template.save_path,
target_path=template.target_path,
)
if active is not None and active.new_task_id != info["hash"]:
return MediaRenameReport(
result=RenameResult(
RenameOutcome.DESTINATION_EXISTS,
detail="canonical target is reserved by another operation",
),
prepared=prepared,
)
if active is None:
try:
active, _ = await db.rename_operation.get_or_create(template)
except IntegrityError:
active = await db.rename_operation.get_by_target(
downloader_type=template.downloader_type,
save_path=template.save_path,
target_path=template.target_path,
)
if active is None:
return MediaRenameReport(
result=RenameResult(
RenameOutcome.RETRYABLE_FAILURE,
detail="could not reserve rename operation",
),
prepared=prepared,
)
if active.state == "done":
return MediaRenameReport(
result=RenameResult(RenameOutcome.ALREADY_APPLIED),
prepared=prepared,
)
if active.state == "conflict":
conflict = active
claimed = None
else:
conflict = None
if active.state == "running":
recovered = await db.rename_operation.recover_stale_running(
active.id,
before=datetime.now(timezone.utc)
- timedelta(seconds=_PENDING_RENAME_COOLDOWN),
)
if not recovered:
return MediaRenameReport(
result=RenameResult(
RenameOutcome.RETRYABLE_FAILURE,
detail="rename operation is already running",
),
prepared=prepared,
)
active = await db.rename_operation.get(active.id) or active
if active.state == "retry" and not self._retry_is_due(
active.retry_at
):
return MediaRenameReport(
result=RenameResult(
RenameOutcome.RETRYABLE_FAILURE,
detail=active.last_error or "rename retry cooldown",
),
prepared=prepared,
)
claimed = await db.rename_operation.claim(
active.id,
from_states=("retry",),
to_state="running",
)
if conflict is not None:
await self._emit_conflict_once(
conflict,
torrent_name=info.get("name", ""),
reason=conflict.last_error or "target already exists",
)
return MediaRenameReport(
result=RenameResult(
RenameOutcome.DESTINATION_EXISTS,
detail=conflict.last_error,
),
prepared=prepared,
)
if claimed is None:
return MediaRenameReport(
result=RenameResult(
RenameOutcome.RETRYABLE_FAILURE,
detail="rename operation was claimed by another worker",
),
prepared=prepared,
)
# The downloader may have applied the previous rename just before
# the process crashed, leaving the DB row in ``running``. Its own
# file list is authoritative proof of ownership; reconcile that
# state before sending the external mutation again (qB commonly
# answers the replay with 409).
current_files = await self.client.get_torrent_files(info["hash"])
current_names = {
str(item.get("name", "")).replace("\\", "/") for item in current_files
}
if (
prepared.target_path in current_names
and prepared.source_path not in current_names
):
await self._set_operation_state(claimed, "done")
return MediaRenameReport(
result=RenameResult(RenameOutcome.ALREADY_APPLIED),
prepared=prepared,
)
report = await self._execute_media_rename(
prepared=prepared,
bangumi_name=bangumi_name,
_hash=info["hash"],
episode_offset=episode_offset,
)
if report.result.succeeded:
await self._set_operation_state(claimed, "done")
return report
if report.result.outcome is RenameOutcome.DESTINATION_EXISTS:
conflict = await self._set_operation_state(
claimed,
"conflict",
error=report.result.detail or "target path already exists",
)
await self._emit_conflict_once(
conflict,
torrent_name=info.get("name", ""),
reason=conflict.last_error or "target path already exists",
)
return report
await self._set_operation_state(
claimed,
"retry",
retry=True,
error=report.result.detail or "rename failed verification",
)
return report

View File

@@ -6,6 +6,7 @@ from .events import (
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RenameConflictEvent,
RenameSkippedEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
@@ -23,6 +24,7 @@ __all__ = [
"NotificationProvider",
"OffsetReviewEvent",
"RenameConflictEvent",
"RenameSkippedEvent",
"PROVIDER_REGISTRY",
"RssFailureEvent",
"SystemEvent",

View File

@@ -26,6 +26,7 @@ from ab_sdk.events import (
LLMPluginInstallFailedEvent,
OffsetReviewEvent,
RenameConflictEvent,
RenameSkippedEvent,
RssFailureEvent,
SystemEvent,
UpdateAppliedEvent,
@@ -39,6 +40,7 @@ __all__ = [
"LLMPluginInstallFailedEvent",
"OffsetReviewEvent",
"RenameConflictEvent",
"RenameSkippedEvent",
"RssFailureEvent",
"SystemEvent",
"UpdateAppliedEvent",

View File

@@ -111,13 +111,18 @@ class EventBus:
self.on_error = on_error
def subscribe(
self, kind: str, handler: Handler, *, owner: str | None = None
self,
kind: str,
handler: Handler,
*,
owner: str | None = None,
timeout: float | None = None,
) -> Callable[[], None]:
sub = _Subscriber(
kind,
handler,
owner,
self._timeout,
timeout if timeout is not None else self._timeout,
self._queue_size,
self._report_error,
)

View File

@@ -1,5 +1,6 @@
"""宿主侧的 PluginContext 实现。"""
import asyncio
import logging
from pathlib import Path
from typing import Any
@@ -7,11 +8,15 @@ from typing import Any
from pydantic import BaseModel
from ab_sdk import Event
from ab_sdk.events import SystemEvent
from .bus import EventBus, Handler
PLUGIN_DATA_ROOT = Path("config") / "plugin-data"
# 插件可通知事件的 fire-and-forget 发送任务,保留强引用防止被 GC
_notify_tasks: set[asyncio.Task] = set()
def _database():
# 延迟 import:module.plugin.host 被网络层、解析器等底层模块引用,
@@ -43,7 +48,11 @@ class DatabaseKV:
class PluginBus:
"""插件视角的总线:订阅归属到插件(卸载时统一清理、失败计入熔断),
发布的事件 kind 必须以 ``<plugin-id>.`` 开头,防止冒充宿主事件。"""
发布的事件 kind 必须以 ``<plugin-id>.`` 开头,防止冒充宿主事件。
插件发布的 :class:`SystemEvent` 与宿主事件走同一条通知路径
(``NotificationManager.send_event``:通知中心 → 事件总线 → 外部渠道)。
"""
def __init__(self, bus: EventBus, plugin_id: str) -> None:
self._bus = bus
@@ -55,6 +64,14 @@ class PluginBus:
f"插件 {self._plugin_id} 只能发布以 '{self._plugin_id}.' 开头的事件,"
f"收到 {event.kind!r}"
)
if isinstance(event, SystemEvent):
# 延迟 import:notification 依赖 module.plugin.host
from module.notification import NotificationManager
task = asyncio.create_task(NotificationManager().send_event(event))
_notify_tasks.add(task)
task.add_done_callback(_notify_tasks.discard)
return
self._bus.publish(event)
def subscribe(self, kind: str, handler: Handler):

View File

@@ -14,6 +14,7 @@ from typing import Any
from ab_sdk import Event, points
from ab_sdk.downloader import DownloaderConnection
from ab_sdk.rename import CORE_ID
from .bus import EventBus
from .registry import ExtensionPoint, ExtensionRegistry, ProviderEntry
@@ -51,6 +52,12 @@ POINTS = (
ExtensionPoint(points.MCP_TOOL, "provider", "MCP 工具", scoped=True),
ExtensionPoint(points.MCP_RESOURCE, "provider", "MCP 资源", scoped=True),
ExtensionPoint(points.MESSAGE_TEMPLATE, "transform", "系统事件通知文案"),
# --- P4 organize ---
ExtensionPoint(
points.RENAME_STRATEGY, "provider", "重命名方式(bangumi_manage.rename_method)"
),
ExtensionPoint(points.MEDIA_FILES, "provider", "种子内文件分类(正片 / 字幕)"),
ExtensionPoint(points.CONFLICT_POLICY, "provider", "目标路径被占用时保留或替换"),
)
_registry: ExtensionRegistry | None = None
@@ -174,6 +181,16 @@ def _register_core(registry: ExtensionRegistry) -> None:
ProviderEntry(CORE, provider_id, metadata(provider_id)),
)
# organize:重命名方式 none(pn / advance / template 由内置插件 rename 提供)、
# 按扩展名分类、版本冲突策略
from module.downloader.path import SuffixMediaFiles
from module.manager.rename_strategies import NO_RENAME, NoRename
from module.manager.revision_policy import CoreConflictPolicy
_core(registry, points.RENAME_STRATEGY, NO_RENAME, NoRename())
_core(registry, points.MEDIA_FILES, CORE_ID, SuffixMediaFiles())
_core(registry, points.CONFLICT_POLICY, CORE_ID, CoreConflictPolicy())
# --- P5 events/api ---------------------------------------------------------

View File

@@ -194,8 +194,9 @@ class PluginManager:
return None
def _enabled(self, candidate: PluginCandidate) -> bool:
"""启用开关;未设置时内置插件默认启用,其它来源默认禁用。"""
default = candidate.source == "builtin"
"""启用开关;未设置时内置插件默认启用(清单 ``default_enabled = false``
的除外),其它来源默认禁用。"""
default = candidate.source == "builtin" and candidate.manifest.default_enabled
return self._settings.plugins.enabled.get(candidate.manifest.id, default)
def _snapshot(self, plugin_id: str) -> str:
@@ -279,7 +280,12 @@ class PluginManager:
ProviderEntry(plugin_id, provider_spec.id, bound),
)
if subscribe_spec is not None:
self.bus.subscribe(subscribe_spec.kind, bound, owner=plugin_id)
self.bus.subscribe(
subscribe_spec.kind,
bound,
owner=plugin_id,
timeout=subscribe_spec.timeout,
)
async def _deactivate(self, plugin_id: str) -> None:
active = self._active.pop(plugin_id, None)

View File

@@ -30,6 +30,9 @@ class PluginManifest(BaseModel):
description: str = ""
authors: list[str] = Field(default_factory=list)
permissions: list[str] = Field(default_factory=list)
default_enabled: bool = Field(
True, description="未设置启用开关时是否默认启用;只对内置插件生效"
)
@field_validator("id")
@classmethod

View File

@@ -139,6 +139,25 @@ class HookRunner:
entry = HookEntry(plugin_id, func, 0, timeout)
return await self._call(entry, point, args, {}, check)
def call_provider_sync(
self,
plugin_id: str,
point: str,
func: Callable[..., Any],
*args: Any,
check: Check | None = None,
) -> tuple[bool, Any]:
"""同步 Provider 方法(如重命名策略)的调用:没有超时,失败与返回值
校验的规则同 :meth:`call_provider`。"""
try:
result = func(*args)
_check(result, check)
except Exception as e:
self._fail(plugin_id, f"{point}: {type(e).__name__}: {e}")
return False, None
self._breaker.record_success(plugin_id)
return True, result
async def _call(
self,
entry: HookEntry,
@@ -152,21 +171,30 @@ class HookRunner:
result = entry.func(*args, **kwargs)
if inspect.isawaitable(result):
result = await asyncio.wait_for(result, timeout)
# 返回值校验在记成功之前:无效结果必须计入熔断,而不是先清零再计一次
if result is not None and check is not None and not check(result):
raise TypeError(f"返回了无效结果 {type(result).__name__},已忽略")
_check(result, check)
except Exception as e:
reason = (
f"{point} 超时({timeout}s)"
if isinstance(e, TimeoutError)
else f"{point}: {type(e).__name__}: {e}"
self._fail(
entry.plugin_id,
(
f"{point} 超时({timeout}s)"
if isinstance(e, TimeoutError)
else f"{point}: {type(e).__name__}: {e}"
),
)
logger.warning("[Plugin:%s] 钩子失败:%s", entry.plugin_id, reason)
self._breaker.record_failure(entry.plugin_id, reason)
return False, None
self._breaker.record_success(entry.plugin_id)
return True, result
def _fail(self, plugin_id: str, reason: str) -> None:
logger.warning("[Plugin:%s] 钩子失败:%s", plugin_id, reason)
self._breaker.record_failure(plugin_id, reason)
def _check(result: Any, check: Check | None) -> None:
# 返回值校验在记成功之前:无效结果必须计入熔断,而不是先清零再计一次
if result is not None and check is not None and not check(result):
raise TypeError(f"返回了无效结果 {type(result).__name__},已忽略")
def _as_verdict(result: Any) -> Verdict | None:
if isinstance(result, Verdict):

View File

@@ -0,0 +1,257 @@
"""内置插件:硬链接到媒体库(订阅 ``torrent.organized``)。
种子整理完成后,把它的正片与字幕链接到 ``library_root``,相对路径与它们在
``source_root`` 下的相对路径相同。下载目录保持原样继续做种;删除种子时不会
删除媒体库中的链接。
- 事件里的路径是下载器视角,先按 ``path_map`` 换成 AB 本地路径;
- 跨文件系统(EXDEV)时按 ``cross_device`` 复制、建软链接或跳过;
- 目标已存在且不是本插件创建的:跳过并通知;是本插件之前为同一集创建的
(版本升级后同名文件换成了新文件):原子替换;
- 事件投递为「至少一次」,已链接的文件再次收到时不做任何事。
"""
import asyncio
import errno
import os
import secrets
import shutil
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Literal
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ab_sdk import Plugin, points, provider, subscribe
from ab_sdk.events import SystemEvent, TorrentOrganized
Status = Literal["linked", "exists", "conflict", "failed"]
# 单个种子的处理超时:跨盘复制整季可能需要几分钟
LINK_TIMEOUT = 3600.0
# ponytail: 补链按扩展名挑文件,与宿主 media_files 的 core 实现同一组;插件不能
# import module.*,若用户换了 media_files 实现(P2.5 slots),这里需要改为经 SDK 查询
BACKFILL_SUFFIXES = frozenset({".mp4", ".mkv", ".ass", ".srt"})
class PathMap(BaseModel):
model_config = ConfigDict(populate_by_name=True)
downloader: str = Field("default", title="下载器", description="下载器实例 id")
from_: str = Field(
alias="from", title="下载器路径", description="下载器看到的路径前缀"
)
to: str = Field(title="本地路径", description="同一位置在 AutoBangumi 中的路径")
class Options(BaseModel):
path_map: list[PathMap] = Field(
default_factory=list,
title="路径映射",
description=(
"下载器与 AutoBangumi 不在同一个文件系统视图(如分别运行在不同容器)"
"时,把下载器路径前缀换成本地路径;按最长前缀匹配,未匹配的路径原样使用"
),
)
source_root: str = Field(
title="下载根目录", description="本地路径,媒体库保持与它相同的目录结构"
)
library_root: str = Field(
title="媒体库目录",
description="链接放在这里;硬链接要求与下载根目录在同一个文件系统",
)
cross_device: Literal["copy", "symlink", "skip"] = Field(
"copy",
title="跨文件系统时",
description="copy 复制文件,symlink 创建软链接,skip 跳过并通知",
)
@field_validator("source_root", "library_root")
@classmethod
def _absolute(cls, value: str) -> str:
if not os.path.isabs(value):
raise ValueError("须为绝对路径")
return os.path.normpath(value)
@model_validator(mode="after")
def _library_outside_source(self) -> "Options":
# 媒体库在下载目录内时,补链会把媒体库自己再链接一遍
if Path(self.library_root).is_relative_to(self.source_root):
raise ValueError("媒体库目录不能位于下载根目录内")
return self
@dataclass(frozen=True, slots=True)
class HardlinkFailed(SystemEvent):
"""种子中有文件没能链接到媒体库(目标被占用、跨文件系统被跳过等)。"""
kind: ClassVar[str] = "hardlink.failed"
severity: ClassVar[str] = "warning"
torrent_hash: str
files: str # 每行一个:路径:原因
def dedup_key(self) -> str | None:
return f"hardlink.failed:{self.torrent_hash}"
def describe(self) -> tuple[str, str]:
return ("硬链接未完成", f"种子:{self.torrent_hash}\n{self.files}")
def _identity(path: Path) -> list[int]:
"""文件身份(不跟随软链接)。含大小与修改时间:旧文件删除后 inode 可能被复用。"""
st = os.lstat(path)
return [st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns]
def _same(src: Path, dst: Path) -> bool:
"""dst 是 src 的硬链接、软链接,或 copy2 的副本(大小与修改时间相同)。"""
try:
if os.path.samefile(src, dst):
return True
except OSError: # 断开的软链接
return False
s, d = os.stat(src), os.lstat(dst)
return stat.S_ISREG(d.st_mode) and (s.st_size, s.st_mtime_ns) == (
d.st_size,
d.st_mtime_ns,
)
def _make(src: Path, dst: Path, cross_device: str) -> tuple[Status, str]:
"""先在同目录的临时文件上建立链接或副本,再原子地移到 ``dst``:复制中断
(磁盘满、插件重载、进程退出)不会在目标位置留下半个文件,版本升级时也
不会出现目标暂时缺失的窗口。临时文件名每次不同:补链与订阅可能同时处理
同一个目标。"""
tmp = dst.with_name(f".{dst.name}.{secrets.token_hex(4)}.ab-hardlink")
try:
try:
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, tmp)
else:
shutil.copy2(src, tmp)
os.replace(tmp, dst)
except BaseException:
tmp.unlink(missing_ok=True)
raise
return "linked", ""
# 插件放置的文件:[源文件身份, 目标身份]
Record = list[list[int]]
def place(
src: Path, dst: Path, owned: Record | None, cross_device: str
) -> tuple[Status, str, Record | None]:
"""在线程中执行的文件操作。``owned`` 是插件上次在 ``dst`` 放置文件时的记录,
None 表示 ``dst`` 不是插件创建的。返回 linked / exists 时附带新记录。"""
ident = _identity(src)
if os.path.lexists(dst):
here = _identity(dst)
# 指向源文件或是它的副本:即使记录没来得及写入(协程被取消、进程退出)
# 也认作本插件放置的,并补上记录
if _same(src, dst) or owned == [ident, here]:
return "exists", "", [ident, here]
# 目标被换成了别的文件(用户放的),不能覆盖
if owned is None or owned[1] != here:
return "conflict", "媒体库中已有同名文件且不是本插件创建的", None
# 否则是版本升级:同一集换成了新文件,替换插件之前创建的链接
else:
dst.parent.mkdir(parents=True, exist_ok=True)
status, reason = _make(src, dst, cross_device)
return status, reason, [ident, _identity(dst)] if status == "linked" else None
class HardlinkPlugin(Plugin[Options]):
config_model = Options
def to_local(self, path: str, downloader: str) -> str:
best: tuple[str, str] | None = None
for m in self.config.path_map:
prefix = m.from_.rstrip("/")
if m.downloader != downloader:
continue
if path != prefix and not path.startswith(f"{prefix}/"):
continue
if best is None or len(prefix) > len(best[0]):
best = (prefix, m.to.rstrip("/"))
if best is None:
return path
return best[1] + path[len(best[0]) :]
async def link(self, src: Path) -> tuple[Status, str]:
try:
rel = Path(os.path.normpath(src)).relative_to(self.config.source_root)
except ValueError:
return "failed", f"不在下载根目录 {self.config.source_root} 下"
dst = Path(self.config.library_root) / rel
key = f"link:{dst}"
owned = await self.ctx.kv.get(key)
try:
status, reason, record = await asyncio.to_thread(
place, src, dst, owned, self.config.cross_device
)
except OSError as e:
return "failed", str(e)
if record is not None and record != owned:
await self.ctx.kv.set(key, record)
return status, reason
@subscribe(TorrentOrganized.kind, timeout=LINK_TIMEOUT)
async def on_organized(self, event: TorrentOrganized) -> None:
problems: list[str] = []
for f in event.files:
status, reason = await self.link(
Path(self.to_local(f.path, event.downloader_id))
)
if status in ("conflict", "failed"):
problems.append(f"{f.path}:{reason}")
if problems:
self.ctx.log.warning("种子 %s 未完成链接:%s", event.torrent_hash, problems)
self.ctx.bus.publish(
HardlinkFailed(
torrent_hash=event.torrent_hash, files="\n".join(problems)
)
)
async def backfill(self) -> dict[Status, int]:
"""把下载根目录下已有的正片与字幕补链到媒体库(只在用户触发时运行)。"""
root = Path(self.config.source_root)
files = await asyncio.to_thread(
lambda: sorted(
p
for p in root.rglob("*")
if p.is_file() and p.suffix.lower() in BACKFILL_SUFFIXES
)
)
counts: dict[Status, int] = {
"linked": 0,
"exists": 0,
"conflict": 0,
"failed": 0,
}
for src in files:
status, _ = await self.link(src)
counts[status] += 1
self.ctx.log.info("补链完成:%s", counts)
return counts
@provider(points.API_ROUTER, id="api")
def api(self) -> APIRouter:
router = APIRouter()
@router.post("/backfill")
async def backfill() -> dict[Status, int]:
return await self.backfill()
return router

View File

@@ -0,0 +1,10 @@
[plugin]
id = "hardlink"
name = "硬链接到媒体库"
version = "1.0.0"
sdk = ">=0.4,<1"
entry = "hardlink_plugin:HardlinkPlugin"
description = "整理完成后把正片与字幕硬链接到媒体库目录,下载目录保持原样继续做种"
authors = ["AutoBangumi"]
permissions = ["fs.write"]
default_enabled = false

View File

@@ -0,0 +1,107 @@
"""内置插件:种子整理完成后通知媒体服务器刷新媒体库(订阅 ``torrent.organized``)。
收到第一个事件后等待 ``delay`` 秒,期间整理完成的所有种子合并成一次刷新请求。
未填写服务器地址或 API Key 时什么也不做,所以默认启用也无副作用。
``torrent.organized`` 投递为「至少一次」:重命名方式为 none 的种子每次进程
重启后会再发布一次,已配置时重启后最多多出一次(合并后的)刷新。
"""
import asyncio
from collections.abc import Callable
from typing import Literal
import httpx
from pydantic import BaseModel, Field
from ab_sdk import Plugin, PluginContext, secret_field, subscribe
from ab_sdk.events import TorrentOrganized
ServerType = Literal["jellyfin", "emby", "plex"]
class Options(BaseModel):
server: ServerType = Field("jellyfin", title="服务器类型")
url: str = Field(
"",
title="服务器地址",
description="如 http://192.168.1.10:8096(Plex 默认端口 32400);留空则不刷新",
)
api_key: str = secret_field(
title="API Key",
description="Jellyfin / Emby 的 API Key,或 Plex 的 X-Plex-Token",
)
delay: float = Field(
30,
ge=0,
le=3600,
title="延迟(秒)",
description="收到整理完成事件后等待多久再刷新,期间的事件合并为一次刷新",
)
def build_request(options: Options) -> tuple[str, str, dict[str, str]]:
"""返回 (HTTP 方法, URL, 请求头)。"""
base = options.url.strip().rstrip("/")
key = options.api_key
if options.server == "plex":
return "GET", f"{base}/library/sections/all/refresh", {"X-Plex-Token": key}
if options.server == "emby":
return "POST", f"{base}/emby/Library/Refresh", {"X-Emby-Token": key}
return (
"POST",
f"{base}/Library/Refresh",
{"Authorization": f'MediaBrowser Token="{key}"'},
)
class MediaServerRefresh(Plugin[Options]):
config_model = Options
def __init__(self, ctx: PluginContext) -> None:
super().__init__(ctx)
self._pending: asyncio.Task[None] | None = None
# 测试可替换为带 MockTransport 的客户端
self.client_factory: Callable[[], httpx.AsyncClient] = lambda: (
httpx.AsyncClient(timeout=10)
)
@property
def configured(self) -> bool:
return bool(self.config.url.strip() and self.config.api_key)
async def teardown(self) -> None:
if self._pending is not None and not self._pending.done():
self._pending.cancel()
try:
await self._pending
except asyncio.CancelledError:
pass
self._pending = None
@subscribe(TorrentOrganized.kind)
async def on_organized(self, event: TorrentOrganized) -> None:
if not self.configured:
return
if self._pending is None or self._pending.done():
self.ctx.log.debug("种子 %s 整理完成,计划刷新媒体库", event.torrent_hash)
self._pending = asyncio.create_task(self._refresh_later())
async def _refresh_later(self) -> None:
await asyncio.sleep(self.config.delay)
try:
await self.refresh()
except Exception as e:
self.ctx.log.warning("刷新媒体库失败:%s", e)
async def refresh(self) -> bool:
method, url, headers = build_request(self.config)
async with self.client_factory() as client:
resp = await client.request(method, url, headers=headers)
if resp.is_success:
self.ctx.log.info("已请求 %s 刷新媒体库", self.config.server)
return True
self.ctx.log.warning(
"%s 刷新媒体库失败:HTTP %s", self.config.server, resp.status_code
)
return False

View File

@@ -0,0 +1,9 @@
[plugin]
id = "media-server-refresh"
name = "媒体库刷新"
version = "1.0.0"
sdk = ">=0.4,<1"
entry = "media_server_refresh:MediaServerRefresh"
description = "种子整理完成后通知 Jellyfin / Emby / Plex 刷新媒体库;填写服务器地址与 API Key 后生效"
authors = ["AutoBangumi"]
permissions = ["network"]

View File

@@ -0,0 +1,8 @@
[plugin]
id = "rename"
name = "重命名"
version = "1.0.0"
sdk = ">=0.4,<1"
entry = "rename_plugin:RenamePlugin"
description = "提供重命名方式 pn、advance 与自定义模板 template。停用后所有番剧按 none 处理(保留原文件名)"
authors = ["AutoBangumi"]

View File

@@ -0,0 +1,140 @@
"""内置插件:重命名方式 ``pn``、``advance`` 与 ``template``(``rename_strategy``)。
``pn`` / ``advance`` 的输出与 3.x 逐字节一致。``template`` 用 Jinja2 沙箱模板
渲染文件名主体(不含扩展名),字幕自动追加 ``.语言``,最后追加扩展名。
注意:title / bangumi_name 来自已存在于磁盘上的文件 / 文件夹名(单个路径分量),
不做保留字符清洗——追加清洗会让既有做种库(如含 ":" 的标题)在升级后被整库
批量重命名 (#721 评审)。电影 / 剧场版为 ``Title (Year).ext``,不使用 SxxExx。
"""
from jinja2 import StrictUndefined, Template, TemplateError
from jinja2.sandbox import SandboxedEnvironment
from pydantic import BaseModel, Field, field_validator
from ab_sdk import Plugin, PluginContext, points, provider
from ab_sdk.rename import RenameInput, RenameSkipped, RenameStrategy, pad
DEFAULT_TEMPLATE = "{{ title }} S{{ season|pad(2) }}E{{ episode|pad(2) }}"
# 保存配置时用它试渲染一次,渲染不出合法文件名的模板直接拒绝
SAMPLE = RenameInput(
kind="media",
media_path="[Group] Sample - 01 [1080p].mkv",
title="Sample",
bangumi_name="Sample (2024)",
season=1,
episode=1,
suffix=".mkv",
group="Group",
)
# StrictUndefined:写错变量名在保存时就报错,而不是渲染出空字符串
_env = SandboxedEnvironment(undefined=StrictUndefined, autoescape=False)
_env.filters["pad"] = pad
def _language(f: RenameInput) -> str:
return f".{f.language}" if f.kind == "subtitle" else ""
def standard_name(base: str, f: RenameInput) -> str:
"""``{base} SxxEyy[.语言].ext``;电影为 ``{base}[.语言].ext``。"""
if f.episode_type == "movie":
return f"{base}{_language(f)}{f.suffix}"
return f"{base} S{pad(f.season)}E{pad(f.episode)}{_language(f)}{f.suffix}"
class PnRename:
"""``pn``:以文件名解析出的标题命名。"""
def target_name(self, f: RenameInput) -> str:
return standard_name(f.title, f)
class AdvanceRename:
"""``advance``:以番剧文件夹名命名。"""
def target_name(self, f: RenameInput) -> str:
return standard_name(f.bangumi_name, f)
class TemplateRename:
"""``template``:渲染失败或文件名不安全时抛出 RenameSkipped,宿主保留原名
并通知用户。绝不退回到 pn。"""
def __init__(self, template: Template) -> None:
self._template = template
def target_name(self, f: RenameInput) -> str:
try:
stem = self._template.render(
title=f.title,
bangumi_name=f.bangumi_name,
season=f.season,
episode=f.episode,
episode_type=f.episode_type,
group=f.group or "",
kind=f.kind,
language=f.language,
).strip()
except Exception as e:
raise RenameSkipped(f"模板渲染失败:{e}") from None
if not stem or not stem.strip("."):
raise RenameSkipped("模板渲染结果为空")
if any(c in "/\\" or ord(c) < 32 for c in stem):
raise RenameSkipped(f"模板渲染结果含路径分隔符或控制字符:{stem!r}")
return f"{stem}{_language(f)}{f.suffix}"
def compile_template(source: str) -> TemplateRename:
"""编译并用示例文件试渲染;不合法时抛出 ValueError(保存配置时返回 422)。"""
try:
strategy = TemplateRename(_env.from_string(source))
except TemplateError as e:
raise ValueError(f"模板语法错误:{e}") from None
try:
strategy.target_name(SAMPLE)
except RenameSkipped as e:
raise ValueError(str(e)) from None
return strategy
class Options(BaseModel):
template: str = Field(
DEFAULT_TEMPLATE,
title="文件名模板",
description=(
"重命名方式选择 template 时使用的 Jinja2 模板,渲染文件名主体(不含"
"扩展名,字幕会自动追加语言)。可用变量:title、bangumi_name、season、"
"episode、episode_type(episode / movie / special)、group、kind"
"(media / subtitle)、language;过滤器 pad(n) 把数字补零到 n 位。"
"渲染失败或结果含路径分隔符的文件保留原名,并发送通知。"
),
)
@field_validator("template")
@classmethod
def _check_template(cls, value: str) -> str:
compile_template(value)
return value
class RenamePlugin(Plugin[Options]):
config_model = Options
def __init__(self, ctx: PluginContext) -> None:
super().__init__(ctx)
self._template = compile_template(self.config.template)
@provider(points.RENAME_STRATEGY, id="pn")
def pn_strategy(self) -> RenameStrategy:
return PnRename()
@provider(points.RENAME_STRATEGY, id="advance")
def advance_strategy(self) -> RenameStrategy:
return AdvanceRename()
@provider(points.RENAME_STRATEGY, id="template")
def template_strategy(self) -> RenameStrategy:
return self._template

View File

@@ -40,6 +40,42 @@ def _reset_downloader_client_cache():
_reset_client_cache()
# ---------------------------------------------------------------------------
# Built-in rename plugin
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def _rename_plugin_cls():
from module.plugin.loader import BUILTIN_ROOT, discover
candidates, _ = discover(
local_root=BUILTIN_ROOT / "__missing__", entry_point_group="ab-test-none"
)
return {c.manifest.id: c for c in candidates}["rename"].load()
@pytest.fixture(autouse=True)
def _builtin_rename_plugin(_rename_plugin_cls, tmp_path):
"""内置插件 rename 默认启用:与生产一致,单元测试里 pn / advance /
template 也已登记在进程级注册表中。"""
from ab_sdk.hooks import PROVIDER_ATTR
from ab_sdk.testing import create_plugin
from module.plugin import host
from module.plugin.registry import ProviderEntry
plugin, _ = create_plugin(_rename_plugin_cls, plugin_id="rename", data_dir=tmp_path)
registry = host.get_registry()
for name, member in vars(_rename_plugin_cls).items():
spec = getattr(member, PROVIDER_ATTR, None)
if spec is not None:
registry.add_provider(
spec.point, ProviderEntry("rename", spec.id, getattr(plugin, name))
)
yield
registry.remove_plugin("rename")
# ---------------------------------------------------------------------------
# Database Fixtures
# ---------------------------------------------------------------------------

View File

@@ -153,6 +153,12 @@ async def test_bangumi_delete_all_with_fk_references_succeeds(db_session):
# ---------------------------------------------------------------------------
async def _search_one(db: TorrentDatabase, qb_hash: str) -> Torrent | None:
rows = await db.search_by_qb_hashes([qb_hash])
assert len(rows) <= 1
return rows[0] if rows else None
async def test_torrent_search_by_qb_hash(db_session):
"""Test searching torrent by qBittorrent hash."""
db = TorrentDatabase(db_session)
@@ -166,7 +172,7 @@ async def test_torrent_search_by_qb_hash(db_session):
await db.add(torrent)
# Search by qb_hash
result = await db.search_by_qb_hash("abc123def456")
result = await _search_one(db, "abc123def456")
assert result is not None
assert result.name == torrent.name
assert result.qb_hash == "abc123def456"
@@ -176,7 +182,7 @@ async def test_torrent_search_by_qb_hash_not_found(db_session):
"""Test searching non-existent qb_hash returns None."""
db = TorrentDatabase(db_session)
result = await db.search_by_qb_hash("nonexistent_hash")
result = await _search_one(db, "nonexistent_hash")
assert result is None
@@ -197,7 +203,7 @@ async def test_torrent_with_bangumi_id(db_session):
await db.add(torrent)
# Search and verify bangumi_id is preserved
result = await db.search_by_qb_hash("hash_for_bangumi_42")
result = await _search_one(db, "hash_for_bangumi_42")
assert result is not None
assert result.bangumi_id == 42
@@ -216,16 +222,16 @@ async def test_torrent_qb_hash_index_efficient(db_session):
await db.add_all(torrents)
# Verify we can find specific torrents by hash
result = await db.search_by_qb_hash("hash_5")
result = await _search_one(db, "hash_5")
assert result is not None
assert result.name == "Torrent 5"
result = await db.search_by_qb_hash("hash_9")
result = await _search_one(db, "hash_9")
assert result is not None
assert result.name == "Torrent 9"
# Non-existent hash
result = await db.search_by_qb_hash("hash_100")
result = await _search_one(db, "hash_100")
assert result is None

View File

@@ -0,0 +1,294 @@
"""内置插件 hardlink:订阅 torrent.organized,把整理好的文件链接到媒体库。"""
import asyncio
import errno
import os
import shutil
import threading
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from ab_sdk.events import OrganizedFile, TorrentOrganized
from ab_sdk.testing import create_plugin
from module.plugin.loader import BUILTIN_ROOT, discover
SEASON = "Bangumi/Anime (2024)/Season 1"
def load_candidate():
candidates, errors = discover(
local_root=BUILTIN_ROOT / "__missing__", entry_point_group="ab-test-none"
)
assert errors == []
return {c.manifest.id: c for c in candidates}["hardlink"]
@pytest.fixture
def roots(tmp_path) -> tuple[Path, Path]:
downloads, library = tmp_path / "downloads", tmp_path / "library"
(downloads / SEASON).mkdir(parents=True)
return downloads, library
def make_plugin(roots: tuple[Path, Path], **options):
downloads, library = roots
options = {
"path_map": [{"from": "/dl", "to": str(downloads)}],
"source_root": str(downloads),
"library_root": str(library),
**options,
}
return create_plugin(load_candidate().load(), options, plugin_id="hardlink")
def write(path: Path, content: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path
def organized(*names: str, downloader: str = "default") -> TorrentOrganized:
return TorrentOrganized(
torrent_hash="h1",
bangumi_id=7,
files=tuple(OrganizedFile(f"/dl/{SEASON}/{n}", "media") for n in names),
downloader_id=downloader,
)
def exdev(src, dst):
raise OSError(errno.EXDEV, "Invalid cross-device link")
def test_hardlink_builtin_disabled_by_default():
candidate = load_candidate()
assert candidate.source == "builtin"
assert candidate.manifest.default_enabled is False
async def test_on_organized_maps_longest_prefix_of_same_downloader_and_links(
roots, tmp_path
):
downloads, library = roots
wrong = str(tmp_path / "wrong")
plugin, ctx = make_plugin(
roots,
path_map=[
{"from": "/dl", "to": wrong},
{"from": "/dl/Bangumi", "to": str(downloads / "Bangumi")},
{"downloader": "other", "from": "/dl/Bangumi/Anime (2024)", "to": wrong},
],
)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
sub = write(downloads / SEASON / "Anime S01E01.zh.ass", "sub")
await plugin.on_organized(organized(src.name, sub.name))
assert os.path.samefile(src, library / SEASON / src.name)
assert os.path.samefile(sub, library / SEASON / sub.name)
assert ctx.bus.published == []
@pytest.mark.parametrize("mode", ["copy", "symlink", "skip"])
async def test_on_organized_cross_device_applies_policy(roots, monkeypatch, mode):
downloads, library = roots
plugin, ctx = make_plugin(roots, cross_device=mode)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
monkeypatch.setattr(os, "link", exdev)
await plugin.on_organized(organized(src.name))
dst = library / SEASON / src.name
if mode == "skip":
assert not dst.exists()
[event] = ctx.bus.published
assert event.kind == "hardlink.failed" and src.name in event.files
return
assert dst.read_text() == "v1"
assert dst.is_symlink() is (mode == "symlink")
assert ctx.bus.published == []
async def test_on_organized_redelivery_does_not_copy_again(roots, monkeypatch):
downloads, library = roots
plugin, ctx = make_plugin(roots)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
monkeypatch.setattr(os, "link", exdev)
copies: list[object] = []
def copy2(a, b):
copies.append(b)
return shutil.copyfile(a, b)
monkeypatch.setattr(shutil, "copy2", copy2)
await plugin.on_organized(organized(src.name))
await plugin.on_organized(organized(src.name))
assert len(copies) == 1
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)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
await plugin.on_organized(organized(src.name))
# V2 经 revision saga 替换后落在同一规范路径,但已是另一个文件
src.unlink()
write(src, "v2")
await plugin.on_organized(organized(src.name))
dst = library / SEASON / src.name
assert os.path.samefile(src, dst) and dst.read_text() == "v2"
assert [p.name for p in dst.parent.iterdir()] == [src.name]
assert ctx.bus.published == []
async def test_on_organized_problem_files_kept_and_notified_once(roots, tmp_path):
downloads, library = roots
plugin, ctx = make_plugin(
roots,
path_map=[
{"from": "/dl", "to": str(downloads)},
{"from": f"/dl/{SEASON}/outside", "to": str(tmp_path / "elsewhere")},
],
)
taken = write(downloads / SEASON / "Anime S01E01.mkv", "new")
write(library / SEASON / taken.name, "user file")
write(tmp_path / "elsewhere" / "x.mkv", "x")
await plugin.on_organized(organized(taken.name, "outside/x.mkv"))
assert (library / SEASON / taken.name).read_text() == "user file"
[event] = ctx.bus.published
assert event.kind == "hardlink.failed" and event.torrent_hash == "h1"
assert taken.name in event.files and "x.mkv" in event.files
def test_backfill_route_links_existing_files_and_counts(roots):
downloads, library = roots
plugin, ctx = make_plugin(roots)
for name in ("new.mkv", "new.zh.ass", "foreign.mkv", "note.nfo", "part.mkv.!qB"):
write(downloads / SEASON / name, name)
write(library / SEASON / "foreign.mkv", "user file")
linked = write(downloads / SEASON / "linked.mkv", "linked")
(library / SEASON).mkdir(parents=True, exist_ok=True)
os.link(linked, library / SEASON / "linked.mkv")
app = FastAPI()
app.include_router(plugin.api())
resp = TestClient(app).post("/backfill")
assert resp.json() == {"linked": 2, "exists": 1, "conflict": 1, "failed": 0}
assert os.path.samefile(
downloads / SEASON / "new.zh.ass", library / SEASON / "new.zh.ass"
)
assert not (library / SEASON / "note.nfo").exists()
assert ctx.bus.published == []
@pytest.mark.parametrize(
"overrides",
[
{"source_root": "downloads"},
{"library_root": "library"},
{"library_root": "/downloads/library", "source_root": "/downloads"},
],
)
def test_options_invalid_roots_rejected(overrides):
model = load_candidate().load().config_model
with pytest.raises(ValidationError):
model.model_validate(
{"source_root": "/downloads", "library_root": "/media", **overrides}
)
async def test_on_organized_unrecorded_own_copy_adopted_and_upgraded(
roots, monkeypatch
):
downloads, library = roots
plugin, ctx = make_plugin(roots)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
monkeypatch.setattr(os, "link", exdev)
# 上次复制已放到目标位置,但协程被取消或进程退出,所有权没来得及记录
dst = library / SEASON / src.name
dst.parent.mkdir(parents=True)
shutil.copy2(src, dst)
await plugin.on_organized(organized(src.name))
src.unlink()
write(src, "v2")
await plugin.on_organized(organized(src.name))
assert dst.read_text() == "v2"
assert ctx.bus.published == []
async def test_on_organized_user_file_at_former_link_kept(roots):
downloads, library = roots
plugin, ctx = make_plugin(roots)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
await plugin.on_organized(organized(src.name))
# 用户删掉插件的链接,在同一位置放了自己的文件
dst = library / SEASON / src.name
dst.unlink()
write(dst, "user file")
src.unlink()
write(src, "v2")
await plugin.on_organized(organized(src.name))
assert dst.read_text() == "user file"
[event] = ctx.bus.published
assert event.kind == "hardlink.failed" and src.name in event.files
async def test_link_concurrent_copies_of_same_target_all_succeed(roots, monkeypatch):
downloads, library = roots
plugin, ctx = make_plugin(roots)
src = write(downloads / SEASON / "Anime S01E01.mkv", "v1")
monkeypatch.setattr(os, "link", exdev)
both_copying = threading.Barrier(2, timeout=5)
def copy2(a, b):
both_copying.wait()
return shutil.copyfile(a, b)
monkeypatch.setattr(shutil, "copy2", copy2)
# 补链与订阅同时处理同一个目标
results = await asyncio.gather(plugin.link(src), plugin.link(src))
assert [status for status, _ in results] == ["linked", "linked"]
dst = library / SEASON / src.name
assert dst.read_text() == "v1"
assert [p.name for p in dst.parent.iterdir()] == [src.name]

View File

@@ -0,0 +1,136 @@
"""内置插件 media-server-refresh:订阅 torrent.organized,合并后请求媒体服务器刷新。"""
import asyncio
import logging
import httpx
import pytest
from ab_sdk.events import OrganizedFile, TorrentOrganized
from ab_sdk.testing import create_plugin
from module.plugin.loader import BUILTIN_ROOT, discover
def load_candidate():
candidates, errors = discover(
local_root=BUILTIN_ROOT / "__missing__", entry_point_group="ab-test-none"
)
assert errors == []
return {c.manifest.id: c for c in candidates}["media-server-refresh"]
def organized(n: int = 1) -> TorrentOrganized:
return TorrentOrganized(
torrent_hash=f"h{n}",
bangumi_id=1,
files=(OrganizedFile(f"/dl/A S01E0{n}.mkv", "media"),),
)
def make_plugin(options, handler=None):
plugin, _ = create_plugin(
load_candidate().load(), options, plugin_id="media-server-refresh"
)
requests: list[httpx.Request] = []
def record(request: httpx.Request) -> httpx.Response:
requests.append(request)
return handler(request) if handler else httpx.Response(204)
plugin.client_factory = lambda: httpx.AsyncClient(
transport=httpx.MockTransport(record)
)
return plugin, requests
def test_media_server_refresh_builtin_enabled_by_default_with_secret_key():
candidate = load_candidate()
assert candidate.source == "builtin"
assert candidate.manifest.default_enabled is True
schema = candidate.load().config_model.model_json_schema()
assert schema["properties"]["api_key"]["secret"] is True
@pytest.mark.parametrize(
"options", [{}, {"url": "http://jf:8096"}, {"api_key": "k"}, {"url": " "}]
)
async def test_on_organized_unconfigured_is_noop(options):
plugin, requests = make_plugin(options)
await plugin.on_organized(organized())
assert plugin._pending is None
await plugin.teardown()
assert requests == []
@pytest.mark.parametrize(
("server", "method", "url", "header", "value"),
[
(
"jellyfin",
"POST",
"http://srv:8096/Library/Refresh",
"authorization",
'MediaBrowser Token="k3y"',
),
("emby", "POST", "http://srv:8096/emby/Library/Refresh", "x-emby-token", "k3y"),
(
"plex",
"GET",
"http://srv:8096/library/sections/all/refresh",
"x-plex-token",
"k3y",
),
],
)
async def test_on_organized_burst_coalesces_into_one_refresh(
server, method, url, header, value
):
plugin, requests = make_plugin(
{"server": server, "url": "http://srv:8096/", "api_key": "k3y", "delay": 0.05}
)
for n in range(1, 4):
await plugin.on_organized(organized(n))
assert plugin._pending is not None
await asyncio.wait_for(plugin._pending, 1)
assert len(requests) == 1
assert requests[0].method == method
assert str(requests[0].url) == url
assert requests[0].headers[header] == value
# 刷新完成后的新事件会再排一次
await plugin.on_organized(organized(4))
await asyncio.wait_for(plugin._pending, 1)
assert len(requests) == 2
async def test_refresh_http_error_returns_false():
plugin, requests = make_plugin(
{"url": "http://srv", "api_key": "k"}, handler=lambda r: httpx.Response(401)
)
assert await plugin.refresh() is False
assert len(requests) == 1
async def test_on_organized_connection_error_logged_without_secret(caplog):
def explode(request):
raise httpx.ConnectError("refused")
plugin, _ = make_plugin(
{"url": "http://srv", "api_key": "s3cr3t-key", "delay": 0}, handler=explode
)
with caplog.at_level(logging.WARNING):
await plugin.on_organized(organized())
assert plugin._pending is not None
await asyncio.wait_for(plugin._pending, 1)
assert "刷新媒体库失败" in caplog.text
assert "s3cr3t-key" not in caplog.text
async def test_teardown_pending_refresh_cancelled():
plugin, requests = make_plugin({"url": "http://srv", "api_key": "k", "delay": 60})
await plugin.on_organized(organized())
pending = plugin._pending
assert pending is not None
await plugin.teardown()
assert pending.cancelled()
assert requests == []

View File

@@ -0,0 +1,402 @@
"""P4 organize 扩展点:rename_strategy 的解析、回退与跳过通知,conflict_policy,
以及 file.renamed / torrent.organized 事件。"""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from ab_sdk import points
from ab_sdk.events import (
FileRenamed,
OrganizedFile,
RenameSkippedEvent,
TorrentOrganized,
)
from ab_sdk.rename import (
ConflictRequest,
RenameInput,
RenameSkipped,
Revision,
RevisionTask,
)
from ab_sdk.testing import RecordingBus, create_plugin
from module.downloader import DownloadClient
from module.manager import renamer as renamer_module
from module.manager.renamer import Renamer
from module.manager.revision_policy import CoreConflictPolicy
from module.models import EpisodeFile, SubtitleFile
from module.models.config import Plugins
from module.plugin import PluginManager, host
from module.plugin.loader import BUILTIN_ROOT, discover
from module.plugin.registry import ProviderEntry
from module.plugin.runner import CircuitBreaker, HookRunner
SAVE_PATH = "/downloads/Bangumi/Anime (2024)/Season 1"
NAME = "[Sub] Anime - 01.mkv"
INFO = {"hash": "h1", "name": NAME, "save_path": SAVE_PATH, "tags": "ab:7"}
SUB = "[Sub] Anime - 01.zh.ass"
def parse(torrent_path: str, **kwargs) -> EpisodeFile | SubtitleFile:
if torrent_path == SUB:
return SubtitleFile(
media_path=SUB,
title="Anime",
season=1,
episode=1,
language="zh",
suffix=".ass",
)
return EpisodeFile(
media_path=torrent_path, title="Anime", season=1, episode=1, suffix=".mkv"
)
@pytest.fixture
def plugins(monkeypatch):
"""全新的进程级注册表、runner 与记录事件的总线。"""
monkeypatch.setattr(host, "_registry", None)
registry = host.get_registry()
tripped: list[str] = []
breaker = CircuitBreaker(2, on_trip=lambda plugin_id, _: tripped.append(plugin_id))
monkeypatch.setattr(host, "_runner", HookRunner(registry, breaker))
bus = RecordingBus()
monkeypatch.setattr(host, "_bus", bus)
monkeypatch.setattr(renamer_module, "_organized_published", {})
monkeypatch.setattr(renamer_module, "_skip_notified", set())
return SimpleNamespace(registry=registry, tripped=tripped, bus=bus)
def add_strategy(registry, provider_id: str, target_name, plugin_id: str = "ext"):
impl = SimpleNamespace(target_name=target_name)
registry.add_provider(
points.RENAME_STRATEGY, ProviderEntry(plugin_id, provider_id, lambda: impl)
)
@pytest.fixture
def renamer(mock_qb_client):
with patch("module.downloader.download_client.settings") as mock_settings:
mock_settings.downloader.type = "qbittorrent"
mock_settings.downloader.host = "localhost:8080"
mock_settings.downloader.path = "/downloads/Bangumi"
with patch(
"module.downloader.download_client.DownloadClient._DownloadClient__getClient",
return_value=mock_qb_client,
):
client = DownloadClient()
client.client = mock_qb_client
mock_qb_client.torrents_info.return_value = [INFO]
mock_qb_client.torrents_files.return_value = [
{"name": NAME},
{"name": SUB},
]
return Renamer(client)
async def run(renamer: Renamer, method: str) -> None:
with (
patch.object(renamer._parser, "torrent_parser", side_effect=parse),
patch("module.manager.renamer.settings") as mock_settings,
patch("module.downloader.path.settings") as mock_path_settings,
):
mock_settings.bangumi_manage.rename_method = method
mock_settings.bangumi_manage.remove_bad_torrent = False
mock_path_settings.downloader.path = "/downloads/Bangumi"
await renamer.rename()
def published(bus: RecordingBus, cls: type) -> list:
return [e for e in bus.published if isinstance(e, cls)]
async def test_rename_unregistered_method_keeps_names_without_tag(plugins, renamer):
await run(renamer, "jellyfin-style")
renamer.client.client.torrents_rename_file.assert_not_called()
renamer.client.client.add_tag.assert_not_called()
assert published(plugins.bus, TorrentOrganized)
async def test_rename_plugin_strategy_renames_and_publishes_events(plugins, renamer):
seen: list[RenameInput] = []
def target_name(f: RenameInput) -> str:
seen.append(f)
language = f".{f.language}" if f.kind == "subtitle" else ""
return f"{f.title} E{f.episode}{language}{f.suffix}"
add_strategy(plugins.registry, "custom", target_name)
await run(renamer, "custom")
assert [(f.kind, f.bangumi_name) for f in seen] == [
("media", "Anime (2024)"),
("subtitle", "Anime (2024)"),
]
renamer.client.client.add_tag.assert_awaited_once_with("h1", "ab:renamed")
assert published(plugins.bus, FileRenamed) == [
FileRenamed(
bangumi_id=7,
old_path=f"{SAVE_PATH}/{NAME}",
new_path=f"{SAVE_PATH}/Anime E1.mkv",
file_kind="media",
),
FileRenamed(
bangumi_id=7,
old_path=f"{SAVE_PATH}/{SUB}",
new_path=f"{SAVE_PATH}/Anime E1.zh.ass",
file_kind="subtitle",
),
]
assert published(plugins.bus, TorrentOrganized) == [
TorrentOrganized(
torrent_hash="h1",
bangumi_id=7,
files=(
OrganizedFile(f"{SAVE_PATH}/Anime E1.mkv", "media"),
OrganizedFile(f"{SAVE_PATH}/Anime E1.zh.ass", "subtitle"),
),
)
]
async def test_rename_plugin_removed_mid_tick_keeps_tick_strategy(plugins, renamer):
def target_name(f: RenameInput) -> str:
# 本轮处理中插件被停用(重载、熔断):策略已从注册表移除
plugins.registry.remove_plugin("ext")
language = f".{f.language}" if f.kind == "subtitle" else ""
return f"{f.title} E{f.episode}{language}{f.suffix}"
add_strategy(plugins.registry, "custom", target_name)
await run(renamer, "custom")
# 打 ab:renamed 标签与生成文件名必须来自同一个策略:字幕也按本轮策略改名
renamed = [
c.kwargs["new_path"]
for c in renamer.client.client.torrents_rename_file.await_args_list
]
assert renamed == ["Anime E1.mkv", "Anime E1.zh.ass"]
renamer.client.client.add_tag.assert_awaited_once_with("h1", "ab:renamed")
async def test_rename_none_publishes_organized_once_per_process(plugins, renamer):
await run(renamer, "none")
await run(renamer, "none")
renamer.client.client.torrents_rename_file.assert_not_called()
assert published(plugins.bus, FileRenamed) == []
organized = published(plugins.bus, TorrentOrganized)
assert len(organized) == 1
assert organized[0].files[0] == OrganizedFile(f"{SAVE_PATH}/{NAME}", "media")
def _skip(f: RenameInput) -> str:
raise RenameSkipped("模板渲染失败:'titel' is undefined")
def _crash(f: RenameInput) -> str:
raise ValueError("boom")
@pytest.mark.parametrize("with_runner", [True, False])
@pytest.mark.parametrize(
("target_name", "counted"),
[(_skip, False), (_crash, True), (lambda f: "", True), (lambda f: 3, True)],
)
async def test_rename_strategy_failure_keeps_name_and_notifies_once(
plugins, renamer, monkeypatch, target_name, counted, with_runner
):
if not with_runner:
# 未设置 runner(脚本、CLI):插件失败只记录日志,不计入熔断
monkeypatch.setattr(host, "_runner", None)
counted = False
add_strategy(plugins.registry, "custom", target_name)
await run(renamer, "custom")
await run(renamer, "custom")
renamer.client.client.torrents_rename_file.assert_not_called()
renamer.client.client.add_tag.assert_not_called()
assert published(plugins.bus, TorrentOrganized) == []
events = [e for e in renamer.events if isinstance(e, RenameSkippedEvent)]
assert len(events) == 1
assert events[0].task_id == "h1" and events[0].strategy == "custom"
assert plugins.tripped == (["ext"] if counted else [])
def _task(file_count: int = 1, revision: int | None = 1) -> RevisionTask:
identity = (
None
if revision is None
else Revision(7, "episode", 1, 1, "ani", "1080p", revision)
)
return RevisionTask("h", "name", file_count, identity)
@pytest.mark.parametrize(
("owners", "incoming", "configured", "strict", "action", "reason"),
[
(
(_task(), _task()),
_task(),
"replace",
True,
"hold",
"canonical path has more than one downloader owner",
),
(
(_task(file_count=2),),
_task(),
"replace",
True,
"hold",
"automatic replacement requires two single-file torrents",
),
(
(_task(revision=None),),
_task(),
"replace",
True,
"hold",
"revision identity is incomplete",
),
(
(_task(),),
_task(),
"replace",
False,
"hold",
"existing and incoming releases are not a strict revision upgrade",
),
((_task(),), _task(), "hold", True, "hold", "revision conflict policy is hold"),
((_task(),), _task(), "replace", True, "replace", ""),
],
)
def test_core_conflict_policy_decide_matches_3x_reasons(
owners, incoming, configured, strict, action, reason
):
decision = CoreConflictPolicy().decide(
ConflictRequest("t.mkv", incoming, owners, configured, strict)
)
assert (decision.action, decision.reason) == (action, reason)
# ---------------------------------------------------------------- built-in plugin
def load_rename_plugin():
candidates, errors = discover(
local_root=BUILTIN_ROOT / "__missing__", entry_point_group="ab-test-none"
)
assert errors == []
return {c.manifest.id: c for c in candidates}["rename"]
def template_strategy(tmp_path, template: str):
plugin, _ = create_plugin(
load_rename_plugin().load(), {"template": template}, data_dir=tmp_path
)
return plugin.template_strategy()
def rename_input(**overrides) -> RenameInput:
fields = {
"kind": "media",
"media_path": NAME,
"title": "Anime",
"bangumi_name": "Anime (2024)",
"season": 1,
"episode": 1,
"suffix": ".mkv",
"group": "Sub",
}
return RenameInput(**{**fields, **overrides})
@pytest.mark.parametrize(
("template", "overrides", "expected"),
[
(None, {}, "Anime S01E01.mkv"),
(None, {"episode": 12.5}, "Anime S01E12.5.mkv"),
(
"{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(3) }}",
{"kind": "subtitle", "language": "zh-tw", "suffix": ".ass"},
"Anime - S01E001.zh-tw.ass",
),
(
"{% if episode_type == 'movie' %}{{ bangumi_name }}"
"{% else %}[{{ group }}] {{ title }} {{ episode }}{% endif %}",
{"episode_type": "movie"},
"Anime (2024).mkv",
),
],
)
def test_template_strategy_renders_stem_and_appends_suffix(
tmp_path, template, overrides, expected
):
plugin, _ = create_plugin(
load_rename_plugin().load(),
{} if template is None else {"template": template},
data_dir=tmp_path,
)
strategy = plugin.template_strategy()
assert strategy.target_name(rename_input(**overrides)) == expected
@pytest.mark.parametrize(
"template",
[
"{{ title ", # 语法错误
"{{ titel }}", # 未定义变量
"{{ title.__class__ }}", # 沙箱拒绝
"{{ group }}/{{ title }}", # 路径分隔符
"{{ '' }}", # 空文件名
"..",
],
)
def test_template_options_invalid_template_rejected(template):
options_model = load_rename_plugin().load().config_model
with pytest.raises(ValidationError):
options_model.model_validate({"template": template})
@pytest.mark.parametrize(
("template", "overrides"),
[
("{{ group }}", {"group": None}), # 运行时渲染为空
("{{ title }}", {"title": "a/b"}), # 运行时出现分隔符
("{{ (episode / season)|int }}", {"season": 0}), # 运行时渲染异常
],
)
def test_template_strategy_bad_runtime_name_raises_skipped(
tmp_path, template, overrides
):
strategy = template_strategy(tmp_path, template)
with pytest.raises(RenameSkipped):
strategy.target_name(rename_input(**overrides))
async def test_rename_plugin_loaded_by_manager_enabled_by_default(plugins, tmp_path):
manager = PluginManager(
SimpleNamespace(plugins=Plugins()),
registry=plugins.registry,
discover_fn=lambda: ([load_rename_plugin()], []),
data_root=tmp_path,
)
await manager.start()
try:
status = {s.id: s for s in manager.statuses()}["rename"]
assert status.state == "active"
strategies = plugins.registry.providers(points.RENAME_STRATEGY)
assert {pid: e.plugin_id for pid, e in strategies.items()} == {
"none": "core",
"pn": "rename",
"advance": "rename",
"template": "rename",
}
with pytest.raises(ValidationError):
manager.validate_options("rename", {"template": "{{ nope }}"})
finally:
await manager.stop()
assert set(plugins.registry.providers(points.RENAME_STRATEGY)) == {"none"}

View File

@@ -6,7 +6,7 @@ from typing import ClassVar
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from ab_sdk import points, secret_field
from ab_sdk.downloader import (
@@ -507,6 +507,28 @@ class TestPluginsApi:
save.assert_not_awaited()
assert "demo" not in settings.plugins.options
def test_update_plugin_validator_error_returns_422(self, authed_client, plugin_ctx):
"""field_validator 抛出的 ValueError 也返回 422(而不是序列化失败的 500)。"""
class Strict(BaseModel):
site: str = ""
@field_validator("site")
@classmethod
def _check(cls, value: str) -> str:
raise ValueError("bad site")
ctx, save = plugin_ctx
ctx.plugins.validate_options.side_effect = lambda _pid, opts: (
Strict.model_validate(opts)
)
response = authed_client.put(
"/api/v1/plugins/demo", json={"options": {"site": "x"}}
)
assert response.status_code == 422
assert "bad site" in response.text
save.assert_not_awaited()
def test_update_unknown_plugin(self, authed_client, plugin_ctx):
assert authed_client.put("/api/v1/plugins/x", json={}).status_code == 404
@@ -525,6 +547,7 @@ class TestPluginsApi:
"notifier": [],
"search_site": [],
"metadata_provider": [],
"rename_strategy": [],
}
def test_config_get_masks_plugin_secrets(self, authed_client, plugin_ctx):

View File

@@ -11,6 +11,7 @@ from typing import ClassVar, cast
import pytest
from ab_sdk import Event, Plugin, PluginDisabled, Verdict, hook, provider, subscribe
from ab_sdk.events import SystemEvent
from module.models.config import Plugins
from module.plugin import ExtensionPoint, ExtensionRegistry, RegistryError
from module.plugin.bus import EventBus
@@ -51,8 +52,11 @@ def write_plugin(
return plugin_dir
def manifest(plugin_id: str = "demo", sdk: str = ">=0.1,<1") -> PluginManifest:
return parse_manifest(MANIFEST.format(id=plugin_id, sdk=sdk, entry="x:Y"))
def manifest(
plugin_id: str = "demo", sdk: str = ">=0.1,<1", default_enabled: bool = True
) -> PluginManifest:
text = MANIFEST.format(id=plugin_id, sdk=sdk, entry="x:Y")
return parse_manifest(f"{text}default_enabled = {str(default_enabled).lower()}\n")
@dataclass(frozen=True, slots=True)
@@ -328,6 +332,33 @@ class TestHostContext:
ctx.bus.publish(PluginDisabled(plugin_id="x", reason="fake"))
await bus.close()
async def test_plugin_system_event_goes_through_notification_center(
self, tmp_path, monkeypatch
):
from module.notification.manager import NotificationManager
from module.plugin import context
@dataclass(frozen=True, slots=True)
class Synced(SystemEvent):
kind: ClassVar[str] = "demo.synced"
sent: list[Event] = []
async def send_event(self, event):
sent.append(event)
monkeypatch.setattr(NotificationManager, "send_event", send_event)
bus = EventBus()
on_bus: list[Event] = []
bus.subscribe("*", on_bus.append)
ctx = HostPluginContext("demo", None, bus, tmp_path)
ctx.bus.publish(Synced())
await asyncio.gather(*context._notify_tasks)
await bus.drain()
# send_event 自己负责发布到总线,PluginBus 不再重复入队
assert sent == [Synced()] and on_bus == []
await bus.close()
async def test_data_dir_created_lazily(self, tmp_path):
ctx = HostPluginContext("demo", None, EventBus(), tmp_path)
assert not (tmp_path / "demo").exists()
@@ -470,9 +501,12 @@ def candidate_for(
plugin_id: str = "demo",
source: str = "builtin",
sdk: str = ">=0.1,<1",
default_enabled: bool = True,
) -> PluginCandidate:
return PluginCandidate(
manifest=manifest(plugin_id, sdk), source=source, load=lambda: cls # type: ignore[arg-type]
manifest=manifest(plugin_id, sdk, default_enabled),
source=source, # type: ignore[arg-type]
load=lambda: cls,
)
@@ -558,10 +592,15 @@ class TestManager:
assert manager.statuses()[0].state == "active"
await manager.stop()
async def test_non_builtin_disabled_by_default(self, tmp_path):
@pytest.mark.parametrize(
"source, default_enabled", [("local", True), ("builtin", False)]
)
async def test_disabled_by_default(self, tmp_path, source, default_enabled):
recorder = Recorder()
manager, _ = make_manager(
candidate_for(build_plugin(recorder), source="local"),
candidate_for(
build_plugin(recorder), source=source, default_enabled=default_enabled
),
tmp_path=tmp_path,
allow_unsigned=True,
)
@@ -591,6 +630,26 @@ class TestManager:
assert len(seen) == 1
await manager.stop()
async def test_subscribe_timeout_overrides_bus_default(self, tmp_path):
seen: list[int] = []
class Slow(Plugin):
@subscribe("demo.happened", timeout=5)
async def on_demo(self, event):
await asyncio.sleep(0.05)
seen.append(event.value)
manager, _ = make_manager(candidate_for(Slow), tmp_path=tmp_path)
errors: list[str] = []
manager.bus = EventBus(
handler_timeout=0.01, on_error=lambda owner, reason: errors.append(reason)
)
await manager.start()
manager.bus.publish(DemoEvent(1))
await manager.bus.drain()
assert (seen, errors) == ([1], [])
await manager.stop()
async def test_failed_setup_rolls_back_registrations(self, tmp_path):
class Broken(Plugin):
@hook("torrent.filter")

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

View File

@@ -1,4 +1,4 @@
"""Tests for Renamer: gen_path, rename_file, rename_collection, rename flow."""
"""Tests for Renamer: gen_path, single-file / collection rename, rename flow."""
from unittest.mock import AsyncMock, MagicMock, patch
@@ -79,7 +79,7 @@ class TestGenPath:
language="zh",
suffix=".ass",
)
result = Renamer.gen_path(sub, "Bangumi", method="subtitle_pn")
result = Renamer.gen_path(sub, "Bangumi", method="pn")
assert result == "My Anime S01E03.zh.ass"
def test_subtitle_advance_method(self):
@@ -92,7 +92,7 @@ class TestGenPath:
language="zh-tw",
suffix=".srt",
)
result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_advance")
result = Renamer.gen_path(sub, "Bangumi Name", method="advance")
assert result == "Bangumi Name S02E07.zh-tw.srt"
def test_zero_padding_single_digit(self):
@@ -215,7 +215,7 @@ class TestGenPathMovie:
suffix=".ass",
episode_type="movie",
)
result = Renamer.gen_path(sub, "天气之子 (2019)", method="subtitle_advance")
result = Renamer.gen_path(sub, "天气之子 (2019)", method="advance")
assert result == "天气之子 (2019).zh.ass"
def test_gen_path_movie_group_tag_enabled_keeps_clean_name(self):
@@ -296,7 +296,7 @@ class TestGenPathGroupTagStability:
suffix=".ass",
)
with patch.object(settings.bangumi_manage, "group_tag", True):
result = Renamer.gen_path(sub, "Bangumi Name", method="subtitle_pn")
result = Renamer.gen_path(sub, "Bangumi Name", method="pn")
assert result == "My Anime S01E05.zh.ass"
def test_gen_path_group_tag_disabled_no_prefix(self):
@@ -329,11 +329,15 @@ class TestGenPathGroupTagStability:
# ---------------------------------------------------------------------------
# rename_file
# single-file torrent (rename() → _process_single_torrent)
# ---------------------------------------------------------------------------
class TestRenameFile:
class TestRenameSingleFile:
"""单文件种子走生产路径 rename() 的行为(原 rename_file 测试迁移而来)。"""
SAVE_PATH = "/downloads/Bangumi/My Anime/Season 1"
@pytest.fixture
def renamer(self, mock_qb_client):
"""Create Renamer with mocked internals."""
@@ -355,27 +359,62 @@ class TestRenameFile:
client.client = mock_qb_client
return Renamer(client)
async def _rename(
self,
renamer,
ep,
*,
media_path="old.mkv",
method="pn",
_hash="hash123",
tags="",
remove_bad_torrent=False,
) -> list[Notification]:
renamer.client.client.torrents_info.return_value = [
{
"hash": _hash,
"name": "[Sub] My Anime - 05.mkv",
"save_path": self.SAVE_PATH,
"tags": tags,
}
]
renamer.client.client.torrents_files.return_value = [
{"name": media_path, "size": 1}
]
with (
patch.object(renamer._parser, "torrent_parser", return_value=ep),
patch.object(
renamer,
"_batch_lookup_offsets",
AsyncMock(return_value={_hash: (0, "episode")}),
),
patch("module.manager.renamer.settings") as mock_settings,
patch("module.downloader.path.settings") as mock_path_settings,
):
mock_settings.bangumi_manage.rename_method = method
mock_settings.bangumi_manage.remove_bad_torrent = remove_bad_torrent
mock_path_settings.downloader.path = "/downloads/Bangumi"
return await renamer.rename()
async def test_successful_rename(self, renamer):
"""rename_file parses, generates new path, renames, returns Notification."""
"""解析、生成新路径、重命名并返回 Notification。"""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = True
result = await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
)
renamer.client.client.torrents_rename_file.return_value = True
result = await self._rename(renamer, ep)
assert result is not None
assert isinstance(result, Notification)
assert result.official_title == "My Anime"
assert result.season == 1
assert result.episode == 5
assert len(result) == 1
assert isinstance(result[0], Notification)
assert result[0].official_title == "My Anime"
assert result[0].season == 1
assert result[0].episode == 5
renamer.client.client.torrents_rename_file.assert_awaited_once_with(
torrent_hash="hash123",
old_path="old.mkv",
new_path="My Anime S01E05.mkv",
verify=True,
)
async def test_fractional_episode_notification_keeps_fraction(self, renamer):
"""半集重命名后通知里的集数保留小数 (#667)。"""
@@ -386,35 +425,19 @@ class TestRenameFile:
episode=12.5,
suffix=".mkv",
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = True
result = await renamer.rename_file(
torrent_name="[Sub] My Anime - 12.5.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
)
renamer.client.client.torrents_rename_file.return_value = True
result = await self._rename(renamer, ep)
assert result is not None
assert result.episode == 12.5
assert len(result) == 1
assert result[0].episode == 12.5
async def test_successful_rename_adds_renamed_tag(self, renamer):
"""重命名成功后给种子打 ab:renamed 标签,供外部脚本识别 (#147)。"""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = True
await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
)
renamer.client.client.torrents_rename_file.return_value = True
await self._rename(renamer, ep)
renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed")
@@ -427,35 +450,26 @@ class TestRenameFile:
episode=5,
suffix=".mkv",
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="My Anime S01E05.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
)
result = await self._rename(renamer, ep, media_path="My Anime S01E05.mkv")
assert result == []
renamer.client.client.torrents_rename_file.assert_not_awaited()
renamer.client.client.add_tag.assert_awaited_once_with("hash123", "ab:renamed")
async def test_existing_renamed_tag_not_re_added(self, renamer):
await renamer._mark_renamed("hash123", "ab:42, ab:renamed")
renamer.client.client.add_tag.assert_not_awaited()
async def test_renamed_torrent_is_skipped(self, renamer):
"""已带 ab:renamed 的种子在 rename() 里直接跳过,不再打标签。"""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = True
await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
existing_tags="ab:42, ab:renamed",
)
result = await self._rename(renamer, ep, tags="ab:42, ab:renamed")
assert result == []
renamer.client.client.torrents_rename_file.assert_not_awaited()
renamer.client.client.add_tag.assert_not_awaited()
async def test_tagging_failure_does_not_break_rename(self, renamer):
@@ -463,36 +477,21 @@ class TestRenameFile:
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = True
renamer.client.client.add_tag.side_effect = RuntimeError("qB down")
result = await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash-tag-fail",
)
renamer.client.client.torrents_rename_file.return_value = True
renamer.client.client.add_tag.side_effect = RuntimeError("qB down")
result = await self._rename(renamer, ep, _hash="hash-tag-fail")
assert result is not None
assert result.episode == 5
assert len(result) == 1
assert result[0].episode == 5
async def test_failed_rename_does_not_tag(self, renamer):
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
renamer.client.client.torrents_rename_file.return_value = False
await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash-fail-tag",
)
renamer.client.client.torrents_rename_file.return_value = False
result = await self._rename(renamer, ep, _hash="hash-fail-tag")
assert result == []
renamer.client.client.add_tag.assert_not_awaited()
async def test_none_method_does_not_tag(self, renamer):
@@ -500,74 +499,29 @@ class TestRenameFile:
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
await renamer.rename_file(
torrent_name="[Sub] My Anime - 05.mkv",
media_path="old.mkv",
bangumi_name="My Anime",
method="none",
season=1,
_hash="hash123",
)
await self._rename(renamer, ep, method="none")
renamer.client.client.torrents_rename_file.assert_not_awaited()
renamer.client.client.add_tag.assert_not_awaited()
async def test_parse_fails_no_remove(self, renamer):
"""When parser returns None and remove_bad_torrent=False, returns None."""
with patch.object(renamer._parser, "torrent_parser", return_value=None):
with patch("module.manager.renamer.settings") as mock_settings:
mock_settings.bangumi_manage.remove_bad_torrent = False
result = await renamer.rename_file(
torrent_name="garbage",
media_path="bad.mkv",
bangumi_name="Test",
method="pn",
season=1,
_hash="hash123",
)
assert result is None
renamer.client.client.torrents_delete.assert_not_called()
async def test_parse_fails_remove_bad(self, renamer):
"""When parser fails and remove_bad_torrent=True, deletes torrent."""
with patch.object(renamer._parser, "torrent_parser", return_value=None):
with patch("module.manager.renamer.settings") as mock_settings:
mock_settings.bangumi_manage.remove_bad_torrent = True
await renamer.rename_file(
torrent_name="garbage",
media_path="bad.mkv",
bangumi_name="Test",
method="pn",
season=1,
_hash="hash_bad",
)
renamer.client.client.torrents_delete.assert_called_once_with(
"hash_bad", delete_files=True
@pytest.mark.parametrize("remove_bad_torrent", [False, True])
async def test_parse_fails(self, renamer, remove_bad_torrent):
"""解析失败时不返回通知;仅 remove_bad_torrent=True 时删除种子。"""
result = await self._rename(
renamer,
None,
media_path="bad.mkv",
_hash="hash_bad",
remove_bad_torrent=remove_bad_torrent,
)
async def test_same_path_skipped(self, renamer):
"""When generated path equals current path, no rename occurs."""
ep = EpisodeFile(
media_path="My Anime S01E05.mkv",
title="My Anime",
season=1,
episode=5,
suffix=".mkv",
)
with patch.object(renamer._parser, "torrent_parser", return_value=ep):
result = await renamer.rename_file(
torrent_name="test",
media_path="My Anime S01E05.mkv",
bangumi_name="My Anime",
method="pn",
season=1,
_hash="hash123",
assert result == []
if remove_bad_torrent:
renamer.client.client.torrents_delete.assert_called_once_with(
"hash_bad", delete_files=True
)
assert result is None
renamer.client.client.torrents_rename_file.assert_not_called()
else:
renamer.client.client.torrents_delete.assert_not_called()
# ---------------------------------------------------------------------------
@@ -959,7 +913,7 @@ class TestRenameFlow:
with patch.object(
renamer,
"_batch_lookup_offsets",
AsyncMock(return_value={"h1": (0, 0, "movie")}),
AsyncMock(return_value={"h1": (0, "movie")}),
):
with patch("module.manager.renamer.settings") as mock_settings:
mock_settings.bangumi_manage.rename_method = "advance"
@@ -1047,7 +1001,7 @@ class TestRevisionConflictFlow:
@staticmethod
def _offsets():
return {"new-v2": (0, 0, "episode")}
return {"new-v2": (0, "episode")}
async def test_renamed_tag_skips_before_file_and_offset_queries(self, renamer):
renamer.client.client.torrents_info.return_value = [self._infos()[0]]
@@ -1411,7 +1365,7 @@ class TestRevisionConflictFlow:
patch.object(
renamer,
"_batch_lookup_offsets",
AsyncMock(return_value={"season-pack": (0, 0, "episode")}),
AsyncMock(return_value={"season-pack": (0, "episode")}),
),
):
assert await renamer.rename() == []
@@ -1428,7 +1382,7 @@ class TestRevisionConflictFlow:
patch.object(
restarted,
"_batch_lookup_offsets",
AsyncMock(return_value={"season-pack": (0, 0, "episode")}),
AsyncMock(return_value={"season-pack": (0, "episode")}),
),
):
assert await restarted.rename() == []
@@ -1574,7 +1528,7 @@ class TestParseBangumiIdFromTags:
class TestGenPathWithOffsets:
"""Tests for gen_path with episode_offset and season_offset parameters."""
"""Tests for gen_path with episode offsets; season offsets live in the folder."""
def test_episode_offset_positive(self):
"""Episode offset adds to episode number."""
@@ -1616,40 +1570,19 @@ class TestGenPathWithOffsets:
result = Renamer.gen_path(ep, "Bangumi", method="pn", episode_offset=0)
assert "E00" in result # Episode 0 is valid for specials
def test_season_offset_positive(self):
"""Season offset is now applied to folder path, not filename.
The season_offset parameter is kept for API compatibility but no longer
affects the filename. The folder path (generated by _gen_save_path)
already includes the offset, so the season from the folder is used directly.
"""
# Simulate file in Season 2 folder (offset already applied to folder)
@pytest.mark.parametrize("season", [1, 2])
def test_season_from_folder_used_directly(self, season):
"""季度偏移只作用于文件夹(gen_save_path 已计入),gen_path 直接使用
从文件夹解析出的季度,不再叠加偏移。"""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=2, episode=5, suffix=".mkv"
media_path="old.mkv",
title="My Anime",
season=season,
episode=5,
suffix=".mkv",
)
result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=1)
assert (
"S02" in result
) # Season from folder used directly, offset not re-applied
def test_season_offset_negative(self):
"""Season offset is now applied to folder path, not filename."""
# Simulate file in Season 2 folder (offset already applied to folder)
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=2, episode=5, suffix=".mkv"
)
result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-1)
assert (
"S02" in result
) # Season from folder used directly, offset not re-applied
def test_season_offset_negative_below_one_ignored(self):
"""Season offset parameter no longer affects filename."""
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=1, episode=5, suffix=".mkv"
)
result = Renamer.gen_path(ep, "Bangumi", method="pn", season_offset=-5)
assert "S01" in result # Season from folder used directly
result = Renamer.gen_path(ep, "Bangumi", method="pn")
assert f"S0{season}E05" in result
def test_both_offsets_combined(self):
"""Episode offset applied to filename, season offset applied to folder path.
@@ -1661,9 +1594,7 @@ class TestGenPathWithOffsets:
ep = EpisodeFile(
media_path="old.mkv", title="My Anime", season=2, episode=13, suffix=".mkv"
)
result = Renamer.gen_path(
ep, "Bangumi", method="pn", episode_offset=-12, season_offset=1
)
result = Renamer.gen_path(ep, "Bangumi", method="pn", episode_offset=-12)
assert "S02E01" in result # Season 2 from folder, Episode 13-12=1
def test_offset_with_advance_method(self):
@@ -1686,9 +1617,7 @@ class TestGenPathWithOffsets:
language="zh",
suffix=".ass",
)
result = Renamer.gen_path(
sub, "Bangumi", method="subtitle_pn", episode_offset=-12
)
result = Renamer.gen_path(sub, "Bangumi", method="pn", episode_offset=-12)
assert "E13" in result # 25 - 12 = 13
def test_offset_none_method_unchanged(self):
@@ -1705,12 +1634,22 @@ class TestGenPathWithOffsets:
# ---------------------------------------------------------------------------
# _lookup_offsets
# _batch_lookup_offsets
# ---------------------------------------------------------------------------
async def _lookup_one(renamer, *, torrent_hash, torrent_name, save_path, tags):
info = {
"hash": torrent_hash,
"name": torrent_name,
"save_path": save_path,
"tags": tags,
}
return (await renamer._batch_lookup_offsets([info])).get(torrent_hash)
class TestLookupOffsets:
"""Tests for Renamer._lookup_offsets method with multi-tier lookup."""
"""Renamer._batch_lookup_offsets:按 qb_hash → 标签 → 种子名 → 保存路径的顺序关联番剧。"""
@pytest.fixture
def renamer(self, mock_qb_client):
@@ -1767,7 +1706,8 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="abc123hash",
torrent_name="irrelevant",
save_path="/irrelevant/path",
@@ -1775,7 +1715,6 @@ class TestLookupOffsets:
)
assert episode_offset == -12
assert season_offset == 1
async def test_lookup_by_tag_when_hash_not_found(self, renamer, db_session):
"""Second priority: lookup by ab:ID tag when qb_hash not found."""
@@ -1803,7 +1742,8 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent_hash",
torrent_name="irrelevant",
save_path="/irrelevant/path",
@@ -1811,7 +1751,6 @@ class TestLookupOffsets:
)
assert episode_offset == 5
assert season_offset == 0
async def test_lookup_by_torrent_name(self, renamer, db_session):
"""Third priority: lookup by torrent name matching title_raw."""
@@ -1839,7 +1778,8 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent_hash",
torrent_name="[SubGroup] Name Match - 01 [1080p].mkv",
save_path="/irrelevant/path",
@@ -1847,7 +1787,6 @@ class TestLookupOffsets:
)
assert episode_offset == -6
assert season_offset == 2
async def test_lookup_by_save_path_fallback(self, renamer, db_session):
"""Fourth priority: lookup by save_path when other methods fail."""
@@ -1876,7 +1815,8 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent_hash",
torrent_name="completely_different_name.mkv",
save_path="/downloads/Bangumi/Path Match Anime (2024)/Season 1",
@@ -1884,10 +1824,9 @@ class TestLookupOffsets:
)
assert episode_offset == 10
assert season_offset == -1
async def test_lookup_returns_zero_when_not_found(self, renamer, db_session):
"""Returns (0, 0) when no matching bangumi found."""
"""No matching bangumi: offset 0."""
from module.database.bangumi import BangumiDatabase
from module.database.torrent import TorrentDatabase
@@ -1899,7 +1838,8 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent",
torrent_name="no_match",
save_path="/no/match/path",
@@ -1907,7 +1847,6 @@ class TestLookupOffsets:
)
assert episode_offset == 0
assert season_offset == 0
async def test_lookup_skips_deleted_bangumi(self, renamer, db_session):
"""Skips deleted bangumi even if hash/tag matches."""
@@ -1936,31 +1875,31 @@ class TestLookupOffsets:
mock_db.bangumi = BangumiDatabase(db_session)
MockDatabase.return_value = mock_db
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent",
torrent_name="no_match",
save_path="/no/match",
tags=f"ab:{bangumi.id}",
)
# Should return (0, 0) because bangumi is deleted
# bangumi 已删除:不使用它的偏移
assert episode_offset == 0
assert season_offset == 0
async def test_lookup_handles_database_exception(self, renamer):
"""Returns (0, 0) when database throws exception."""
"""数据库异常时不给出偏移:rename() 本轮跳过该种子,而不是按 0 猜测。"""
with patch("module.manager.renamer.Database") as MockDatabase:
MockDatabase.side_effect = Exception("Database connection failed")
episode_offset, season_offset = await renamer._lookup_offsets(
link = await _lookup_one(
renamer,
torrent_hash="any",
torrent_name="any",
save_path="/any",
tags="",
)
assert episode_offset == 0
assert season_offset == 0
assert link is None
async def test_lookup_by_save_path_with_trailing_slash(self, renamer, db_session):
"""Save path matching works with trailing slashes."""
@@ -1990,7 +1929,8 @@ class TestLookupOffsets:
MockDatabase.return_value = mock_db
# Query WITH trailing slash - should still match
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent",
torrent_name="no_match",
save_path="/downloads/Bangumi/Test (2024)/Season 1/",
@@ -1998,7 +1938,6 @@ class TestLookupOffsets:
)
assert episode_offset == 5
assert season_offset == 2
async def test_lookup_by_save_path_with_backslashes(self, renamer, db_session):
"""Save path matching works with Windows-style backslashes."""
@@ -2028,7 +1967,8 @@ class TestLookupOffsets:
MockDatabase.return_value = mock_db
# Query with backslashes - should still match after normalization
episode_offset, season_offset = await renamer._lookup_offsets(
episode_offset, _ = await _lookup_one(
renamer,
torrent_hash="nonexistent",
torrent_name="no_match",
save_path="\\downloads\\Bangumi\\Test (2024)\\Season 1",
@@ -2036,33 +1976,3 @@ class TestLookupOffsets:
)
assert episode_offset == 3
assert season_offset == 1
class TestNormalizePath:
"""Tests for Renamer._normalize_path static method."""
def test_empty_path(self):
from module.manager.renamer import Renamer
assert Renamer._normalize_path("") == ""
def test_removes_trailing_slash(self):
from module.manager.renamer import Renamer
assert Renamer._normalize_path("/path/to/dir/") == "/path/to/dir"
def test_removes_trailing_backslash(self):
from module.manager.renamer import Renamer
assert Renamer._normalize_path("C:\\path\\to\\dir\\") == "C:/path/to/dir"
def test_converts_backslashes(self):
from module.manager.renamer import Renamer
assert Renamer._normalize_path("C:\\path\\to\\dir") == "C:/path/to/dir"
def test_preserves_forward_slashes(self):
from module.manager.renamer import Renamer
assert Renamer._normalize_path("/path/to/dir") == "/path/to/dir"

View File

@@ -59,11 +59,6 @@ load_corpus # tokenizer 基准测试工具
run_benchmark
render_text
# --- 待 4.0 后续阶段处理(见 docs/plans/2026-10-08-plugin-architecture-4.0-design.md)---
_.rename_file # P4 重写 renamer 时与测试一起迁移
_._lookup_offsets # P4:与 _batch_lookup_offsets 合并
_.release_replacement_lease # 待确认是否 saga 遗漏调用(设计文档第 11 节)
# --- 插件运行时(module/plugin)---
# 公开接口:宿主在 P2/P3 迁移扩展点时调用(声明扩展点、执行 transform 钩子)
_.declare
@@ -82,3 +77,14 @@ _.__package__
# 内置插件:由插件加载器按 plugin.toml 的 entry 导入,钩子经 @hook 扫描登记
IngestFilters
_.include_regex
# --- P4 organize ---
# 内置插件:由插件管理器按 plugin.toml 加载,经 @provider / @subscribe 登记
RenamePlugin
_.pn_strategy
_.advance_strategy
_.template_strategy
HardlinkPlugin
MediaServerRefresh
_.on_organized # hardlink 与 media-server-refresh 共用
_.api # 插件 HTTP 路由,宿主挂载到 /api/v1/plugins/<id>/

View File

@@ -9,6 +9,8 @@
- `pn`:保留更多发布标题信息,使用 `种子标题 S0XE0X` 风格。
- `advance`:使用官方标题与标准季集格式。
- `none`:不重命名文件。
- `template`:按自定义模板命名,见下文[模板重命名](#模板重命名)。
- `pn`、`advance`、`template` 由内置插件「重命名」(`rename`)提供,默认启用;在 **设置 → 插件** 中停用它后,所有番剧按 `none` 处理。其它插件也可以提供重命名方式,启用后出现在下拉框中。
- **番剧补全**:检测当季缺失集数并尝试补全下载。
- **添加组标签**:为下载器中的任务添加字幕组相关标签。
- **删除坏种**:移除下载器中状态异常的种子。
@@ -29,5 +31,65 @@
| `remove_bad_torrent` | 删除错误种子 | 布尔值 | 删除坏种 | `false` |
| `track_orphans` | 记录未匹配种子 | 布尔值 | 记录未匹配种子 | `true` |
## 模板重命名
选择 `template` 后,文件名由内置插件 `rename` 的模板生成。在 **设置 → 插件 → 重命名** 中修改「文件名模板」,默认值为:
::: v-pre
```
{{ title }} S{{ season|pad(2) }}E{{ episode|pad(2) }}
```
默认模板与 `pn` 的结果相同。模板使用 [Jinja2](https://jinja.palletsprojects.com/) 语法(沙箱模式),可用变量:
| 变量 | 含义 | 示例 |
| --- | --- | --- |
| `title` | 从文件名解析出的标题 | `葬送的芙莉莲` |
| `bangumi_name` | 番剧文件夹名 | `葬送的芙莉莲 (2023)` |
| `season` | 季度(取自 Season 文件夹,已含季度偏移) | `1` |
| `episode` | 集数(已含集数偏移,半集保留小数) | `5`、`12.5` |
| `episode_type` | `episode` / `movie` / `special` | `episode` |
| `group` | 字幕组,可能为空 | `ANi` |
| `kind` | `media`(正片)或 `subtitle`(字幕) | `media` |
| `language` | 字幕语言,正片为空 | `zh` |
`pad(n)` 过滤器把数字的整数部分补零到 n 位,小数保留:`{{ episode|pad(3) }}` 得到 `005`,`{{ 12.5|pad(2) }}` 得到 `12.5`。
- 模板只生成文件名主体,扩展名自动追加;字幕追加 `.语言.扩展名`,如 `.zh.ass`。
- 保存时会用示例文件试渲染一次:语法错误、使用不存在的变量或渲染结果不合法的模板直接被拒绝。
- 运行时某个文件渲染失败、结果为空或含 `/`、`\`:该文件**保留原名**并发送「文件未重命名」通知,种子不打 `ab:renamed` 标签,修正模板后下一轮自动重试。不会退回 `pn`。
示例:`[{{ group }}] {{ bangumi_name }} - {{ episode|pad(2) }}` → `[ANi] 葬送的芙莉莲 (2023) - 05.mkv`
:::
::: warning
更换重命名方式(包括修改模板)只影响之后整理的种子;已整理完成(带 `ab:renamed` 标签)的种子不会被重新命名。
:::
## 硬链接到媒体库
内置插件「硬链接到媒体库」(`hardlink`)**默认停用**,在 **设置 → 插件** 中启用。种子整理完成后,它把正片与字幕链接到媒体库目录;下载目录保持原样继续做种。
| 选项 | 说明 | 默认值 |
| --- | --- | --- |
| `source_root` | 下载根目录(AB 看到的绝对路径)。媒体库保持与它相同的目录结构 | 无,必填 |
| `library_root` | 媒体库目录(绝对路径),不能位于 `source_root` 内 | 无,必填 |
| `path_map` | 路径映射列表 `[{downloader, from, to}]`:把下载器 `downloader`(实例 id,默认 `default`)看到的路径前缀 `from` 换成 AB 看到的路径 `to`;按最长前缀匹配,未匹配的路径原样使用 | `[]` |
| `cross_device` | 硬链接遇到跨文件系统时:`copy` 复制文件、`symlink` 创建软链接、`skip` 跳过并通知 | `copy` |
- 媒体库中已有同名文件、但不是本插件创建的:跳过,**绝不覆盖**,并发送「硬链接未完成」通知。
- 版本升级(新版本种子替换旧种子,文件名不变)后,本插件之前创建的链接会被原子替换为指向新文件的链接。
- 删除种子不会删除媒体库中的链接。
- 启用前已经下载的文件不会自动处理,需要按需补链:调用 `POST /api/v1/plugins/hardlink/backfill`(设置页按钮将在后续版本提供)。
- `path_map` 目前的设置表单还不能编辑,请在 `config/config.json` 的 `plugins.options.hardlink.path_map` 中填写,例如 `[{"from": "/downloads", "to": "/media/downloads"}]`。
::: tip Docker
硬链接不能跨文件系统。在 Docker 中,请把下载目录与媒体库放在同一块盘上,并以**同一个挂载点**映射进 AB 容器(例如把 `/mnt/media` 整体挂载为 `/media`,下载目录与媒体库都在其下)。两个分别挂载的目录即使在同一块盘上,也会被视为不同的文件系统,此时按 `cross_device` 处理(默认复制,占用双倍空间)。下载器运行在另一个容器里、看到的路径与 AB 不同时,用 `path_map` 映射。
:::
## 媒体库刷新
内置插件「媒体库刷新」(`media-server-refresh`)在种子整理完成后请求 Jellyfin、Emby 或 Plex 刷新媒体库。在 **设置 → 插件 → 媒体库刷新** 中选择服务器类型,填写服务器地址与 API Key(Plex 为 `X-Plex-Token`)后生效;未填写时不做任何事。收到事件后等待「延迟」秒(默认 30),期间整理完成的种子合并为一次刷新。
[1]: https://www.autobangumi.org/faq/#download-path
[2]: https://www.autobangumi.org/faq/#file-renaming

View File

@@ -17,6 +17,9 @@
| REST 路由 | `points.API_ROUTER` | `fastapi.APIRouter` | 插件启用即生效 |
| MCP 工具 | `points.MCP_TOOL` | `McpTool` | 插件启用即生效 |
| MCP 资源 | `points.MCP_RESOURCE` | `McpResource` | 插件启用即生效 |
| 重命名方式 | `points.RENAME_STRATEGY` | `RenameStrategy` | 设置 → 番剧管理设置 → 重命名方式 |
| 文件分类 | `points.MEDIA_FILES` | `MediaFiles` | 暂只使用宿主实现(见下文) |
| 版本冲突策略 | `points.CONFLICT_POLICY` | `ConflictPolicy` | 暂只使用宿主实现(见下文) |
以及以下钩子(`@hook`,插件启用即生效):
@@ -28,7 +31,7 @@
| `http.request` | `points.HTTP_REQUEST` | transform | 修改 AB 发出的 GET 请求头(私有站 Cookie 等) |
| `message_template` | `points.MESSAGE_TEMPLATE` | transform | 改写系统事件推送到外部渠道的文案 |
此外,插件可以订阅系统事件(`@subscribe`),并使用私有的键值存储和数据目录。重命名等整理流水线的扩展点会在后续版本开放。
此外,插件可以订阅系统事件与整理事件(`@subscribe`,如 `torrent.organized`),并使用私有的键值存储和数据目录。
## 目录结构
@@ -45,7 +48,7 @@ config/plugins/local/my-plugin/ # 目录名必须与清单中的 id 相同
id = "my-plugin" # 小写字母、数字、连字符
name = "我的插件"
version = "0.1.0"
sdk = ">=0.3,<1" # 依赖的 ab_sdk 版本范围
sdk = ">=0.4,<1" # 依赖的 ab_sdk 版本范围
entry = "my_plugin:MyPlugin" # 模块:Plugin 子类,相对插件目录
description = "一句话说明"
permissions = ["network"] # 仅用于向用户展示,不做强制
@@ -118,7 +121,7 @@ class MyPlugin(Plugin[Options]):
| --- | --- |
| `@provider(point, id=...)` | 工厂方法,返回该扩展点约定的实现 |
| `@hook(point, priority=...)` | 挂到宿主声明的 filter / transform 扩展点 |
| `@subscribe(kind)` | 订阅事件,`"*"` 表示全部事件 |
| `@subscribe(kind, timeout=...)` | 订阅事件,`"*"` 表示全部事件;`timeout` 覆盖默认 30 秒的单个事件处理超时 |
扩展点名写错时,插件会加载失败并在插件列表里显示原因,不会被悄悄忽略。
@@ -302,6 +305,96 @@ class MyPlugin(Plugin):
`request.kind` 区分番剧与电影,`request.torrent.homepage` 是种子的详情页。返回值是完整的新元数据,通常基于 `request.current` 修改。失败或超时时保留原值,并计入熔断。添加订阅时,「解析器」下拉框会列出插件提供的元数据源。
### 重命名方式(rename_strategy)
下载完成后,AB 为种子里的每个正片与字幕调用设置项「重命名方式」(`bangumi_manage.rename_method`)对应的 Provider。宿主只自带 `none`(保留原名);`pn`、`advance` 与 `template` 由内置插件「重命名」(`rename`,默认启用)提供。插件登记的 id 会出现在 设置 → 番剧管理设置 → 重命名方式 的下拉框里。
```python
from ab_sdk import Plugin, points, provider
from ab_sdk.rename import RenameInput, RenameSkipped, pad
class JellyfinStyle:
def target_name(self, f: RenameInput) -> str:
language = f".{f.language}" if f.kind == "subtitle" else ""
if f.episode_type == "movie":
return f"{f.bangumi_name}{language}{f.suffix}"
if not f.bangumi_name:
raise RenameSkipped("缺少番剧文件夹名")
return f"{f.bangumi_name} - S{pad(f.season)}E{pad(f.episode)}{language}{f.suffix}"
class MyRename(Plugin):
@provider(points.RENAME_STRATEGY, id="jellyfin-style")
def jellyfin(self):
return JellyfinStyle()
```
- `RenameInput` 是冻结快照:`kind`(`media` / `subtitle`)、`media_path`(种子内原相对路径)、`title`(解析自文件名)、`bangumi_name`(保存目录的番剧文件夹名)、`season`、`episode`(已应用集数偏移)、`suffix`(含点)、`episode_type`(`episode` / `movie` / `special`)、`language`(字幕语言)、`group`。字幕不再有单独的 `subtitle_*` 方式,按 `kind` 区分。
- 返回种子内的新相对路径,通常只是文件名(扩展名与字幕语言由策略自己拼上);返回 `f.media_path` 表示不改名。`pad(n, width=2)` 补零且保留半集的小数(`pad(9.5) == "09.5"`)。
- 抛出 `RenameSkipped(原因)`:该文件保留原名,种子不打「已重命名」标签,每个种子按原因发一条 `rename_skipped` 通知;修正后下一轮自动重试。它表示输入或配置有问题,不计入熔断。
- 抛出其它异常、返回空串或非字符串:同样保留原名并通知,同时计入熔断。
- `target_name` 是同步调用,没有超时,请不要在里面做网络或磁盘 IO。
- 设置里选择的 id 没有登记(如插件被停用或熔断)时,AB 记录一次日志并按 `none` 处理。
::: v-pre
内置的 `template` 使用 Jinja2 沙箱模板渲染文件名主体,在 设置 → 插件 → 重命名 中填写,例如 `{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(2) }}`。可用变量为 `title`、`bangumi_name`、`season`、`episode`、`episode_type`、`group`、`kind`、`language`。保存时会编译并试渲染一次,不合法的模板直接被拒绝(HTTP 422)。运行时渲染失败或结果为空、含路径分隔符时,该文件保留原名并通知,不会退回 `pn`。
:::
### 文件分类与版本冲突(media_files / conflict_policy)
- `media_files`:`classify(path) -> "media" | "subtitle" | "ignore"`,决定种子内哪些文件按正片、字幕重命名。宿主实现按扩展名判断(`.mp4` / `.mkv` 为正片,`.ass` / `.srt` 为字幕)。
- `conflict_policy`:`decide(ConflictRequest) -> ConflictDecision("hold" | "replace")`,新种子的规范文件名已被另一个种子占用时决定保留旧的还是替换。宿主实现沿用设置项「版本冲突策略」。宿主只在「唯一占用者、双方都是单文件种子、双方解析身份完整」时执行替换,其它情况一律按 `hold` 处理。
这两个扩展点目前只使用宿主实现(id 为 `default`)。插件可以登记自己的实现,但要等多下载器版本的 `plugins.slots` 提供后才能被选用。
### 整理事件(file.renamed / torrent.organized)
| kind | 事件类 | 字段 | 何时发布 |
| --- | --- | --- | --- |
| `file.renamed` | `FileRenamed` | `bangumi_id`、`old_path`、`new_path`、`file_kind`、`downloader_id` | 一个文件被实际重命名(含版本替换后的改名) |
| `torrent.organized` | `TorrentOrganized` | `torrent_hash`、`bangumi_id`、`files`(`OrganizedFile(path, kind)` 元组)、`downloader_id` | 一个种子整理完成;重命名方式为 `none` 时同样发布,`files` 为原路径 |
- 路径是**下载器视角**的绝对路径,保存目录与种子内路径以 `/` 拼接(Windows 下载器的 `\` 也统一为 `/`)。AB 与下载器看到的目录不同(如分别运行在不同容器)时,订阅者需要自己做路径映射。
- `bangumi_id` 来自种子的 `ab:<id>` 标签,旧种子可能为 None。`downloader_id` 目前固定为 `"default"`,多下载器版本会给出实例 id。
- `torrent.organized` 的投递是**至少一次**:未打「已重命名」标签的种子(如重命名方式为 `none`)在每次 AB 重启后会再发布一次,订阅者必须幂等。
- 这两个事件只发布到事件总线,不进入通知中心。
```python
from ab_sdk import subscribe
from ab_sdk.events import TorrentOrganized
@subscribe("torrent.organized", timeout=600)
async def on_organized(self, event: TorrentOrganized):
for f in event.files:
if f.kind == "media":
await self.refresh_library(f.path)
```
### 内置插件:硬链接到媒体库(hardlink)
内置插件 `hardlink` 默认停用,在 设置 → 插件 中启用。它订阅 `torrent.organized`,把正片与字幕链接到媒体库目录,下载目录原样保留继续做种。
| 选项 | 说明 |
| --- | --- |
| `source_root` | 下载根目录(AB 本地路径)。媒体库保持与它相同的目录结构:`library_root / (文件相对 source_root 的路径)` |
| `library_root` | 媒体库目录(AB 本地路径),不能位于 `source_root` 内 |
| `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` 通知。
- 本插件之前为同一集创建的链接,在版本升级(新版本替换旧种子,规范文件名不变)后会被原子替换为指向新文件的链接。
- 已链接的文件再次收到事件时不做任何事;插件在自己的键值存储里记录它创建过的目标路径。
- **删除种子不会删除媒体库中的链接**。
- 启用前已经下载的文件不会自动处理。调用 `POST /api/v1/plugins/hardlink/backfill` 按需补链:遍历 `source_root` 下的 `.mp4` / `.mkv` / `.ass` / `.srt`,返回 `{"linked", "exists", "conflict", "failed"}` 计数。设置页里的「补链已有文件」按钮将随前端插件挂载点一起提供。
::: tip Docker
硬链接不能跨文件系统。在 Docker 中,请把下载目录与媒体库放在同一块盘上,并以**同一个挂载点**映射进 AB 容器(例如把 `/mnt/media` 整体挂载为 `/media`,下载目录与媒体库都在其下),否则两个独立挂载的目录即使在同一块盘上,也会被视为不同文件系统,链接会退化为 `cross_device` 指定的行为。下载器运行在另一个容器里、看到的路径与 AB 不同时,用 `path_map` 做映射。
:::
### REST 路由
```python
@@ -380,11 +473,14 @@ AB 发出的、会进入通知中心的事件都是 `ab_sdk.events.SystemEvent`
| `llm_auth_failure` | `LLMAuthFailureEvent` | 订阅类 LLM 提供商凭据失效 |
| `llm_plugin_install_failed` | `LLMPluginInstallFailedEvent` | LLM 插件安装失败 |
| `rename_conflict` | `RenameConflictEvent` | 媒体文件重命名遇到目标路径冲突 |
| `rename_skipped` | `RenameSkippedEvent` | 重命名方式无法为种子中的文件给出名字,文件保留原名 |
| `plugin.loaded` | `PluginLoaded` | 插件加载成功 |
| `plugin.disabled` | `PluginDisabled` | 插件加载失败或被熔断 |
| `inbox.changed` | — | 通知中心有新消息、已读或删除(宿主内部使用,供 SSE 推送) |
系统事件的 `kind` 沿用 3.x 的取值(通知中心按它存储和翻译),因此不带 `.` 前缀;插件自己的事件仍必须以 `<插件 id>.` 开头。后续版本会陆续增加流水线事件(如 `torrent.added`、`file.renamed`)。
系统事件的 `kind` 沿用 3.x 的取值(通知中心按它存储和翻译),因此不带 `.` 前缀;插件自己的事件仍必须以 `<插件 id>.` 开头。整理流水线事件 `file.renamed`、`torrent.organized` 见上文「整理事件」。
插件也可以继承 `SystemEvent` 定义自己的可通知事件。用 `self.ctx.bus.publish(...)` 发布后,它与宿主事件走同一条路径:写入通知中心(前端没有对应翻译时显示 `describe()` 的标题与正文),发布到事件总线,再推送到外部通知渠道。`dedup_key()` 相同的事件在通知中心合并为一条。内置插件 `hardlink` 的 `hardlink.failed` 就是这样发出的。
### 通知文案模板

View File

@@ -9,11 +9,73 @@
- `pn`: keeps more release-title information, using a `Torrent title S0XE0X` style.
- `advance`: uses official title and standard season/episode naming.
- `none`: do not rename files.
- `template`: name files with your own template, see [Template rename](#template-rename).
- `pn`, `advance` and `template` come from the built-in `rename` plugin, which is enabled by default. If you disable it under **Settings → Plugins**, every series is handled as `none`. Other plugins can add rename methods; enabled ones appear in the drop-down.
- **EPS complete**: tries to backfill missing episodes in the current season.
- **Add Group Tag**: adds subgroup-related tags to downloader tasks.
- **Delete Bad Torrent**: removes errored downloader tasks.
- **Track Unmatched Torrents**: stores torrents that do not match any rule as orphan records. Disable it if you want newly added rules to catch old feed items immediately, at the cost of rechecking those old items on each RSS refresh.
## Template rename
With `template`, file names come from the template of the built-in `rename` plugin. Edit the file name template under **Settings → Plugins → rename**. The default is:
::: v-pre
```
{{ title }} S{{ season|pad(2) }}E{{ episode|pad(2) }}
```
The default produces the same names as `pn`. Templates use sandboxed [Jinja2](https://jinja.palletsprojects.com/) syntax with these variables:
| Variable | Meaning | Example |
| --- | --- | --- |
| `title` | title parsed from the file name | `Frieren` |
| `bangumi_name` | series folder name | `Frieren (2023)` |
| `season` | season, from the Season folder (season offset applied) | `1` |
| `episode` | episode (episode offset applied; half episodes keep the fraction) | `5`, `12.5` |
| `episode_type` | `episode` / `movie` / `special` | `episode` |
| `group` | release group, may be empty | `ANi` |
| `kind` | `media` or `subtitle` | `media` |
| `language` | subtitle language, empty for media | `zh` |
The `pad(n)` filter zero-pads the integer part to n digits and keeps the fraction: `{{ episode|pad(3) }}` gives `005`, `{{ 12.5|pad(2) }}` gives `12.5`.
- The template renders only the base name. The extension is appended for you (`.zh.ass` style for subtitles).
- On save, the template is rendered once against a sample file. Templates with syntax errors, unknown variables or an invalid result are rejected.
- If a file fails to render at run time, or the result is empty or contains `/` or `\`, that file **keeps its name** and you get a "Files kept their names" notification. The torrent is not tagged `ab:renamed`, so the next cycle retries after you fix the template. It never falls back to `pn`.
Example: `[{{ group }}] {{ bangumi_name }} - {{ episode|pad(2) }}` → `[ANi] Frieren (2023) - 05.mkv`
:::
::: warning
A new rename method or template applies only to torrents organized afterwards. Torrents already organized (tagged `ab:renamed`) are not renamed again.
:::
## Hard links to a media library
The built-in `hardlink` plugin is **disabled by default**. Enable it under **Settings → Plugins**. After a torrent is organized, it links the video and subtitle files into a library folder. The download folder stays as it is and keeps seeding.
| Option | Description | Default |
| --- | --- | --- |
| `source_root` | download root as AutoBangumi sees it (absolute path). The library mirrors its folder structure | none, required |
| `library_root` | library folder (absolute path), must not be inside `source_root` | none, required |
| `path_map` | list of `{downloader, from, to}`: replaces the path prefix `from`, as downloader instance `downloader` (default `default`) sees it, with the path `to` that AutoBangumi sees. Longest prefix wins; unmatched paths are used as they are | `[]` |
| `cross_device` | what to do when a hard link would cross file systems: `copy` the file, create a `symlink`, or `skip` and notify | `copy` |
- If the library already has a file with that name that the plugin did not create, the plugin skips it, **never overwrites it**, and sends a notification.
- After a revision upgrade (a new release replaces the old torrent under the same file name), the link the plugin made earlier is atomically replaced with a link to the new file.
- Deleting a torrent does not delete its links in the library.
- Files downloaded before you enabled the plugin are not linked automatically. To link them, call `POST /api/v1/plugins/hardlink/backfill` (a settings button follows in a later release).
- The settings form cannot edit `path_map` yet. Set it in `config/config.json` under `plugins.options.hardlink.path_map`, for example `[{"from": "/downloads", "to": "/media/downloads"}]`.
::: tip Docker
Hard links cannot cross file systems. In Docker, keep the download folder and the library on the same disk and mount them into the AutoBangumi container through **one** mount (for example, mount `/mnt/media` as `/media` with both folders below it). Two separate mounts count as different file systems even on the same disk; the plugin then follows `cross_device` (by default it copies, which uses twice the space). If the downloader runs in another container and sees different paths, use `path_map`.
:::
## Media server refresh
The built-in `media-server-refresh` plugin asks Jellyfin, Emby or Plex to refresh the library after a torrent is organized. Under **Settings → Plugins → media-server-refresh**, select the server type and enter the server URL and API key (for Plex, the `X-Plex-Token`). Without them the plugin does nothing. After an event it waits `delay` seconds (default 30) and sends one refresh for all torrents organized in that time.
## `config.json`
Section: `bangumi_manage`

View File

@@ -9,11 +9,71 @@
- `pn`:リリースタイトル情報を多めに保持する `Torrent title S0XE0X` 形式。
- `advance`:公式タイトルと標準的なシーズン/話数形式。
- `none`:リネームしません。
- `template`:独自テンプレートで命名します([テンプレートリネーム](#テンプレートリネーム)参照)。
- `pn`・`advance`・`template` は内蔵プラグイン `rename`(既定で有効)が提供します。**設定 → プラグイン** で無効にすると、すべての番組が `none` として扱われます。他のプラグインがリネーム方式を追加することもあり、有効なものはドロップダウンに表示されます。
- **話数補完**:不足している話数を補完ダウンロードします。
- **グループタグ追加**:字幕組関連タグをダウンローダータスクに追加します。
- **不良Torrent削除**:エラー状態のタスクを削除します。
- **未一致Torrentを記録**:ルールに一致しないTorrentを孤児レコードとして保存します。
## テンプレートリネーム
`template` を選ぶと、内蔵プラグイン `rename` のテンプレートでファイル名を生成します。**設定 → プラグイン → rename** でファイル名テンプレートを編集します。既定値:
::: v-pre
```
{{ title }} S{{ season|pad(2) }}E{{ episode|pad(2) }}
```
既定値は `pn` と同じ結果になります。サンドボックス化された [Jinja2](https://jinja.palletsprojects.com/) 構文で、次の変数が使えます。
| 変数 | 意味 | 例 |
| --- | --- | --- |
| `title` | ファイル名から解析したタイトル | `葬送のフリーレン` |
| `bangumi_name` | 番組フォルダー名 | `葬送のフリーレン (2023)` |
| `season` | シーズン(Season フォルダーから取得、シーズンオフセット適用済み) | `1` |
| `episode` | 話数(話数オフセット適用済み、半話は小数を保持) | `5`、`12.5` |
| `episode_type` | `episode` / `movie` / `special` | `episode` |
| `group` | 字幕グループ(空の場合あり) | `ANi` |
| `kind` | `media` または `subtitle` | `media` |
| `language` | 字幕の言語(動画では空) | `zh` |
`pad(n)` フィルターは整数部を n 桁にゼロ埋めし、小数部は保持します(`{{ episode|pad(3) }}` → `005`、`{{ 12.5|pad(2) }}` → `12.5`)。
- テンプレートはファイル名の本体のみを生成し、拡張子(字幕は `.zh.ass` 形式)は自動で付きます。
- 保存時にサンプルファイルで一度レンダリングします。構文エラー、未定義の変数、不正な結果のテンプレートは拒否されます。
- 実行時にレンダリングが失敗した、結果が空、または `/`・`\` を含む場合、そのファイルは**元の名前のまま**になり、通知が送られます。Torrent に `ab:renamed` タグは付かないため、テンプレートを直すと次のサイクルで再試行されます。`pn` に戻ることはありません。
:::
::: warning
リネーム方式やテンプレートの変更は、その後に整理される Torrent にのみ適用されます。整理済み(`ab:renamed` タグ付き)の Torrent は再リネームされません。
:::
## メディアライブラリへのハードリンク
内蔵プラグイン `hardlink` は**既定で無効**です。**設定 → プラグイン** で有効にします。Torrent の整理が終わると、動画と字幕をライブラリフォルダーにリンクします。ダウンロードフォルダーはそのまま残り、シードを続けます。
| 項目 | 説明 | 既定値 |
| --- | --- | --- |
| `source_root` | AutoBangumi から見たダウンロードルート(絶対パス)。ライブラリは同じフォルダー構成になります | なし(必須) |
| `library_root` | ライブラリフォルダー(絶対パス)。`source_root` の内側には置けません | なし(必須) |
| `path_map` | `{downloader, from, to}` のリスト。ダウンローダーインスタンス `downloader`(既定 `default`)から見たパス接頭辞 `from` を、AutoBangumi から見たパス `to` に置き換えます。最長一致、一致しないパスはそのまま使います | `[]` |
| `cross_device` | ハードリンクがファイルシステムをまたぐ場合:`copy`(コピー)、`symlink`(シンボリックリンク)、`skip`(スキップして通知) | `copy` |
- プラグインが作成していない同名ファイルがライブラリにある場合はスキップし、**上書きしません**。通知が送られます。
- リビジョン更新(新しいリリースが同じファイル名で古い Torrent を置き換える)の後、プラグインが以前作成したリンクは新しいファイルへのリンクにアトミックに置き換えられます。
- Torrent を削除しても、ライブラリのリンクは削除されません。
- 有効化する前にダウンロード済みのファイルは自動ではリンクされません。`POST /api/v1/plugins/hardlink/backfill` を呼び出すとリンクします(設定画面のボタンは今後のリリースで提供予定)。
- `path_map` は設定フォームでまだ編集できません。`config/config.json` の `plugins.options.hardlink.path_map` に記入してください(例:`[{"from": "/downloads", "to": "/media/downloads"}]`)。
::: tip Docker
ハードリンクはファイルシステムをまたげません。Docker では、ダウンロードフォルダーとライブラリを同じディスクに置き、**1 つのマウント**で AutoBangumi コンテナに渡してください(例:`/mnt/media` を `/media` としてまとめてマウントし、両方をその下に置く)。別々にマウントしたフォルダーは同じディスク上でも別のファイルシステムとして扱われ、`cross_device` の設定に従います(既定はコピーで、容量を 2 倍使います)。ダウンローダーが別のコンテナで動作し、見えるパスが異なる場合は `path_map` を使います。
:::
## メディアサーバーの更新
内蔵プラグイン `media-server-refresh` は、Torrent の整理後に Jellyfin・Emby・Plex へライブラリの更新を要求します。**設定 → プラグイン → media-server-refresh** でサーバー種別を選び、サーバー URL と API Key(Plex は `X-Plex-Token`)を入力すると有効になります。未入力の間は何もしません。イベントを受け取ると `delay` 秒(既定 30)待ち、その間に整理された Torrent をまとめて 1 回だけ更新します。
## `config.json`
セクション:`bangumi_manage`

View File

@@ -45,6 +45,58 @@
- **i18n(第 11 节风险 3)先给出 key**:`SystemEvent.i18n()` 返回 `notifications.kind.<kind>` 与 `payload()`,与前端现有文案键一致;外部推送与通知中心的中文兜底文案不变。插件事件的前端翻译依赖 P6 的 `host.i18n`。
- 进程级访问器 `module.plugin.host.get_bus/set_bus`、`get_runner/set_runner`、`publish()`,由 `AppContext` 构造时设置,未设置时为空操作。MCP 的 `tools/list_changed` 通知未实现(客户端重新 list 即可看到插件工具变化)。
### 实施中的调整(P4)
拆分与扩展点:
- **`renamer.py` 拆分**:revision 替换事务(普通改名日志、V1→V2 替换、恢复、操作状态、版本占用者)搬到 `manager/revision_saga.py` 的 `RevisionSaga`,`Renamer` 以 mixin 方式继承它(`class Renamer(RevisionSaga)`),而不是构造函数注入。这样第一个提交是纯搬移,测试仍能访问 `renamer._run_ordinary_rename`、`renamer.events`、`renamer._downloader_type`。`renamer.py` 从 1805 行降到 1061 行(含 P4 新增的扩展点接入)。
- **重命名方式**:宿主只以 `core` 登记 `none`(`manager/rename_strategies.py` 的 `NoRename`)。`pn`、`advance`、`template` 由内置插件 `rename`(`module/plugins/builtin/rename/`,默认启用)提供。设置值 `bangumi_manage.rename_method` 直接作为 Provider id,`slots` 与迁移器留到 P2.5。id 未登记(插件停用或被熔断)时记录一次日志并按 `none` 处理。
- **纯搬移的一处例外**:3.3 中未知重命名方式遇到电影文件时会改名为 `{title}{suffix}`;现在未知 id 一律按 `none` 处理,保留原名。
- **`normal`**:`gen_path` 中已没有 `normal` 分支。`conf/config.py` 中 `normal → none` 的转换属于 3.x 兼容层(第 8.3 节),保留不动。
- **字幕**:删除 `subtitle_pn` / `subtitle_advance`,类别由 `RenameInput.kind` 给出。因此 `test_renamer.py` 有 5 处调用参数从 `subtitle_*` 改为 `pn` / `advance`(断言未改),`test_plugin_extensions.py::test_providers` 的期望值增加 `rename_strategy: []`。既有测试依赖进程注册表中的 `pn` / `advance`,由 `conftest.py` 的 autouse fixture 登记内置插件 `rename`,与生产默认一致。除此之外,pn / advance / none 的输出与 3.3 逐字节一致。
- **策略调用是同步的**:`HookRunner.call_provider_sync` 在熔断器内调用策略并校验返回值(非空字符串),没有超时;Jinja2 渲染也没有超时,只受沙箱 `MAX_RANGE` 限制。`RenameSkipped` 不计入熔断;其它异常与非法返回值计入熔断,并同样按跳过处理。
- **跳过通知**:新增系统事件 `rename_skipped`(`RenameSkippedEvent`,WebUI 的 `KNOWN_KINDS`、i18n 与跳转路由同步增加),不复用 `rename_conflict`。同一 (种子 hash, 原因) 每个进程只通知一次。字幕被跳过同样会阻止打 `ab:renamed` 标签,用户修正后种子会被重试。
- **模板失效的后果**:已保存的模板若不再通过校验(手工改配置、Jinja2 升级),整个 `rename` 插件加载失败,`pn` / `advance` 随之消失,所有种子按 `none` 处理。插件因连续 5 次异常或非法返回值被熔断时同样如此。
- **`media_files` 与 `conflict_policy`** 只解析固定的 core id `default`(`ab_sdk.rename.CORE_ID`)。插件可以登记实现,但选择要等 P2.5 的 `slots`。`conflict_policy` 的接口改为 `decide(ConflictRequest) -> ConflictDecision`:`ConflictRequest` 带上宿主读取的设置值 `configured`(`bangumi_manage.revision_conflict_policy`,测试 patch 的是 `module.manager.renamer.settings`,所以设置仍在 `renamer.py` 中读取)和宿主计算的 `strict_upgrade`。宿主仍只在「唯一占用者、双方都是单文件种子、双方身份完整」时执行替换。
- **事件字段**:`FileRenamed` 的类别字段叫 `file_kind`,因为 `kind` 是 `Event` 的类变量;`OrganizedFile` 保留 `kind`。路径为下载器视角、以 `/` 拼接的绝对路径;`downloader_id` 在 P2.5 之前固定为 `"default"`。
- **`TorrentOrganized` 的投递是至少一次**:进程内按 hash 记忆已发布的最终文件集合,重启后、或文件集合变化时会再次发布。重命名方式为 `none` 的种子同样发布。订阅者必须幂等。
- **WebUI**:重命名方式下拉框经 `usePluginProviders` 合并插件提供的 id(如 `template`),`rename_method` 类型放宽为字符串。`template` 在 设置 → 番剧管理设置 → 重命名方式 中选择,模板在 设置 → 插件 → 重命名 中填写。
- `ab_sdk` 新增 `ab_sdk.rename`(契约与冻结快照 `RenameInput`、`Revision`、`RevisionTask`、`ConflictRequest`,以及 `pad()`),`SDK_VERSION` 升到 `0.4.0`。
插件运行时(为内置插件 `hardlink` 补齐):
- **清单字段 `default_enabled`**(默认 `true`,只对内置插件生效)。`hardlink` 设为 `false`,内置但默认停用。
- **`@subscribe(kind, timeout=...)`**:覆盖总线默认 30 秒的单个事件处理超时。跨盘复制一集常超过 30 秒,超时会计入熔断,5 个种子后插件就会被停用。`hardlink` 用 3600 秒,文件操作放在 `asyncio.to_thread` 中执行,不阻塞事件循环。
- **插件的可通知事件进入通知中心**:P5 中插件发布的 `SystemEvent` 只进事件总线。现在 `PluginBus.publish` 把它交给 `NotificationManager.send_event`,与宿主事件一样写入通知中心、发布到总线、推送外部渠道。前端没有对应翻译的 kind 显示 `describe()` 的中文标题与正文。
内置插件 `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` 内。插件代码加载过一次后才有设置表单,所以首次启用必然加载失败(缺少必填项),用户随后在出现的表单中填写、保存,插件重新加载。
- `path_map` 是对象列表,P2 的 JSON Schema 表单不支持对象数组(显示为「不支持的字段」),P4 期间只能在 `config.json` 中填写。表单支持对象数组留到 P6 / P7。
- 所有放置(新建与版本升级替换)都先写到同目录的临时文件 `.<文件名>.<随机串>.ab-hardlink`,再 `os.replace` 到目标;失败时删除临时文件。复制中断不会在目标留下半个文件,否则重试时它会被当成「不是本插件创建的」冲突。临时文件名每次不同,因为补链与订阅可能同时处理同一个目标。
- `hardlink.failed` 的通知正文只能显示种子 hash,因为 `TorrentOrganized` 不带种子名;补充种子名留到 P2.5 / P6。
- 「由插件创建」记录在插件 KV 中:键 `link:<目标路径>`,值为 `[源文件身份, 目标身份]`,身份为 `lstat` 的 `[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 实现相同)挑选文件。
- **补链按钮推迟到 P6**:设置页的按钮依赖 P6 的 `settings.section` 挂载点,P4 只提供 REST 路由。
- 删除种子不会删除媒体库中的链接。
未实施(推迟):
- 第 10 节 P4 行中的 `file_parser` 扩展点。
- 第 7 节的 `ab_sdk.testing.RenameStrategyContract` 契约测试套件。
两份 P4 实现与移植:
- P4 有两份独立实现。另一个会话的版本(提交 a0706513、12e7e2f5,基于旧 P5 0b6de315)曾推到 `refactor/4.0-p4-organize`,现保留为 `refactor/4.0-p4-organize-cloud`(head 544628f6)。本分支保留本节上文的设计,因为它符合已确认的决策:`pn` / `advance` / `template` 都在内置插件 `rename` 中,坏模板跳过文件并通知、不退回 `pn`,另有 `hardlink`。另一份的 `rename-template` 插件、`BangumiLink`、`ab_sdk.organize` 与由 `PluginManager` 设置进程级总线的做法没有移植。
- 从另一份移植并按本分支改写的内容:
- **内置插件 `media-server-refresh`**:订阅 `torrent.organized`(另一份订阅 `file.renamed`;前者在重命名方式为 `none` 时也会发布),等待 `delay` 秒,把期间的事件合并成一次 Jellyfin / Emby / Plex 刷新请求。未填写地址或 API Key 时不做任何事,所以与另一份相同,默认启用。由于至少一次投递,已配置时每次重启最多多出一次合并后的刷新。
- **死代码**:删除 `Renamer.rename_file` / `_rename_media_file`、`_lookup_offsets` / `_normalize_path`、`BangumiDatabase.match_by_save_path`、`TorrentDatabase.search_by_qb_hash`、`RenameOperationDatabase.release_replacement_lease`。删除前确认它们在本分支(含 `revision_saga.py`)没有生产调用方。原测试改为经 `rename()` 与 `_batch_lookup_offsets` 驱动。`season_offset` 从 `gen_path` 起整条重命名链路移除,`_batch_lookup_offsets` 的结果从 `(集数偏移, 季度偏移, 类型)` 改为 `(集数偏移, 类型)`:季度偏移已体现在 Season 文件夹,文件名从未使用它。
- **插件配置 422**:`field_validator` 抛出的 `ValueError` 会留在 `ValidationError.errors()` 的 `ctx` 中,无法 JSON 序列化,保存配置返回 500(`hardlink` 的路径校验、`rename` 的模板校验都会触发)。现在以 `include_context=False` 返回 422。
- **用户文档**:`docs/{,en/,ja/}config/manager.md` 增加 `template`、`hardlink`(含 `path_map` 与 Docker 下硬链接不能跨文件系统的说明)与 `media-server-refresh` 三节,按本分支的设计重写;`CHANGELOG.md` 增加 P4 条目。
- 移植时发现并修复:vulture 白名单缺少 `rename` / `hardlink` 内置插件的入口(CI 的 vulture 检查会失败);VitePress 不给行内代码加 `v-pre`,文档里行内代码中的 Jinja2 示例在构建时报 `_ctx.pad is not a function`,现在用 `::: v-pre` 容器包住(含本设计文档第 3.6 节与插件开发文档)。
## 1. 背景与目标
AB 目前只有 **LLM 提供商** 是真正的运行时插件系统:签名下载、目录加载、懒导入、热重载。
@@ -266,7 +318,9 @@ TMDB 常量(genre 16、`w780`、`gap_months=6`)收进插件配置。
| `torrent.organized` | Observer hook | 整个种子处理完成 | 无 |
内置 `rename_strategy` 包括 `pn`、`advance`、`none`,并新增 `template`:
::: v-pre
- `template` 使用 Jinja2 沙箱模板,例如 `{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(2) }}`。项目已依赖 Jinja2,能覆盖 80% 的定制需求。
:::
- 字幕不再是 `subtitle_*` 平行方法,改为 `RenameInput.kind`。
- 删除废弃的 `normal`。
@@ -591,7 +645,7 @@ organize: downloader.completed → media_files.classify → file_parser
| **P2 迁移已有注册表** | 下载器、通知、LLM、搜索站点、定时任务改为扩展点,内置实现以 `core` 登记;`/api/v1/plugins`(列表、启停、配置、Provider 列表)与 WebUI 插件卡片(JSON Schema 表单);`secret_field` 掩码;插件开发文档 | 已完成;内置行为不变(全量测试)。调整见第 0 节 |
| **P2.5 多下载器** | 下载器多实例;`downloader_id` 列与迁移;按实例路由 add / rename / delete;organize 逐实例扫描 | qb + aria2 并存的 e2e 用例;单实例行为不变 |
| **P3 流水线插件化:ingest** | `torrent.filter`、`title.parsed`、`torrent.adding`、`http.request` 钩子;`metadata_provider`(mikan / tmdb 以 `core` 登记);内置插件 `ingest-filters`(包含过滤) | 已完成;无插件时行为不变(全量测试)。`feed_source`、`title_parser` 链、`admission_policy`、`matcher`、`ranker`、`save_path`、size 过滤、按订阅覆盖推迟,见第 0 节 |
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 模板重命名;硬链接示例插件 |
| **P4 流水线插件化:organize** | `media_files`、`file_parser`、`rename_strategy`(含 `template`)、`conflict_policy`、`file.renamed` 等事件 | 已完成:`renamer.py` 拆出 `revision_saga.py`;`rename_strategy` / `media_files` / `conflict_policy` 扩展点与 `file.renamed` / `torrent.organized` 事件;内置插件 `rename`(pn / advance / template,pn / advance / none 输出与 3.3 一致)、`hardlink`(默认停用)与 `media-server-refresh`。`file_parser`、`RenameStrategyContract`、补链设置按钮(P6)推迟,调整见第 0 节 |
| **P5 事件与外部接口** | SSE 改订阅 bus;`api_router`、`mcp_tool` 扩展点;`message_template` | 已完成:系统事件上总线、通知中心 SSE 改为事件推送、插件路由 / MCP 工具与资源 / 通知模板;status 等快照类 SSE 仍按节拍采样。调整见第 0 节 |
| **P6 前端插件** | Web Component 挂载点、`AbHost` 桥接、错误边界、`/plugins/<id>/web` 静态资源、`@autobangumi/plugin-ui` 包 | 示例插件「手动选种」以详情页标签形式可用 |
| **P7 生态** | 插件管理页(安装、启停、日志、错误)、签名目录发布流程、模板仓库(含前端模板)、`ab-plugin` CLI、文档(中 / 英 / 日) | 6 个以上示例插件上架 |
@@ -604,7 +658,7 @@ organize: downloader.completed → media_files.classify → file_parser
已决议题(SDK 版本、插件依赖、前端插件、多下载器)见第 0 节。剩余风险:
1. **性能**。每个种子都要经过多段 hook,RSS 一次可能有几百条。Filter hook 需要支持批量接口 `accept_many`,并加基准测试。
2. **`release_replacement_lease` 无调用方**。初步判断冗余:状态迁移会清租约,租约也有过期时间。P0 删除前,确认 claim 之后的提前退出路径只靠过期回收是否可以接受(最长占用一个租约周期)。
2. ~~**`release_replacement_lease` 无调用方**~~。已在 P4 删除:`set_state_claimed` 的同一条 UPDATE 写入状态并清除租约;异常退出的步骤只靠过期回收,最长占用一个租约周期(5 分钟,与重试冷却相同),只会推迟下一次尝试。
3. **翻译**。事件 `describe()` 当前硬编码中文。插件化后要走 i18n key,否则第三方插件消息无法翻译。前端组件通过 `host.i18n` 拿当前语言。
4. **多下载器的跨实例一致性**。同一番剧中途更换下载器时,已下载种子仍留在旧实例。renamer 按 `torrent.downloader_id` 路由即可,但 UI 要明确展示每个种子所在实例。此外不做跨实例迁移。
5. **前端插件的 0.x 期 API 变动**。`AbHost` 与挂载点同样遵循 SDK 0.x → 1.0 的节奏,4.0 期间可能调整,文档要标注。

View File

@@ -18,6 +18,7 @@ const KIND_ROUTES: Record<string, string> = {
offset_review: '/bangumi',
download_failure: '/bangumi',
rename_conflict: '/downloader',
rename_skipped: '/downloader',
};
const SEVERITY_ICONS = {

View File

@@ -1,7 +1,18 @@
import { mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
import { defineComponent, nextTick, ref } from 'vue';
import ConfigManage from '../config-manage.vue';
vi.mock('@/hooks/usePluginProviders', () => ({
usePluginProviders: () =>
ref({
downloader: [],
notifier: [],
search_site: [],
metadata_provider: [],
rename_strategy: ['advance', 'pn', 'template'],
}),
}));
vi.mock('@/hooks/useMyI18n', () => ({
useMyI18n: () => ({ t: (key: string) => key }),
}));
@@ -80,4 +91,29 @@ describe('config-manage', () => {
};
expect(store.__manageState.revision_conflict_policy).toBe('replace');
});
it('should append plugin rename strategies when the rename plugin provides them', () => {
const wrapper = mount(ConfigManage, {
global: {
stubs: {
'ab-fold-panel': { template: '<section><slot /></section>' },
'ab-setting': AbSettingStub,
},
},
});
const method = wrapper
.findAllComponents(AbSettingStub)
.find(
(setting) =>
(setting.props('label') as () => string)() ===
'config.manage_set.method'
);
expect(method?.props('prop')?.items).toEqual([
'pn',
'advance',
'none',
'template',
]);
});
});

View File

@@ -5,12 +5,21 @@ import type {
RevisionConflictPolicy,
} from '#/config';
import type { SelectItem, SettingItem } from '#/components';
import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t } = useMyI18n();
const { getSettingGroup } = useConfigStore();
const manage = getSettingGroup('bangumi_manage');
const renameMethod: RenameMethod = ['pn', 'advance', 'none'];
const builtinMethods: RenameMethod = ['pn', 'advance', 'none'];
const pluginProviders = usePluginProviders();
// pn / advance 也由内置插件 rename 提供;插件新增的方式(如 template)追加在后
const renameMethod = computed(() => [
...builtinMethods,
...pluginProviders.value.rename_strategy.filter(
(id) => !(builtinMethods as readonly string[]).includes(id)
),
]);
const revisionConflictPolicies: RevisionConflictPolicy = ['hold', 'replace'];
const revisionConflictOptions = computed<SelectItem[]>(() => [
@@ -37,7 +46,7 @@ const items = computed<SettingItem<BangumiManage>[]>(() => [
label: () => t('config.manage_set.method'),
type: 'select',
prop: {
items: renameMethod,
items: renameMethod.value,
},
},
{

View File

@@ -18,6 +18,7 @@ const empty = {
notifier: [],
search_site: [],
metadata_provider: [],
rename_strategy: [],
};
describe('usePluginProviders', () => {

View File

@@ -6,6 +6,7 @@ const providers = ref<PluginProviders>({
notifier: [],
search_site: [],
metadata_provider: [],
rename_strategy: [],
});
let loaded = false;
@@ -20,7 +21,7 @@ export async function refreshPluginProviders() {
}
/**
* 插件提供的下载器 / 通知渠道 / 搜索站点 / 元数据源 id,供设置页下拉框合并候选。
* 插件提供的下载器 / 通知渠道 / 搜索站点 / 元数据源 / 重命名方式 id,供设置页下拉框合并候选。
* 首次使用时请求;请求失败(如未登录)时保持空列表,不影响内置选项。
*/
export function usePluginProviders() {

View File

@@ -755,6 +755,10 @@
"rename_conflict": {
"title": "Media rename conflict",
"body": "{torrent_name} → {target_path}: {reason}"
},
"rename_skipped": {
"title": "Files kept their names",
"body": "{torrent_name} ({strategy}): {reason}"
}
}
}

View File

@@ -755,6 +755,10 @@
"rename_conflict": {
"title": "媒体文件重命名冲突",
"body": "{torrent_name} → {target_path}:{reason}"
},
"rename_skipped": {
"title": "文件未重命名",
"body": "{torrent_name}({strategy}):{reason}"
}
}
}

View File

@@ -13,6 +13,7 @@ const KNOWN_KINDS = [
'llm_auth_failure',
'llm_plugin_install_failed',
'rename_conflict',
'rename_skipped',
] as const;
export const useNotificationStore = defineStore('notification', () => {

View File

@@ -62,7 +62,8 @@ export interface RssParser {
export interface BangumiManage {
enable: boolean;
eps_complete: boolean;
rename_method: TupleToUnion<RenameMethod>;
/** 内置方式,或插件提供的重命名方式 id(如 template) */
rename_method: TupleToUnion<RenameMethod> | (string & {});
revision_conflict_policy: TupleToUnion<RevisionConflictPolicy>;
group_tag: boolean;
remove_bad_torrent: boolean;

View File

@@ -58,4 +58,5 @@ export interface PluginProviders {
notifier: string[];
search_site: string[];
metadata_provider: string[];
rename_strategy: string[];
}