Merge branch 'fix/4.0-builtin-plugin-i18n' into feat/4.0-plugin-market-ui

# Conflicts:
#	webui/src/i18n/en.json
#	webui/src/i18n/zh-CN.json
#	webui/src/utils/id-label.ts
This commit is contained in:
Estrella Pan
2026-10-09 13:37:41 +02:00
7 changed files with 246 additions and 17 deletions

View File

@@ -331,12 +331,12 @@ class PluginManager:
del self._failed[plugin_id]
def _blocked_reason(self, candidate: PluginCandidate) -> str | None:
"""插件不应运行的原因;None 表示应启用。"""
"""插件不应运行的原因代码(前端按代码翻译);None 表示应启用。"""
conf = self._settings.plugins
if not self._enabled(candidate):
return "未启用"
return "not_enabled"
if not candidate.signed and not conf.allow_unsigned:
return "未签名插件需要开启 plugins.allow_unsigned"
return "unsigned_blocked"
return None
def _enabled(self, candidate: PluginCandidate) -> bool:

View File

@@ -686,7 +686,7 @@ class TestManager:
await manager.start()
[status] = manager.statuses()
assert status.state == "disabled"
assert "allow_unsigned" in (status.error or "")
assert status.error == "unsigned_blocked"
settings_obj.plugins.allow_unsigned = True
await manager.apply_settings()
assert manager.statuses()[0].state == "active"
@@ -834,7 +834,7 @@ class TestManager:
settings_obj.plugins.enabled = {"demo": False}
await manager.apply_settings()
[status] = manager.statuses()
assert (status.state, status.error) == ("disabled", "未启用")
assert (status.state, status.error) == ("disabled", "not_enabled")
async def test_unknown_extension_point_fails_load(self, tmp_path):
recorder = Recorder()
@@ -984,7 +984,7 @@ class TestPluginsApi:
source="local",
signed=False,
state="disabled",
error="未签名插件需要开启 plugins.allow_unsigned",
error="unsigned_blocked",
)
ctx = SimpleNamespace(plugins=SimpleNamespace(statuses=lambda: [status]))
monkeypatch.setattr(settings, "plugins", Plugins())

View File

@@ -13,6 +13,12 @@ import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
import { idLabel } from '@/utils/id-label';
import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
import {
localizeFields,
pluginDescription,
pluginName,
pluginReason,
} from '@/utils/plugin-text';
// 插件卡片不参与全局保存:每次改动直接调 /plugins 接口落盘并应用,
// 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。
@@ -133,7 +139,7 @@ async function setEnabled(plugin: PluginInfo, enabled: boolean) {
impact &&
!(await confirm({
title: t('config.plugins_set.disable_confirm_title', {
name: plugin.name,
name: pluginName(t, plugin.id, plugin.name),
}),
body: impact,
confirmText: t('config.plugins_set.disable'),
@@ -157,7 +163,7 @@ async function saveOptions(plugin: PluginInfo) {
async function uninstall(plugin: PluginInfo) {
const confirmed = await confirm({
title: t('config.plugins_set.uninstall_confirm_title', {
name: plugin.name,
name: pluginName(t, plugin.id, plugin.name),
}),
body: [impactText(plugin), t('config.plugins_set.uninstall_confirm_body')]
.filter(Boolean)
@@ -213,7 +219,7 @@ onMounted(load);
>
<header class="plugin__header">
<div class="plugin__title">
<strong>{{ plugin.name }}</strong>
<strong>{{ pluginName(t, plugin.id, plugin.name) }}</strong>
<span class="plugin__meta"
>{{ plugin.id }} · v{{ plugin.version }}</span
>
@@ -222,7 +228,9 @@ onMounted(load);
:model-value="plugin.enabled"
:loading="busy.has(plugin.id)"
:aria-label="
$t('config.plugins_set.enabled_for', { name: plugin.name })
$t('config.plugins_set.enabled_for', {
name: pluginName(t, plugin.id, plugin.name),
})
"
@update:model-value="setEnabled(plugin, $event)"
/>
@@ -239,14 +247,14 @@ onMounted(load);
</div>
<p v-if="plugin.description" class="plugin__desc">
{{ plugin.description }}
{{ pluginDescription(t, plugin.id, plugin.description) }}
</p>
<AbAlert
v-if="plugin.error"
v-if="plugin.error && plugin.error !== 'not_enabled'"
:type="plugin.state === 'error' ? 'danger' : 'info'"
:title="$t(`config.plugins_set.state_${plugin.state}`)"
>
{{ plugin.error }}
{{ pluginReason(t, plugin.error) }}
</AbAlert>
<p
v-if="plugin.permissions.length"
@@ -269,7 +277,9 @@ onMounted(load);
<template v-if="plugin.config_schema">
<PluginSchemaForm
v-model="drafts[plugin.id]"
:fields="schemaFields(plugin.config_schema)"
:fields="
localizeFields(t, plugin.id, schemaFields(plugin.config_schema))
"
/>
<div class="plugin__save">
<AbButton

View File

@@ -259,7 +259,11 @@
"field_min": "Must be {n} or more",
"field_max": "Must be {n} or less",
"market_hint": "Install and update plugins in the plugin market.",
"open_market": "Open plugin market"
"open_market": "Open plugin market",
"reason": {
"not_enabled": "Not enabled",
"unsigned_blocked": "Unsigned plugins only load when “Allow unsigned plugins” is on."
}
}
},
"downloader": {
@@ -906,5 +910,82 @@
"no_filter_match": "No plugins match these filters",
"show_all": "Show all plugins",
"empty": "The catalog has no plugins yet"
},
"builtin_plugins": {
"hardlink": {
"name": "Hardlink to media library",
"description": "After organizing, hardlink episodes and subtitles into the media library folder. The download folder stays as it is and keeps seeding.",
"fields": {
"path_map": {
"title": "Path mapping",
"description": "When the downloader and AutoBangumi see different file systems (for example, separate containers), replace the downloader's path prefix with a local path. The longest matching prefix wins. Paths that do not match are used as they are.",
"downloader": {
"title": "Downloader",
"description": "Downloader instance id"
},
"from": {
"title": "Downloader path",
"description": "Path prefix as the downloader sees it"
},
"to": {
"title": "Local path",
"description": "The same location as AutoBangumi sees it"
}
},
"source_root": {
"title": "Download root",
"description": "Local path. The media library keeps the same folder structure."
},
"library_root": {
"title": "Media library folder",
"description": "Links go here. Hardlinks need the same file system as the download root."
},
"cross_device": {
"title": "Across file systems",
"description": "copy copies the file, symlink creates a symbolic link, skip skips it and sends a notification"
}
}
},
"ingest_filters": {
"name": "Torrent filter",
"description": "On top of each rule's exclude filters, only download torrents that match include keywords. Has no effect when empty.",
"fields": {
"include": {
"title": "Include filter",
"description": "Only download torrents whose name matches any of these expressions (regex, case-insensitive), for example 1080p or 简体. Empty means no limit. Applies to all subscriptions."
}
}
},
"media_server_refresh": {
"name": "Media server refresh",
"description": "After torrents are organized, tell Jellyfin, Emby or Plex to refresh the library. Takes effect once the server URL and API key are set.",
"fields": {
"server": {
"title": "Server type"
},
"url": {
"title": "Server URL",
"description": "For example http://192.168.1.10:8096 (Plex uses port 32400 by default). Leave empty to skip refreshing."
},
"api_key": {
"title": "API key",
"description": "The Jellyfin or Emby API key, or the Plex X-Plex-Token"
},
"delay": {
"title": "Delay (seconds)",
"description": "How long to wait after an organize event before refreshing. Events in that window are merged into one refresh."
}
}
},
"rename": {
"name": "Rename",
"description": "Provides the rename methods pn and advance, and a custom template. When disabled, all bangumi use none and keep their original file names.",
"fields": {
"template": {
"title": "File name template",
"description": "The Jinja2 template used when the rename method is template. It renders the file name without the extension (subtitles get the language appended). Variables: title, bangumi_name, season, episode, episode_type (episode / movie / special), group, kind (media / subtitle), language. The filter pad(n) zero-pads a number to n digits. A file whose template fails or whose result contains a path separator keeps its original name, and you get a notification."
}
}
}
}
}

View File

@@ -259,7 +259,11 @@
"field_min": "不能小于 {n}",
"field_max": "不能大于 {n}",
"market_hint": "在插件市场中安装和更新插件。",
"open_market": "打开插件市场"
"open_market": "打开插件市场",
"reason": {
"not_enabled": "未启用",
"unsigned_blocked": "未签名插件需要开启「允许未签名插件」后才会加载。"
}
}
},
"downloader": {
@@ -906,5 +910,82 @@
"no_filter_match": "没有符合筛选条件的插件",
"show_all": "显示全部插件",
"empty": "目录中还没有插件"
},
"builtin_plugins": {
"hardlink": {
"name": "硬链接到媒体库",
"description": "整理完成后把正片与字幕硬链接到媒体库目录,下载目录保持原样继续做种",
"fields": {
"path_map": {
"title": "路径映射",
"description": "下载器与 AutoBangumi 不在同一个文件系统视图(如分别运行在不同容器)时,把下载器路径前缀换成本地路径;按最长前缀匹配,未匹配的路径原样使用",
"downloader": {
"title": "下载器",
"description": "下载器实例 id"
},
"from": {
"title": "下载器路径",
"description": "下载器看到的路径前缀"
},
"to": {
"title": "本地路径",
"description": "同一位置在 AutoBangumi 中的路径"
}
},
"source_root": {
"title": "下载根目录",
"description": "本地路径,媒体库保持与它相同的目录结构"
},
"library_root": {
"title": "媒体库目录",
"description": "链接放在这里;硬链接要求与下载根目录在同一个文件系统"
},
"cross_device": {
"title": "跨文件系统时",
"description": "copy 复制文件,symlink 创建软链接,skip 跳过并通知"
}
}
},
"ingest_filters": {
"name": "种子过滤",
"description": "在规则的排除过滤之外,按包含关键词进一步筛选要下载的种子(留空不生效)",
"fields": {
"include": {
"title": "包含过滤",
"description": "只下载名称匹配任一表达式的种子(正则,忽略大小写),例如 1080p、简体。留空表示不限制。对所有订阅生效。"
}
}
},
"media_server_refresh": {
"name": "媒体库刷新",
"description": "种子整理完成后通知 Jellyfin / Emby / Plex 刷新媒体库;填写服务器地址与 API Key 后生效",
"fields": {
"server": {
"title": "服务器类型"
},
"url": {
"title": "服务器地址",
"description": "如 http://192.168.1.10:8096(Plex 默认端口 32400);留空则不刷新"
},
"api_key": {
"title": "API Key",
"description": "Jellyfin / Emby 的 API Key,或 Plex 的 X-Plex-Token"
},
"delay": {
"title": "延迟(秒)",
"description": "收到整理完成事件后等待多久再刷新,期间的事件合并为一次刷新"
}
}
},
"rename": {
"name": "重命名",
"description": "提供重命名方式 pn、advance 与自定义模板 template。停用后所有番剧按 none 处理(保留原文件名)",
"fields": {
"template": {
"title": "文件名模板",
"description": "重命名方式选择 template 时使用的 Jinja2 模板,渲染文件名主体(不含扩展名,字幕会自动追加语言)。可用变量:title、bangumi_name、season、episode、episode_type(episode / movie / special)、group、kind(media / subtitle)、language;过滤器 pad(n) 把数字补零到 n 位。渲染失败或结果含路径分隔符的文件保留原名,并发送通知。"
}
}
}
}
}

View File

@@ -11,6 +11,11 @@ export function idLabel(
id: string
): string {
const key = `${group}.${id.replaceAll('.', '_')}`;
return hasKey(key) ? t(key) : id;
}
/** en.json 中是否有这条文案(en 与 zh-CN 的 key 集合由测试保证一致) */
export function hasKey(key: string): boolean {
const found = key
.split('.')
.reduce<unknown>(
@@ -18,5 +23,5 @@ export function idLabel(
en
);
// 构建时 i18n 插件把文案预编译成消息函数,测试里则是原始字符串
return typeof found === 'string' || typeof found === 'function' ? t(key) : id;
return typeof found === 'string' || typeof found === 'function';
}

View File

@@ -0,0 +1,52 @@
import { hasKey } from './id-label';
import type { SchemaField } from './plugin-schema';
type Translate = (key: string) => string;
/**
* 内置插件的名称、描述与配置字段文案:清单和 config_model 里只有中文,前端在
* `builtin_plugins.<id>` 下为它们各配一份翻译。没有收录的插件(第三方)原样显示
* 后端给的文字。
*/
function root(pluginId: string): string {
return `builtin_plugins.${pluginId.replaceAll('-', '_')}`;
}
function pick(t: Translate, key: string, fallback: string): string {
return hasKey(key) ? t(key) : fallback;
}
export function pluginName(t: Translate, id: string, fallback: string): string {
return pick(t, `${root(id)}.name`, fallback);
}
export function pluginDescription(
t: Translate,
id: string,
fallback: string
): string {
return pick(t, `${root(id)}.description`, fallback);
}
/** 字段文案:`fields.<key>.title / description`,对象数组的子字段嵌套在父字段下 */
export function localizeFields(
t: Translate,
id: string,
fields: SchemaField[],
parent = `${root(id)}.fields`
): SchemaField[] {
return fields.map((field) => {
const base = `${parent}.${field.key}`;
return {
...field,
label: pick(t, `${base}.title`, field.label),
description: pick(t, `${base}.description`, field.description),
itemFields: localizeFields(t, id, field.itemFields, base),
};
});
}
/** 后端 blocked 原因代码(如 not_enabled)的文案;其它错误原样显示 */
export function pluginReason(t: Translate, error: string): string {
return pick(t, `config.plugins_set.reason.${error}`, error);
}