feat(contracts,db): naming settings, override fields and template versions (0032)

- settings.naming {movie, tv, variety, lockFileNameForBound}, default = MP templates;
  library section gains #naming anchor
- naming API schemas and Api.naming endpoints (variables, preview, templates, overrides)
- 0032: library_root.naming, media_entity.naming_override, naming_template_version
  (version 1 = upgraded v1 templates, 2 = MP default, fingerprint-deduplicated)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:24:54 +08:00
1 parent 238445a258
commit dfb08ab552
8 files changed
+268 -2

No files matched your search

+121 -2
View File
@@ -176,6 +176,113 @@ export const SitesSettings = z.object({
});
export type SitesSettings = z.infer<typeof SitesSettings>;
// ── 整理命名模板(docs/design/naming-template.md;引擎在 @umt/domain naming/**)──
/** 一条全路径模板(Jinja 安全子集,`/` 切目录,最后一段是文件名且以 {{fileExt}} 结尾) */
export const NamingTemplate = z.string().min(1).max(1024);
export const NamingKind = z.enum(["movie", "tv", "variety"]);
export type NamingKind = z.infer<typeof NamingKind>;
/** 库根 / 作品级覆盖:按类型覆盖,未给的类型回落上一级 */
export const NamingOverride = z.object({
movie: NamingTemplate.optional(),
tv: NamingTemplate.optional(),
variety: NamingTemplate.optional(),
});
export type NamingOverride = z.infer<typeof NamingOverride>;
export const NamingSettings = z.object({
movie: NamingTemplate,
tv: NamingTemplate,
variety: NamingTemplate,
/** 已绑定作品也固定文件名模板版本(按 path_binding.template_version 取当时的模板);false = 每次入库按当前模板 */
lockFileNameForBound: z.boolean(),
});
export type NamingSettings = z.infer<typeof NamingSettings>;
/** MP 默认(与 @umt/domain MP_DEFAULT_NAMING 一致,server 测试断言) */
export const DEFAULT_NAMING_TEMPLATES = {
movie:
"{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}",
tv: "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}",
variety:
"{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate | date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}",
} as const;
export const NamingIssue = z.object({
code: z.string(),
message: z.string(),
line: z.number().int(),
col: z.number().int(),
});
export type NamingIssue = z.infer<typeof NamingIssue>;
export const NamingSegmentRole = z.enum(["category", "root", "season", "sub", "file"]);
export const NamingSource = z.enum(["entity", "root", "global"]);
export const NamingVariable = z.object({
name: z.string(),
group: z.enum(["title", "episode", "tech", "id", "ours"]),
label: z.string(),
example: z.string(),
/** 我们特有(MP 没有) */
ours: z.boolean(),
});
export const NamingVariablesResponse = z.object({
items: z.array(NamingVariable),
filters: z.array(z.object({ name: z.string(), doc: z.string() })),
/** 预览内置样本(sample.builtin 取 id) */
samples: z.array(z.object({ id: z.number().int(), label: z.string(), kind: NamingKind, fileName: z.string() })),
});
export type NamingVariablesResponse = z.infer<typeof NamingVariablesResponse>;
export const NamingSample = z.union([
z.object({ fileName: z.string().min(1).max(1024) }),
z.object({ sourceReleaseId: Id }),
z.object({ assetId: Id }),
z.object({ builtin: z.number().int().min(1) }),
]);
export const NamingPreviewInput = z.object({
/** 不传 = 按 作品 > 库根 > 全局 取当前生效模板 */
template: NamingTemplate.optional(),
kind: NamingKind,
/** 不传 = 该类型的第一条内置样本 */
sample: NamingSample.optional(),
libraryRootId: Id.optional(),
/** 作品:取其覆盖模板与路径绑定(已绑定时目录用绑定路径) */
entityId: Id.optional(),
});
export type NamingPreviewInput = z.infer<typeof NamingPreviewInput>;
export const NamingPreviewResult = z.object({
ok: z.boolean(),
relativePath: z.string(),
segments: z.array(z.object({ text: z.string(), role: NamingSegmentRole })),
errors: z.array(NamingIssue),
warnings: z.array(z.string()),
/** 输出为空的变量 */
missing: z.array(z.string()),
/** 目录来自已有路径绑定(模板只决定文件名) */
usesBinding: z.boolean(),
templateSource: z.union([NamingSource, z.literal("input")]),
template: z.string(),
sample: z.object({ fileName: z.string(), entityTitle: z.string() }),
/** 渲染所用变量值(调试/变量清单"当前示例值") */
vars: z.record(z.string(), z.union([z.string(), z.number()])),
});
export type NamingPreviewResult = z.infer<typeof NamingPreviewResult>;
export const NamingTemplatesQuery = z.object({ libraryRootId: Id.optional(), entityId: Id.optional() });
export const NamingTemplatesView = z.object({
global: NamingSettings,
defaults: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }),
root: NamingOverride.nullable(),
entity: NamingOverride.nullable(),
effective: z.object({ movie: z.string(), tv: z.string(), variety: z.string() }),
sources: z.object({ movie: NamingSource, tv: NamingSource, variety: NamingSource }),
});
export type NamingTemplatesView = z.infer<typeof NamingTemplatesView>;
export const NamingSettingsPut = NamingSettings.partial();
export const LibraryRootNamingPut = z.object({ naming: NamingOverride.nullable() });
export const EntityNamingOverridePut = z.object({ namingOverride: NamingOverride.nullable() });
/** 每日预算与并发(REQ §6.4、§10.5) */
export const BudgetSettings = z.object({
maxNewTitlesPerDay: z.number().int().min(0),
@@ -196,6 +303,7 @@ export const GlobalSettings = z.object({
agent: AgentRuntimeSettings,
metadata: MetadataSettings,
sites: SitesSettings,
naming: NamingSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
@@ -251,6 +359,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
proxyUrl: null,
userDataMaxSeedingPages: 10,
},
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
@@ -287,6 +396,7 @@ export const GlobalSettingsUpdate = z.object({
})
.optional(),
sites: SitesSettings.partial().optional(),
naming: NamingSettings.partial().optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
@@ -304,6 +414,7 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda
agent: { ...current.agent, ...patch.agent },
metadata: { ...current.metadata, ...metadata },
sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites },
naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming },
};
}
@@ -427,13 +538,21 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "library",
label: "媒体库与目录",
description: "库根、哨兵状态、默认整理方式",
description: "库根、哨兵状态、默认整理方式、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "库根" },
{ id: "organize", label: "整理方式" },
{ id: "naming", label: "命名模板" },
],
fields: [
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"naming.movie",
"naming.tv",
"naming.variety",
"naming.lockFileNameForBound",
],
fields: ["organize.defaultMode", "organize.allowCrossVolumeMove"],
resources: ["library-roots"],
},
{