mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-01 05:06:58 +08:00
Merge pull request #1126 from EstrellaXD/refactor/4.0-p4-organize
feat(4.0-P4): 整理扩展点、内置 rename / hardlink 插件与 renamer 拆分
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -12,7 +12,7 @@ from .events import Event, PluginDisabled, PluginLoaded
|
||||
from .hooks import Verdict, hook, provider, subscribe
|
||||
from .plugin import Plugin
|
||||
|
||||
SDK_VERSION = "0.3.0"
|
||||
SDK_VERSION = "0.4.0"
|
||||
|
||||
__all__ = [
|
||||
"SDK_VERSION",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar
|
||||
|
||||
from .rename import FileKind
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Event:
|
||||
@@ -334,3 +336,79 @@ class RenameConflictEvent(SystemEvent):
|
||||
f"种子:{self.torrent_name}\n目标:{self.target_path}\n"
|
||||
f"原因:{self.reason}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RenameSkippedEvent(SystemEvent):
|
||||
"""重命名策略无法为种子中的文件给出名字(如模板渲染失败),文件保留原名。
|
||||
|
||||
同一种子、同一原因每个进程只通知一次;修正配置后下一轮会自动重试。
|
||||
"""
|
||||
|
||||
kind: ClassVar[str] = "rename_skipped"
|
||||
severity: ClassVar[str] = "warning"
|
||||
once: ClassVar[bool] = False
|
||||
|
||||
task_id: str
|
||||
torrent_name: str
|
||||
strategy: str
|
||||
reason: str
|
||||
|
||||
def dedup_key(self) -> str | None:
|
||||
return f"rename_skipped:{self.task_id}"
|
||||
|
||||
def payload(self) -> dict:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"torrent_name": self.torrent_name,
|
||||
"strategy": self.strategy,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"文件未重命名",
|
||||
f"种子:{self.torrent_name}\n重命名方式:{self.strategy}\n"
|
||||
f"原因:{self.reason}\n文件已保留原名,修正后会自动重试。",
|
||||
)
|
||||
|
||||
|
||||
# --- P4:organize 流水线事件(只发布到事件总线,不进通知中心) -------------------
|
||||
#
|
||||
# 路径均为下载器视角的绝对路径:保存目录与种子内相对路径以 "/" 拼接
|
||||
# (Windows 下载器的 "\" 也统一为 "/")。AutoBangumi 与下载器不在同一文件系统
|
||||
# 视图时(容器挂载不同),订阅者需要自行做路径映射。
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FileRenamed(Event):
|
||||
"""下载器中的一个文件被重命名到规范名(正片或字幕)。"""
|
||||
|
||||
kind: ClassVar[str] = "file.renamed"
|
||||
bangumi_id: int | None # 来自种子的 ab:<id> 标签;旧种子可能没有
|
||||
old_path: str
|
||||
new_path: str
|
||||
file_kind: FileKind
|
||||
downloader_id: str = "default"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizedFile:
|
||||
path: str
|
||||
kind: FileKind
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TorrentOrganized(Event):
|
||||
"""一个种子整理完成:顶层正片都已在最终位置(重命名方式为 none 时即原位置)。
|
||||
|
||||
``files`` 是正片与字幕的最终路径。投递语义为「至少一次」:未打
|
||||
``ab:renamed`` 标签的种子(如重命名方式为 none)每次进程重启后会再发布一次,
|
||||
订阅者须保证幂等。
|
||||
"""
|
||||
|
||||
kind: ClassVar[str] = "torrent.organized"
|
||||
torrent_hash: str
|
||||
bangumi_id: int | None
|
||||
files: tuple[OrganizedFile, ...]
|
||||
downloader_id: str = "default"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,3 +54,20 @@ API_ROUTER = "api_router"
|
||||
MCP_TOOL = "mcp_tool"
|
||||
MCP_RESOURCE = "mcp_resource"
|
||||
MESSAGE_TEMPLATE = "message_template"
|
||||
|
||||
# --- P4 organize(契约见 :mod:`ab_sdk.rename`) ---
|
||||
#
|
||||
# Provider 扩展点:
|
||||
#
|
||||
# - ``RENAME_STRATEGY``:返回 :class:`ab_sdk.rename.RenameStrategy`,id 即
|
||||
# ``bangumi_manage.rename_method`` 的取值。宿主自带 ``none``(不改名),
|
||||
# 内置插件 ``rename`` 提供 ``pn``、``advance``、``template``
|
||||
# - ``MEDIA_FILES``:返回 :class:`ab_sdk.rename.MediaFiles`
|
||||
# - ``CONFLICT_POLICY``:返回 :class:`ab_sdk.rename.ConflictPolicy`
|
||||
#
|
||||
# 事件(``@subscribe``):``file.renamed``(:class:`ab_sdk.events.FileRenamed`)、
|
||||
# ``torrent.organized``(:class:`ab_sdk.events.TorrentOrganized`)
|
||||
|
||||
RENAME_STRATEGY = "rename_strategy"
|
||||
MEDIA_FILES = "media_files"
|
||||
CONFLICT_POLICY = "conflict_policy"
|
||||
|
||||
133
backend/src/ab_sdk/rename.py
Normal file
133
backend/src/ab_sdk/rename.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""organize 流水线(下载完成 → 分类 → 重命名 → 后处理)的插件契约。
|
||||
|
||||
扩展点一览(常量见 :mod:`ab_sdk.points`):
|
||||
|
||||
- ``rename_strategy``(Provider):返回 :class:`RenameStrategy`,id 即设置项
|
||||
``bangumi_manage.rename_method`` 的取值
|
||||
- ``media_files``(Provider):返回 :class:`MediaFiles`,判断种子内文件的类别
|
||||
- ``conflict_policy``(Provider):返回 :class:`ConflictPolicy`,目标路径已被
|
||||
另一个种子占用时决定保留还是替换
|
||||
|
||||
``media_files`` 与 ``conflict_policy`` 目前只使用宿主自带的实现(id
|
||||
:data:`CORE_ID`),插件实现的选择随 P2.5 的 ``plugins.slots`` 一起提供。
|
||||
|
||||
整理完成后宿主发布 :class:`ab_sdk.events.FileRenamed` 与
|
||||
:class:`ab_sdk.events.TorrentOrganized`,插件用 ``@subscribe`` 接收。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
FileKind = Literal["media", "subtitle"]
|
||||
MediaKind = Literal["media", "subtitle", "ignore"]
|
||||
ConflictAction = Literal["hold", "replace"]
|
||||
|
||||
# media_files / conflict_policy 的宿主实现 id
|
||||
CORE_ID = "default"
|
||||
|
||||
|
||||
def pad(value: int | float, width: int = 2) -> str:
|
||||
"""把季号 / 集数的整数部分补零到 ``width`` 位,小数部分原样保留。
|
||||
|
||||
``pad(5) == "05"``、``pad(12) == "12"``、``pad(9.5) == "09.5"``、
|
||||
``pad(12.0) == "12"``。总集篇等半集(12.5)必须保留小数,否则会覆盖同季的
|
||||
整数集 (#667)。内置 pn / advance 与模板的 ``pad`` 过滤器都用它。
|
||||
"""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
whole, dot, fraction = str(value).partition(".")
|
||||
return whole.zfill(width) + dot + fraction
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RenameInput:
|
||||
"""一个待重命名文件。
|
||||
|
||||
``episode`` 已应用番剧的集数偏移;``season`` 来自保存目录(已含季度偏移)。
|
||||
``title`` 解析自文件名,``bangumi_name`` 是保存目录的番剧文件夹名(电影为
|
||||
``Title (Year)``)。两者都是单个路径分量,宿主不做保留字符清洗。
|
||||
"""
|
||||
|
||||
kind: FileKind
|
||||
media_path: str # 种子内的原相对路径
|
||||
title: str
|
||||
bangumi_name: str
|
||||
season: int
|
||||
episode: int | float
|
||||
suffix: str # 含点,如 ".mkv"
|
||||
episode_type: str = "episode" # episode | movie | special
|
||||
language: str = "" # 字幕语言(kind == "subtitle"),如 "zh"、"zh-tw"
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class RenameSkipped(Exception):
|
||||
"""策略无法为该文件给出名字时抛出:宿主保留原文件名,并为该种子发一条通知。
|
||||
|
||||
与其它异常不同,它表示「输入或用户配置有问题」,不计入插件熔断。
|
||||
"""
|
||||
|
||||
|
||||
class RenameStrategy(Protocol):
|
||||
def target_name(self, f: RenameInput) -> str:
|
||||
"""返回种子内的新相对路径(通常只是文件名,即把文件移到种子根目录)。
|
||||
|
||||
返回原路径 ``f.media_path`` 表示不改名。须为非空字符串,否则按插件
|
||||
失败处理(计入熔断)并保留原文件名。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class MediaFiles(Protocol):
|
||||
def classify(self, path: str) -> MediaKind:
|
||||
"""种子内文件的类别:``media`` 重命名为正片,``subtitle`` 随正片重命名,
|
||||
``ignore`` 不处理。"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Revision:
|
||||
"""严格解析出的发布身份;只有同一身份、版本号更高时才算版本升级。"""
|
||||
|
||||
bangumi_id: int
|
||||
media_type: str
|
||||
season: int
|
||||
episode: int | float
|
||||
group: str
|
||||
resolution: str
|
||||
revision: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RevisionTask:
|
||||
"""下载器中的一个种子任务。"""
|
||||
|
||||
hash: str
|
||||
name: str
|
||||
file_count: int
|
||||
revision: Revision | None # 标题解析不完整时为 None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConflictRequest:
|
||||
"""新种子(``incoming``)的规范目标路径已被其它任务(``owners``)占用。"""
|
||||
|
||||
target_path: str
|
||||
incoming: RevisionTask
|
||||
owners: tuple[RevisionTask, ...]
|
||||
# 用户在设置中选择的版本冲突策略(bangumi_manage.revision_conflict_policy)
|
||||
configured: ConflictAction
|
||||
# 唯一占用者与新种子是同一发布、且新种子版本号更高
|
||||
strict_upgrade: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConflictDecision:
|
||||
action: ConflictAction
|
||||
reason: str = "" # hold 时展示给用户的原因
|
||||
|
||||
|
||||
class ConflictPolicy(Protocol):
|
||||
def decide(self, request: ConflictRequest) -> ConflictDecision:
|
||||
"""``replace`` 会删除旧任务及其文件。宿主只在「唯一占用者、双方都是
|
||||
单文件种子、双方身份完整」时执行替换,否则一律按 ``hold`` 处理。"""
|
||||
...
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
backend/src/module/manager/rename_strategies.py
Normal file
14
backend/src/module/manager/rename_strategies.py
Normal 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
@@ -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:
|
||||
|
||||
988
backend/src/module/manager/revision_saga.py
Normal file
988
backend/src/module/manager/revision_saga.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
257
backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py
Normal file
257
backend/src/module/plugins/builtin/hardlink/hardlink_plugin.py
Normal 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
|
||||
10
backend/src/module/plugins/builtin/hardlink/plugin.toml
Normal file
10
backend/src/module/plugins/builtin/hardlink/plugin.toml
Normal 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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
8
backend/src/module/plugins/builtin/rename/plugin.toml
Normal file
8
backend/src/module/plugins/builtin/rename/plugin.toml
Normal 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"]
|
||||
140
backend/src/module/plugins/builtin/rename/rename_plugin.py
Normal file
140
backend/src/module/plugins/builtin/rename/rename_plugin.py
Normal 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
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
294
backend/src/test/test_hardlink.py
Normal file
294
backend/src/test/test_hardlink.py
Normal 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]
|
||||
136
backend/src/test/test_media_server_refresh.py
Normal file
136
backend/src/test/test_media_server_refresh.py
Normal 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 == []
|
||||
402
backend/src/test/test_organize.py
Normal file
402
backend/src/test/test_organize.py
Normal 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"}
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 就是这样发出的。
|
||||
|
||||
### 通知文案模板
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 期间可能调整,文档要标注。
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ const empty = {
|
||||
notifier: [],
|
||||
search_site: [],
|
||||
metadata_provider: [],
|
||||
rename_strategy: [],
|
||||
};
|
||||
|
||||
describe('usePluginProviders', () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,6 +755,10 @@
|
||||
"rename_conflict": {
|
||||
"title": "媒体文件重命名冲突",
|
||||
"body": "{torrent_name} → {target_path}:{reason}"
|
||||
},
|
||||
"rename_skipped": {
|
||||
"title": "文件未重命名",
|
||||
"body": "{torrent_name}({strategy}):{reason}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,4 +58,5 @@ export interface PluginProviders {
|
||||
notifier: string[];
|
||||
search_site: string[];
|
||||
metadata_provider: string[];
|
||||
rename_strategy: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user