diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index f07482a5..9704f730 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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({ diff --git a/packages/contracts/src/content.ts b/packages/contracts/src/content.ts index 4c2e94e5..55009e3e 100644 --- a/packages/contracts/src/content.ts +++ b/packages/contracts/src/content.ts @@ -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; export const MediaEntityCreate = MediaEntity.pick({ diff --git a/packages/contracts/src/instances.ts b/packages/contracts/src/instances.ts index 192eb75c..881ea1a8 100644 --- a/packages/contracts/src/instances.ts +++ b/packages/contracts/src/instances.ts @@ -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; export const LibraryRootCreate = LibraryRoot.pick({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5e1cb7e0..6c78fc8e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -176,6 +176,113 @@ export const SitesSettings = z.object({ }); export type SitesSettings = z.infer; +// ── 整理命名模板(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; +/** 库根 / 作品级覆盖:按类型覆盖,未给的类型回落上一级 */ +export const NamingOverride = z.object({ + movie: NamingTemplate.optional(), + tv: NamingTemplate.optional(), + variety: NamingTemplate.optional(), +}); +export type NamingOverride = z.infer; + +export const NamingSettings = z.object({ + movie: NamingTemplate, + tv: NamingTemplate, + variety: NamingTemplate, + /** 已绑定作品也固定文件名模板版本(按 path_binding.template_version 取当时的模板);false = 每次入库按当前模板 */ + lockFileNameForBound: z.boolean(), +}); +export type NamingSettings = z.infer; + +/** 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; +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; + +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; +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; + +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; +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; @@ -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; @@ -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"], }, { diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 80357ad3..4aacaa29 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -127,6 +127,8 @@ const INTERNAL_TABLES = [ "AdminSession", "LoginAttempt", "SseEvent", + // W9-C 命名模板版本登记(0032):服务端内部,经 /naming/* 暴露 + "NamingTemplateVersion", // W9-I 进程心跳(0034):服务端内部,经 /system/health 的专用 DTO 暴露 "ProcessHeartbeat", "ProcessHeartbeatSample", diff --git a/packages/db/migrations/0032_naming.ts b/packages/db/migrations/0032_naming.ts new file mode 100644 index 00000000..b5a2d64e --- /dev/null +++ b/packages/db/migrations/0032_naming.ts @@ -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): Promise { + 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): Promise { + await execDdl( + db, + ` +${dropTables(["naming_template_version"])} +alter table media_entity drop column naming_override; +alter table library_root drop column naming; +`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 3b48547f..d8ba1df4 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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; + version: number; + fingerprint: string; + templates: Json; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + export interface ObservationTable { id: Generated; 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; export type MetadataFieldRow = Selectable; export type NewMetadataField = Insertable; export type MetadataFieldUpdate = Updateable; +export type NamingTemplateVersionRow = Selectable; +export type NewNamingTemplateVersion = Insertable; +export type NamingTemplateVersionUpdate = Updateable; export type ObservationRow = Selectable; export type NewObservation = Insertable; export type ObservationUpdate = Updateable; diff --git a/packages/db/test/schema.test.ts b/packages/db/test/schema.test.ts index 92fc935b..87656700 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -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",