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"],
},
{
+2
View File
@@ -127,6 +127,8 @@ const INTERNAL_TABLES = [
"AdminSession",
"LoginAttempt",
"SseEvent",
// W9-C 命名模板版本登记(0032):服务端内部,经 /naming/* 暴露
"NamingTemplateVersion",
// W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露
"ProcessHeartbeat",
"ProcessHeartbeatSample",
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "node:crypto";
import type { Kysely } from "kysely";
import { dropTables, entityTable, execDdl } from "../src/migration-helpers.ts";
// W9-C 整理命名模板(docs/design/naming-template.md §6、§9):
// - library_root.naming / media_entity.naming_override:按类型(movie/tv/variety)覆盖模板,null = 不覆盖。
// 生效顺序 作品 > 库根 > 全局(settings.naming)。
// - naming_template_version:生效模板集的版本登记(指纹去重),path_binding.template_version 引用 version;
// "已绑定作品固定文件名模板版本"按它取回当时的模板。1 = v1 旧模板(迁移后语法),2 = MP 默认。
// 模板串是写入时的快照,之后不随代码变化。
const LEGACY_V1 = {
movie:
"{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if cut %} - {{cut}}{% 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 %}{{fileExt}}",
variety:
"{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{% if season_episode %}{{title}} - {{season_episode}}{% if part %} - {{part}}{% endif %}{% else %}{{title}} - {{airDate | date('YYYY-MM-DD')}}{% if issue %} - 第{{issue}}期{% endif %}{% endif %}{{fileExt}}",
};
const MP_DEFAULT = {
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}}",
};
/** 与 @umt/domain namingFingerprint 同算法:sha256(JSON {movie,tv,variety}) */
const fingerprint = (t: { movie: string; tv: string; variety: string }) =>
createHash("sha256")
.update(JSON.stringify({ movie: t.movie, tv: t.tv, variety: t.variety }))
.digest("hex");
const lit = (s: string) => `'${s.replace(/'/g, "''")}'`;
const seed = (version: number, t: typeof MP_DEFAULT) =>
`insert into naming_template_version (version, fingerprint, templates) values (${version}, ${lit(fingerprint(t))}, ${lit(JSON.stringify(t))}::jsonb);`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
alter table library_root add column naming jsonb check (naming is null or jsonb_typeof(naming) = 'object');
alter table media_entity add column naming_override jsonb check (naming_override is null or jsonb_typeof(naming_override) = 'object');
${entityTable(
"naming_template_version",
`
version integer not null unique check (version >= 1),
fingerprint text not null unique,
templates jsonb not null check (jsonb_typeof(templates) = 'object')
`,
)}
create sequence naming_template_version_seq start with 3 owned by naming_template_version.version;
${seed(1, LEGACY_V1)}
${seed(2, MP_DEFAULT)}
`,
);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${dropTables(["naming_template_version"])}
alter table media_entity drop column naming_override;
alter table library_root drop column naming;
`,
);
}
+15
View File
@@ -810,6 +810,7 @@ export interface LibraryRootTable {
notes: string | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
naming: Json | null;
}
export interface LoginAttemptTable {
@@ -854,6 +855,7 @@ export interface MediaEntityTable {
last_metadata_refresh_at: Timestamptz | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
naming_override: Json | null;
}
export interface MediaServerInstanceTable {
@@ -886,6 +888,15 @@ export interface MetadataFieldTable {
updated_at: GeneratedTimestamptz;
}
export interface NamingTemplateVersionTable {
id: Generated<string>;
version: number;
fingerprint: string;
templates: Json;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
export interface ObservationTable {
id: Generated<string>;
feed_id: string;
@@ -1575,6 +1586,7 @@ export interface DB {
media_entity: MediaEntityTable;
media_server_instance: MediaServerInstanceTable;
metadata_field: MetadataFieldTable;
naming_template_version: NamingTemplateVersionTable;
observation: ObservationTable;
organization_file_result: OrganizationFileResultTable;
organization_record: OrganizationRecordTable;
@@ -1722,6 +1734,9 @@ export type MediaServerInstanceUpdate = Updateable<MediaServerInstanceTable>;
export type MetadataFieldRow = Selectable<MetadataFieldTable>;
export type NewMetadataField = Insertable<MetadataFieldTable>;
export type MetadataFieldUpdate = Updateable<MetadataFieldTable>;
export type NamingTemplateVersionRow = Selectable<NamingTemplateVersionTable>;
export type NewNamingTemplateVersion = Insertable<NamingTemplateVersionTable>;
export type NamingTemplateVersionUpdate = Updateable<NamingTemplateVersionTable>;
export type ObservationRow = Selectable<ObservationTable>;
export type NewObservation = Insertable<ObservationTable>;
export type ObservationUpdate = Updateable<ObservationTable>;
+2
View File
@@ -99,6 +99,8 @@ const EXPECTED_TABLES = [
// W7-C 定时任务注册表(0028)
"scheduled_job",
"scheduled_job_run",
// W9-C 命名模板版本(0032)
"naming_template_version",
// W9-D 规则组(0033)
"rule_group",
"rule_atom",