feat(4.0-P4): 内置插件 rename 提供 pn / advance / template 重命名方式

- pn / advance 从 core 移入内置插件 rename(默认启用),输出与 3.x 逐字节一致;
  宿主只保留 none,插件停用时按 none 处理。
- template:Jinja2 沙箱模板(StrictUndefined + pad 过滤器)渲染文件名主体,
  字幕自动追加语言与扩展名。保存配置时编译并试渲染示例,不合法返回 422;
  运行时渲染失败、结果为空或含路径分隔符时抛出 RenameSkipped,文件保留原名并
  通知,绝不退回 pn。
- 设置页重命名方式下拉框并入插件提供的方式;通知中心支持 rename_skipped。
- 测试环境默认登记内置 rename 插件,与生产默认启用一致。
This commit is contained in:
Estrella Pan
2026-10-08 10:37:58 +02:00
parent e0b8939ec5
commit a0ebfff0e1
16 changed files with 380 additions and 48 deletions

View File

@@ -1,42 +1,14 @@
"""重命名策略:由媒体文件信息生成目标文件名。
"""宿主自带的重命名方式 ``none``:保留原路径。
注意:这里的 title/bangumi_name 来自已存在于磁盘上的文件/文件夹名(单个路径
分量,不可能含分隔符),不做保留字符清洗——追加清洗会让既有做种库(如含 ":"
的标题)在升级后被整库批量重命名 (#721 评审)。
电影/剧场版:Title (Year).ext,不使用 SxxExx 编号。bangumi_name 由调用方传入,
与 gen_save_path 的文件夹命名保持一致 (Title (Year))。
``pn`` / ``advance`` / ``template`` 由内置插件 ``rename`` 提供
(module/plugins/builtin/rename/)。
"""
from ab_sdk.rename import RenameInput, pad
from ab_sdk.rename import RenameInput
NO_RENAME = "none"
class NoRename:
"""``none``:保留原路径。"""
def target_name(self, f: RenameInput) -> str:
return f.media_path
def standard_name(base: str, f: RenameInput) -> str:
"""``{base} SxxEyy[.语言].ext``;电影为 ``{base}[.语言].ext``。"""
language = f".{f.language}" if f.kind == "subtitle" else ""
if f.episode_type == "movie":
return f"{base}{language}{f.suffix}"
return f"{base} S{pad(f.season)}E{pad(f.episode)}{language}{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)

View File

@@ -181,19 +181,13 @@ def _register_core(registry: ExtensionRegistry) -> None:
ProviderEntry(CORE, provider_id, metadata(provider_id)),
)
# organize:重命名方式 none、按扩展名分类、版本冲突策略
# organize:重命名方式 none(pn / advance / template 由内置插件 rename 提供)、
# 按扩展名分类、版本冲突策略
from module.downloader.path import SuffixMediaFiles
from module.manager.rename_strategies import (
NO_RENAME,
AdvanceRename,
NoRename,
PnRename,
)
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.RENAME_STRATEGY, "pn", PnRename())
_core(registry, points.RENAME_STRATEGY, "advance", AdvanceRename())
_core(registry, points.MEDIA_FILES, CORE_ID, SuffixMediaFiles())
_core(registry, points.CONFLICT_POLICY, CORE_ID, CoreConflictPolicy())

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ 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 (
@@ -20,13 +21,15 @@ from ab_sdk.rename import (
Revision,
RevisionTask,
)
from ab_sdk.testing import RecordingBus
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.plugin import host
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
@@ -253,3 +256,123 @@ def test_core_conflict_policy_decide_matches_3x_reasons(
ConflictRequest("t.mkv", incoming, owners, configured, strict)
)
assert (decision.action, decision.reason) == (action, reason)
# ---------------------------------------------------------------- built-in plugin
def load_rename_plugin():
candidates, errors = discover(
local_root=BUILTIN_ROOT / "__missing__", entry_point_group="ab-test-none"
)
assert errors == []
return {c.manifest.id: c for c in candidates}["rename"]
def template_strategy(tmp_path, template: str):
plugin, _ = create_plugin(
load_rename_plugin().load(), {"template": template}, data_dir=tmp_path
)
return plugin.template_strategy()
def rename_input(**overrides) -> RenameInput:
fields = {
"kind": "media",
"media_path": NAME,
"title": "Anime",
"bangumi_name": "Anime (2024)",
"season": 1,
"episode": 1,
"suffix": ".mkv",
"group": "Sub",
}
return RenameInput(**{**fields, **overrides})
@pytest.mark.parametrize(
("template", "overrides", "expected"),
[
(None, {}, "Anime S01E01.mkv"),
(None, {"episode": 12.5}, "Anime S01E12.5.mkv"),
(
"{{ title }} - S{{ season|pad(2) }}E{{ episode|pad(3) }}",
{"kind": "subtitle", "language": "zh-tw", "suffix": ".ass"},
"Anime - S01E001.zh-tw.ass",
),
(
"{% if episode_type == 'movie' %}{{ bangumi_name }}"
"{% else %}[{{ group }}] {{ title }} {{ episode }}{% endif %}",
{"episode_type": "movie"},
"Anime (2024).mkv",
),
],
)
def test_template_strategy_renders_stem_and_appends_suffix(
tmp_path, template, overrides, expected
):
plugin, _ = create_plugin(
load_rename_plugin().load(),
{} if template is None else {"template": template},
data_dir=tmp_path,
)
strategy = plugin.template_strategy()
assert strategy.target_name(rename_input(**overrides)) == expected
@pytest.mark.parametrize(
"template",
[
"{{ title ", # 语法错误
"{{ titel }}", # 未定义变量
"{{ title.__class__ }}", # 沙箱拒绝
"{{ group }}/{{ title }}", # 路径分隔符
"{{ '' }}", # 空文件名
"..",
],
)
def test_template_options_invalid_template_rejected(template):
options_model = load_rename_plugin().load().config_model
with pytest.raises(ValidationError):
options_model.model_validate({"template": template})
@pytest.mark.parametrize(
("template", "overrides"),
[
("{{ group }}", {"group": None}), # 运行时渲染为空
("{{ title }}", {"title": "a/b"}), # 运行时出现分隔符
("{{ (episode / season)|int }}", {"season": 0}), # 运行时渲染异常
],
)
def test_template_strategy_bad_runtime_name_raises_skipped(
tmp_path, template, overrides
):
strategy = template_strategy(tmp_path, template)
with pytest.raises(RenameSkipped):
strategy.target_name(rename_input(**overrides))
async def test_rename_plugin_loaded_by_manager_enabled_by_default(plugins, tmp_path):
manager = PluginManager(
SimpleNamespace(plugins=Plugins()),
registry=plugins.registry,
discover_fn=lambda: ([load_rename_plugin()], []),
data_root=tmp_path,
)
await manager.start()
try:
status = {s.id: s for s in manager.statuses()}["rename"]
assert status.state == "active"
strategies = plugins.registry.providers(points.RENAME_STRATEGY)
assert {pid: e.plugin_id for pid, e in strategies.items()} == {
"none": "core",
"pn": "rename",
"advance": "rename",
"template": "rename",
}
with pytest.raises(ValidationError):
manager.validate_options("rename", {"template": "{{ nope }}"})
finally:
await manager.stop()
assert set(plugins.registry.providers(points.RENAME_STRATEGY)) == {"none"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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