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

+55
View File
@@ -16,6 +16,7 @@ import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import * as schedules from "./schedules.ts";
import * as search from "./search.ts";
import * as settingsNs from "./settings.ts";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts";
import * as sources from "./sources.ts";
@@ -1472,6 +1473,60 @@ export const Api = {
}),
},
// ── 整理命名模板(W9-C,docs/design/naming-template.md):保存前服务端编译校验 ──
naming: {
variables: ep({
method: "get",
path: "/naming/variables",
scope: "read",
summary: "命名变量表(名称对齐 MP,含说明/示例值/我们特有标记)、过滤器、预览内置样本",
response: settingsNs.NamingVariablesResponse,
}),
preview: ep({
method: "post",
path: "/naming/preview",
scope: "read",
summary:
"预览:模板 + 样本(文件名 / 资源 / 资产 / 内置)→ 相对路径、段角色、错误(行列号)、提示;已有路径绑定时目录用绑定",
body: settingsNs.NamingPreviewInput,
response: settingsNs.NamingPreviewResult,
}),
templates: ep({
method: "get",
path: "/naming/templates",
scope: "read",
summary: "全局 / 库根 / 作品三级模板与按类型的生效结果(作品 > 库根 > 全局)",
query: settingsNs.NamingTemplatesQuery,
response: settingsNs.NamingTemplatesView,
}),
saveTemplates: ep({
method: "put",
path: "/naming/templates",
scope: "admin",
summary: "保存全局命名模板(逐条编译校验,有错 422 不保存)",
body: settingsNs.NamingSettingsPut,
response: settingsNs.NamingSettings,
}),
libraryRoot: ep({
method: "put",
path: "/library-roots/{id}/naming",
scope: "admin",
summary: "库根级命名覆盖(按类型;null 清除)",
params: IdParam,
body: settingsNs.LibraryRootNamingPut,
response: settingsNs.LibraryRootNamingPut,
}),
entity: ep({
method: "put",
path: "/media-entities/{id}/naming-override",
scope: "admin",
summary: "作品级命名覆盖(按类型;null 清除)。已有路径绑定的目录不受影响,只影响之后的文件名与新季目录",
params: IdParam,
body: settingsNs.EntityNamingOverridePut,
response: settingsNs.EntityNamingOverridePut,
}),
},
// ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ──
pi: {
providers: ep({
+2
View File
@@ -23,6 +23,8 @@ export const MediaEntity = EntityBase.extend({
/** 人工锁定的字段名;刷新不得覆盖 */
fieldLocks: z.array(z.string()),
lastMetadataRefreshAt: Timestamp.nullable(),
/** 作品级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /media-entities/{id}/naming-override 修改 */
namingOverride: JsonObject.nullable().optional(),
});
export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
+2
View File
@@ -27,6 +27,8 @@ export const LibraryRoot = EntityBase.extend({
lastHealthAt: Timestamp.nullable(),
healthDetail: JsonObject,
notes: z.string().nullable(),
/** 库根级命名模板覆盖 {movie?, tv?, variety?}(见 settings NamingOverride);null = 不覆盖。经 PUT /library-roots/{id}/naming 修改 */
naming: JsonObject.nullable().optional(),
});
export type LibraryRoot = z.infer<typeof LibraryRoot>;
export const LibraryRootCreate = LibraryRoot.pick({
+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"],
},
{