diff --git a/backend/src/module/manager/rename_strategies.py b/backend/src/module/manager/rename_strategies.py index 595ca074..7676fb0b 100644 --- a/backend/src/module/manager/rename_strategies.py +++ b/backend/src/module/manager/rename_strategies.py @@ -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) diff --git a/backend/src/module/plugin/host.py b/backend/src/module/plugin/host.py index cd09ef6e..0482ef97 100644 --- a/backend/src/module/plugin/host.py +++ b/backend/src/module/plugin/host.py @@ -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()) diff --git a/backend/src/module/plugins/builtin/rename/plugin.toml b/backend/src/module/plugins/builtin/rename/plugin.toml new file mode 100644 index 00000000..c7af54f1 --- /dev/null +++ b/backend/src/module/plugins/builtin/rename/plugin.toml @@ -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"] diff --git a/backend/src/module/plugins/builtin/rename/rename_plugin.py b/backend/src/module/plugins/builtin/rename/rename_plugin.py new file mode 100644 index 00000000..18bdb9e3 --- /dev/null +++ b/backend/src/module/plugins/builtin/rename/rename_plugin.py @@ -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 diff --git a/backend/src/test/conftest.py b/backend/src/test/conftest.py index 6fcf4c51..3dbd0138 100644 --- a/backend/src/test/conftest.py +++ b/backend/src/test/conftest.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/src/test/test_organize.py b/backend/src/test/test_organize.py index 7f2d1cca..eae8c61c 100644 --- a/backend/src/test/test_organize.py +++ b/backend/src/test/test_organize.py @@ -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"} diff --git a/webui/src/components/layout/ab-notification-center.vue b/webui/src/components/layout/ab-notification-center.vue index 3cef074e..d9b78bd0 100644 --- a/webui/src/components/layout/ab-notification-center.vue +++ b/webui/src/components/layout/ab-notification-center.vue @@ -18,6 +18,7 @@ const KIND_ROUTES: Record = { offset_review: '/bangumi', download_failure: '/bangumi', rename_conflict: '/downloader', + rename_skipped: '/downloader', }; const SEVERITY_ICONS = { diff --git a/webui/src/components/setting/__tests__/config-manage.test.ts b/webui/src/components/setting/__tests__/config-manage.test.ts index 3ee03a55..ca0dea78 100644 --- a/webui/src/components/setting/__tests__/config-manage.test.ts +++ b/webui/src/components/setting/__tests__/config-manage.test.ts @@ -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: '
' }, + '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', + ]); + }); }); diff --git a/webui/src/components/setting/config-manage.vue b/webui/src/components/setting/config-manage.vue index 0523e179..d724498b 100644 --- a/webui/src/components/setting/config-manage.vue +++ b/webui/src/components/setting/config-manage.vue @@ -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(() => [ @@ -37,7 +46,7 @@ const items = computed[]>(() => [ label: () => t('config.manage_set.method'), type: 'select', prop: { - items: renameMethod, + items: renameMethod.value, }, }, { diff --git a/webui/src/hooks/__tests__/usePluginProviders.test.ts b/webui/src/hooks/__tests__/usePluginProviders.test.ts index bfdfed8f..5eaf9879 100644 --- a/webui/src/hooks/__tests__/usePluginProviders.test.ts +++ b/webui/src/hooks/__tests__/usePluginProviders.test.ts @@ -18,6 +18,7 @@ const empty = { notifier: [], search_site: [], metadata_provider: [], + rename_strategy: [], }; describe('usePluginProviders', () => { diff --git a/webui/src/hooks/usePluginProviders.ts b/webui/src/hooks/usePluginProviders.ts index b68f7e9b..f3ed5d6b 100644 --- a/webui/src/hooks/usePluginProviders.ts +++ b/webui/src/hooks/usePluginProviders.ts @@ -6,6 +6,7 @@ const providers = ref({ notifier: [], search_site: [], metadata_provider: [], + rename_strategy: [], }); let loaded = false; @@ -20,7 +21,7 @@ export async function refreshPluginProviders() { } /** - * 插件提供的下载器 / 通知渠道 / 搜索站点 / 元数据源 id,供设置页下拉框合并候选。 + * 插件提供的下载器 / 通知渠道 / 搜索站点 / 元数据源 / 重命名方式 id,供设置页下拉框合并候选。 * 首次使用时请求;请求失败(如未登录)时保持空列表,不影响内置选项。 */ export function usePluginProviders() { diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json index 7fec62ad..d1e6a8ea 100644 --- a/webui/src/i18n/en.json +++ b/webui/src/i18n/en.json @@ -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}" } } } diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json index 59a0b9b4..2eec08bd 100644 --- a/webui/src/i18n/zh-CN.json +++ b/webui/src/i18n/zh-CN.json @@ -755,6 +755,10 @@ "rename_conflict": { "title": "媒体文件重命名冲突", "body": "{torrent_name} → {target_path}:{reason}" + }, + "rename_skipped": { + "title": "文件未重命名", + "body": "{torrent_name}({strategy}):{reason}" } } } diff --git a/webui/src/store/notification.ts b/webui/src/store/notification.ts index cc6bdee5..ea19dcbe 100644 --- a/webui/src/store/notification.ts +++ b/webui/src/store/notification.ts @@ -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', () => { diff --git a/webui/types/config.ts b/webui/types/config.ts index 65d92070..37508b3b 100644 --- a/webui/types/config.ts +++ b/webui/types/config.ts @@ -62,7 +62,8 @@ export interface RssParser { export interface BangumiManage { enable: boolean; eps_complete: boolean; - rename_method: TupleToUnion; + /** 内置方式,或插件提供的重命名方式 id(如 template) */ + rename_method: TupleToUnion | (string & {}); revision_conflict_policy: TupleToUnion; group_tag: boolean; remove_bad_torrent: boolean; diff --git a/webui/types/plugins.ts b/webui/types/plugins.ts index bc018cd6..378266ce 100644 --- a/webui/types/plugins.ts +++ b/webui/types/plugins.ts @@ -58,4 +58,5 @@ export interface PluginProviders { notifier: string[]; search_site: string[]; metadata_provider: string[]; + rename_strategy: string[]; }