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:
1 parent
238445a258
commit
dfb08ab552
8 files changed
+268
-2
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -127,6 +127,8 @@ const INTERNAL_TABLES = [
|
||||
"AdminSession",
|
||||
"LoginAttempt",
|
||||
"SseEvent",
|
||||
// W9-C 命名模板版本登记(0032):服务端内部,经 /naming/* 暴露
|
||||
"NamingTemplateVersion",
|
||||
// W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露
|
||||
"ProcessHeartbeat",
|
||||
"ProcessHeartbeatSample",
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user