diff --git a/apps/server/src/modules/acquisition-organize/__tests__/naming-api.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/naming-api.test.ts new file mode 100644 index 00000000..40e06d31 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/__tests__/naming-api.test.ts @@ -0,0 +1,175 @@ +import { createHash } from "node:crypto"; +import { DEFAULT_NAMING_TEMPLATES } from "@umt/contracts"; +import { LEGACY_V1_NAMING, MP_DEFAULT_NAMING, namingCanonicalJson } from "@umt/domain"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { ensureNamingVersion, namingByVersion } from "../services/naming.ts"; +import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; + +// W9-C 命名模板:变量表、预览(样本/错误/路径绑定)、三级模板读写与保存前校验、模板版本登记。 + +const hasPg = await pgAvailable(); + +it("contracts 默认模板 = domain MP 默认", () => { + expect(DEFAULT_NAMING_TEMPLATES).toEqual(MP_DEFAULT_NAMING); +}); + +describe.skipIf(!hasPg)("命名模板 API(真实 PG)", () => { + let h: Harness; + let rootId: string; + + beforeAll(async () => { + h = await createHarness("naming"); + ({ rootId } = await seedBase(h.db, { + libraryPath: "/media/lib", + cachePath: "/cache", + qbUrl: "http://127.0.0.1:1", + })); + }, 60_000); + afterAll(async () => { + await h?.close(); + }); + + it("迁移种子:版本 1 = v1 旧模板,2 = MP 默认,指纹与 domain 同算法;新模板集按内容登记版本", async () => { + const rows = await h.db.selectFrom("naming_template_version").selectAll().orderBy("version").execute(); + const fp = (t: typeof MP_DEFAULT_NAMING) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex"); + expect(rows.map((r) => [r.version, r.fingerprint])).toEqual([ + [1, fp(LEGACY_V1_NAMING)], + [2, fp(MP_DEFAULT_NAMING)], + ]); + expect((await ensureNamingVersion(h.db, { ...MP_DEFAULT_NAMING })).version).toBe(2); + const custom = { ...MP_DEFAULT_NAMING, movie: "{{title}}/{{title}}{{fileExt}}" }; + const v = (await ensureNamingVersion(h.db, custom)).version; + expect(v).toBeGreaterThanOrEqual(3); + expect((await ensureNamingVersion(h.db, custom)).version).toBe(v); + expect(await namingByVersion(h.db, v)).toEqual({ ...custom, version: v }); + expect(await namingByVersion(h.db, 1)).toEqual({ ...LEGACY_V1_NAMING, version: 1 }); + expect(await namingByVersion(h.db, 9999)).toBeUndefined(); + }); + + it("GET /naming/variables", async () => { + const r = await h.api("GET", "/naming/variables"); + expect(r.status).toBe(200); + const names = r.body.data.items.map((i: { name: string }) => i.name); + expect(names).toEqual(expect.arrayContaining(["title", "videoFormat", "season_episode", "fileExt", "slotName"])); + expect(r.body.data.items.find((i: { name: string }) => i.name === "airDate")).toMatchObject({ ours: true }); + expect(r.body.data.filters.map((f: { name: string }) => f.name)).toContain("pad"); + expect(r.body.data.samples).toHaveLength(6); + }); + + it("POST /naming/preview:内置样本 / 手填文件名 / 编译错误行列号 / 提示", async () => { + const a = await h.api("POST", "/naming/preview", { kind: "tv", sample: { builtin: 3 } }); + expect(a.status).toBe(200); + expect(a.body.data).toMatchObject({ + ok: true, + relativePath: "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv", + usesBinding: false, + templateSource: "global", + segments: [ + { text: "绝命毒师 (2008)", role: "root" }, + { text: "Season 1", role: "season" }, + { text: "绝命毒师 - S01E01 - 第 1 集.mkv", role: "file" }, + ], + }); + const b = await h.api("POST", "/naming/preview", { + kind: "movie", + template: "{{title}} ({{year}})/{{title}} - {{videoFormat}}{{fileExt}}", + sample: { fileName: "Inception.2010.1080p.BluRay.x264-CMCT.mkv" }, + }); + expect(b.body.data).toMatchObject({ + ok: true, + relativePath: "Inception (2010)/Inception - 1080p.mkv", + templateSource: "input", + }); + expect(b.body.data.warnings).toEqual(expect.arrayContaining([expect.stringContaining("year 可能为空")])); + const c = await h.api("POST", "/naming/preview", { + kind: "movie", + template: "{{title}}/\n{{videoformat}}{{fileExt}}", + }); + expect(c.body.data).toMatchObject({ ok: false, relativePath: "" }); + expect(c.body.data.errors[0]).toMatchObject({ code: "unknown_variable", line: 2, col: 3 }); + expect(c.body.data.errors[0].message).toContain("videoFormat"); + }); + + it("三级覆盖:全局保存校验、库根与作品覆盖、GET /naming/templates 生效来源", async () => { + const bad = await h.api("PUT", "/naming/templates", { movie: "{{title}}/{{title}}" }); + expect(bad.status).toBe(422); + const ok = await h.api("PUT", "/naming/templates", { + movie: "{{title_year}}/{{title}}{{fileExt}}", + lockFileNameForBound: true, + }); + expect(ok.status).toBe(200); + expect(ok.body.data).toMatchObject({ movie: "{{title_year}}/{{title}}{{fileExt}}", lockFileNameForBound: true }); + expect((await h.ctx.settings.get("naming")).lockFileNameForBound).toBe(true); + + const e = await seedEntity(h.db, { kind: "series", title: "绝命毒师", year: 2008 }); + expect((await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: { tv: "{{title}}" } })).status).toBe(422); + expect( + ( + await h.api("PUT", `/library-roots/${rootId}/naming`, { + naming: { tv: "{{title}}/{{title}} {{season_episode}}{{fileExt}}" }, + }) + ).status, + ).toBe(200); + const put = await h.api("PUT", `/media-entities/${e}/naming-override`, { + namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" }, + }); + expect(put.body.data).toEqual({ namingOverride: { variety: "{{title}}/{{title}} {{airDate}}{{fileExt}}" } }); + const view = await h.api("GET", `/naming/templates?libraryRootId=${rootId}&entityId=${e}`); + expect(view.body.data.sources).toEqual({ movie: "global", tv: "root", variety: "entity" }); + expect(view.body.data.effective.tv).toBe("{{title}}/{{title}} {{season_episode}}{{fileExt}}"); + + const pv = await h.api("POST", "/naming/preview", { + kind: "tv", + entityId: e, + libraryRootId: rootId, + sample: { fileName: "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv" }, + }); + expect(pv.body.data).toMatchObject({ + ok: true, + relativePath: "绝命毒师/绝命毒师 S01E02.mkv", + templateSource: "root", + }); + + await h.api("PUT", `/library-roots/${rootId}/naming`, { naming: null }); + await h.api("PUT", "/naming/templates", { ...MP_DEFAULT_NAMING, lockFileNameForBound: false }); + }); + + it("预览:已有路径绑定时目录用绑定路径,文件名按模板", async () => { + const e = await seedEntity(h.db, { kind: "series", title: "新译名", year: 2008 }); + await h.db + .insertInto("path_binding") + .values([ + { + media_entity_id: e, + binding_kind: "entity", + library_root_id: rootId, + relative_path: "旧名 (2008)", + template_version: "1", + }, + { + media_entity_id: e, + binding_kind: "season", + season_number: 1, + library_root_id: rootId, + relative_path: "旧名 (2008)/S1", + template_version: "1", + }, + ]) + .execute(); + const r = await h.api("POST", "/naming/preview", { + kind: "tv", + entityId: e, + sample: { fileName: "Show.S01E03.1080p.WEB-DL.H264-NTb.mkv" }, + }); + expect(r.body.data).toMatchObject({ + ok: true, + usesBinding: true, + relativePath: "旧名 (2008)/S1/新译名 - S01E03 - 第 3 集.mkv", + segments: [ + { text: "旧名 (2008)", role: "root" }, + { text: "S1", role: "season" }, + { text: "新译名 - S01E03 - 第 3 集.mkv", role: "file" }, + ], + }); + }); +}); diff --git a/apps/server/src/modules/acquisition-organize/index.ts b/apps/server/src/modules/acquisition-organize/index.ts index a500d279..65f45a4b 100644 --- a/apps/server/src/modules/acquisition-organize/index.ts +++ b/apps/server/src/modules/acquisition-organize/index.ts @@ -9,6 +9,7 @@ import { registerAssetRoutes } from "./routes/assets.ts"; import { registerDashboardRoutes } from "./routes/dashboard.ts"; import { registerDownloadRoutes } from "./routes/downloads.ts"; import { registerLibraryRoutes } from "./routes/library.ts"; +import { registerNamingRoutes } from "./routes/naming.ts"; import { registerOrganizationRoutes } from "./routes/organization.ts"; import { registerRuleRoutes } from "./routes/rules.ts"; import { type AoDeps, automationBlock, enqueueSweep, enqueueTracked, timeSlot } from "./services/common.ts"; @@ -71,6 +72,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se registerLibraryRoutes(app, d); registerDashboardRoutes(app, d); registerAssetRoutes(app, d); + registerNamingRoutes(app, d); }, registerTasks(registry, ctx) { registerAoTasks(registry, lazy(ctx)); diff --git a/apps/server/src/modules/acquisition-organize/routes/naming.ts b/apps/server/src/modules/acquisition-organize/routes/naming.ts new file mode 100644 index 00000000..41cf8777 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/routes/naming.ts @@ -0,0 +1,327 @@ +import { + Api, + DEFAULT_SETTINGS, + type NamingPreviewInput, + type NamingPreviewResult, + NamingSettings, + type NamingTemplatesView, + type PathBinding, +} from "@umt/contracts"; +import type { LibraryRootRow, MediaEntityRow } from "@umt/db"; +import { + buildNamingVars, + MP_DEFAULT_NAMING, + NAMING_FILTERS, + NAMING_SAMPLES, + NAMING_VARIABLES, + type NamingEntityInput, + type NamingKind, + type NamingSegment, + parseRelease, + renderNamingPath, + resolveTargetDirectory, + validateNamingTemplate, +} from "@umt/domain"; +import { toDomainPathBinding } from "@umt/mapping"; +import { ApiException } from "../../../http/errors.ts"; +import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "../../auth-settings/provided.ts"; +import type { ModuleApp } from "../../types.ts"; +import type { AoDeps } from "../services/common.ts"; +import { effectiveNaming, loadNamingEntity, namingErrors, toOverride } from "../services/naming.ts"; +import { actorOf, jsonb, notFound, toApi } from "../util.ts"; +import { mount } from "./helpers.ts"; + +// 整理命名模板(docs/design/naming-template.md §8):变量表、预览、三级模板读写(保存前逐条编译校验)。 + +const TAG = "naming"; + +const isCached = (s: unknown): s is CachedSettingsReader => + typeof (s as CachedSettingsReader)?.invalidate === "function"; + +export function registerNamingRoutes(app: ModuleApp, d: AoDeps) { + const db = () => d.ctx.db; + const namingSettings = async () => + (await d.ctx.settings.get("naming").catch(() => undefined)) ?? DEFAULT_SETTINGS.naming; + const entityRow = async (id: string) => { + const e = await db().selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirst(); + if (!e) throw notFound("作品"); + return e; + }; + const rootRow = async (id: string) => { + const r = await db().selectFrom("library_root").selectAll().where("id", "=", id).executeTakeFirst(); + if (!r) throw notFound("库根"); + return r; + }; + const audit = ( + c: Parameters[0], + action: string, + targetType: string, + targetId: string | null, + payload: unknown, + ) => + db() + .insertInto("audit_event") + .values({ + actor_type: actorOf(c).startsWith("api_key:") ? "api_key" : "user", + actor_id: actorOf(c), + action, + target_type: targetType, + target_id: targetId, + payload: jsonb(payload), + }) + .execute(); + + mount( + app, + Api.naming.variables, + async () => ({ + items: NAMING_VARIABLES.map(({ name, group, label, example, ours }) => ({ name, group, label, example, ours })), + filters: Object.entries(NAMING_FILTERS).map(([name, f]) => ({ name, doc: f.doc })), + samples: NAMING_SAMPLES.map(({ id, label, kind, fileName }) => ({ id, label, kind, fileName })), + }), + { tag: TAG }, + ); + + mount( + app, + Api.naming.templates, + async (_c, { query }) => { + const q = query as { libraryRootId?: string; entityId?: string }; + const global = await namingSettings(); + const root = q.libraryRootId ? await rootRow(q.libraryRootId) : null; + const entity = q.entityId ? await entityRow(q.entityId) : null; + const eff = effectiveNaming(global, root, entity); + return { + global, + defaults: { ...MP_DEFAULT_NAMING }, + root: toOverride(root?.naming), + entity: toOverride(entity?.naming_override), + effective: eff.templates, + sources: eff.sources, + } satisfies NamingTemplatesView; + }, + { tag: TAG }, + ); + + mount( + app, + Api.naming.saveTemplates, + async (c, { body }) => { + const errors = namingErrors(body as Partial); + if (errors.length) throw new ApiException("validation_failed", errors.join("; ")); + const next = NamingSettings.parse({ ...(await namingSettings()), ...(body as object) }); + await db() + .insertInto("settings") + .values({ key: "naming", value: JSON.stringify(next), updated_at: new Date() }) + .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next), updated_at: new Date() })) + .execute(); + if (isCached(d.ctx.settings)) d.ctx.settings.invalidate(); + void d.ctx.events?.publish(SETTINGS_CHANGED_EVENT, { keys: ["naming"] }); + await audit(c, "naming.update", "settings", "naming", { patch: body }); + return next; + }, + { tag: TAG }, + ); + + mount( + app, + Api.naming.libraryRoot, + async (c, { params, body }) => { + await rootRow(params.id); + const naming = toOverride((body as { naming: unknown }).naming); + const errors = naming ? namingErrors(naming) : []; + if (errors.length) throw new ApiException("validation_failed", errors.join("; ")); + await db() + .updateTable("library_root") + .set({ naming: naming ? JSON.stringify(naming) : null }) + .where("id", "=", params.id) + .execute(); + await audit(c, "library_root.naming", "library_root", params.id, { naming }); + return { naming }; + }, + { tag: TAG }, + ); + + mount( + app, + Api.naming.entity, + async (c, { params, body }) => { + await entityRow(params.id); + const namingOverride = toOverride((body as { namingOverride: unknown }).namingOverride); + const errors = namingOverride ? namingErrors(namingOverride) : []; + if (errors.length) throw new ApiException("validation_failed", errors.join("; ")); + await db() + .updateTable("media_entity") + .set({ naming_override: namingOverride ? JSON.stringify(namingOverride) : null }) + .where("id", "=", params.id) + .execute(); + await audit(c, "media_entity.naming_override", "media_entity", params.id, { namingOverride }); + return { namingOverride }; + }, + { tag: TAG }, + ); + + mount(app, Api.naming.preview, async (_c, { body }) => preview(d, body as NamingPreviewInput, namingSettings), { + tag: TAG, + status: 200, + }); +} + +interface SampleSource { + fileName: string; + entity: MediaEntityRow | null; + fallback: NamingEntityInput; +} + +async function loadSample(d: AoDeps, input: NamingPreviewInput): Promise { + const db = d.ctx.db; + const s = input.sample; + const pseudo = (fileName: string): NamingEntityInput => { + const p = parseRelease(fileName); + return { title: p.title ?? "示例作品", ...(p.year ? { year: p.year } : {}) }; + }; + if (!s) { + const b = NAMING_SAMPLES.find((x) => x.kind === input.kind) ?? NAMING_SAMPLES[0]!; + return { fileName: b.fileName, entity: null, fallback: b.entity }; + } + if ("builtin" in s) { + const b = NAMING_SAMPLES.find((x) => x.id === s.builtin); + if (!b) throw new ApiException("validation_failed", `没有内置样本 ${s.builtin}`); + return { fileName: b.fileName, entity: null, fallback: b.entity }; + } + if ("fileName" in s) return { fileName: s.fileName, entity: null, fallback: pseudo(s.fileName) }; + if ("sourceReleaseId" in s) { + const rel = await db + .selectFrom("source_release") + .select(["title", "media_entity_id"]) + .where("id", "=", s.sourceReleaseId) + .executeTakeFirst(); + if (!rel) throw notFound("资源"); + const e = rel.media_entity_id + ? await db.selectFrom("media_entity").selectAll().where("id", "=", rel.media_entity_id).executeTakeFirst() + : undefined; + return { fileName: rel.title, entity: e ?? null, fallback: pseudo(rel.title) }; + } + const asset = await db + .selectFrom("media_asset as a") + .leftJoin("asset_location as l", "l.media_asset_id", "a.id") + .select(["a.media_entity_id", "l.relative_path"]) + .where("a.id", "=", s.assetId) + .orderBy("l.created_at", "desc") + .executeTakeFirst(); + if (!asset) throw notFound("资产"); + const fileName = (asset.relative_path ?? "").split("/").pop() || "asset.mkv"; + const e = asset.media_entity_id + ? await db.selectFrom("media_entity").selectAll().where("id", "=", asset.media_entity_id).executeTakeFirst() + : undefined; + return { fileName, entity: e ?? null, fallback: pseudo(fileName) }; +} + +async function preview( + d: AoDeps, + input: NamingPreviewInput, + namingSettings: () => Promise, +): Promise { + const db = d.ctx.db; + const kind: NamingKind = input.kind; + const sample = await loadSample(d, input); + let entity: MediaEntityRow | null = sample.entity; + if (input.entityId) { + entity = + (await db.selectFrom("media_entity").selectAll().where("id", "=", input.entityId).executeTakeFirst()) ?? null; + if (!entity) throw notFound("作品"); + } + const bindingRows = entity + ? await db + .selectFrom("path_binding") + .selectAll() + .where("media_entity_id", "=", entity.id) + .where("status", "=", "active") + .execute() + : []; + const bindings = bindingRows.map((r) => toDomainPathBinding(toApi(r))); + const rootId = input.libraryRootId ?? bindings[0]?.libraryRootId; + let root: LibraryRootRow | null = null; + if (rootId) { + root = (await db.selectFrom("library_root").selectAll().where("id", "=", rootId).executeTakeFirst()) ?? null; + if (!root && input.libraryRootId) throw notFound("库根"); + } + const eff = effectiveNaming(await namingSettings(), root, entity); + const template = input.template ?? eff.templates[kind]; + const templateSource = input.template !== undefined ? ("input" as const) : eff.sources[kind]; + + const namingEntity: NamingEntityInput = entity + ? { ...(await loadNamingEntity(db, entity, { libraryRoot: root?.name })), kind } + : { ...sample.fallback, kind, ...(root ? { libraryRoot: root.name } : {}) }; + const parsed = parseRelease(sample.fileName); + const ext = parsed.extension ? `.${parsed.extension}` : ".mkv"; + const vars = buildNamingVars(namingEntity, parsed, { fileExt: ext }); + const base = { + templateSource, + template, + sample: { fileName: sample.fileName, entityTitle: namingEntity.title }, + vars, + }; + + const v = validateNamingTemplate(template, kind); + if (!v.ok) + return { + ok: false, + relativePath: "", + segments: v.roles.map((r) => ({ text: r.segment, role: r.role })), + errors: v.errors, + warnings: v.warnings, + missing: [], + usesBinding: false, + ...base, + }; + const r = renderNamingPath(template, vars); + const out = (segments: { text: string; role: NamingSegment["role"] }[], usesBinding: boolean) => ({ + ok: r.ok, + relativePath: r.ok ? segments.map((s) => s.text).join("/") : "", + segments, + errors: r.errors, + warnings: v.warnings, + missing: r.missing, + usesBinding, + ...base, + }); + const plain = r.segments.filter((s) => s.text).map(({ text, role }) => ({ text, role })); + const hasBinding = + entity !== null && + bindings.some((b) => b.scope === "entity" || (kind !== "movie" && b.season === Number(vars.season))); + if (!r.ok || !entity || !hasBinding) return out(plain, false); + + // 已有路径绑定:目录用绑定路径(模板只决定文件名与绑定之后的子目录段) + const season = kind === "movie" ? undefined : Number(vars.season); + const dec = resolveTargetDirectory({ + entity: { id: entity.id, kind: kind === "movie" ? "movie" : "tv" }, + naming: vars, + namingKind: kind, + ...(season !== undefined ? { season } : {}), + bindings, + libraryRoot: { id: root?.id ?? "preview", hostPath: root?.local_path ?? "" }, + templates: { ...eff.templates, [kind]: template, version: 0 }, + }); + if (dec.kind !== "existing" && dec.kind !== "new") return out(plain, false); + const entityPath = bindings.find((b) => b.scope === "entity")?.relativePath ?? ""; + const seasonPath = + dec.kind === "new" + ? (dec.newBindings.find((b) => b.scope === "season")?.relativePath ?? "") + : (bindings.find((b) => b.scope === "season" && b.season === season)?.relativePath ?? ""); + const parts = dec.relativePath.split("/").filter(Boolean); + const nEntity = entityPath ? entityPath.split("/").length : 0; + const nSeason = seasonPath ? seasonPath.split("/").length : nEntity; + const segs = parts.map((text, i) => ({ + text, + role: (i < nEntity - 1 + ? "category" + : i === nEntity - 1 + ? "root" + : i < nSeason + ? "season" + : "sub") as NamingSegment["role"], + })); + const file = plain.find((s) => s.role === "file"); + return out(file ? [...segs, file] : segs, true); +} diff --git a/apps/server/src/modules/acquisition-organize/services/naming.ts b/apps/server/src/modules/acquisition-organize/services/naming.ts new file mode 100644 index 00000000..42f3fd84 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/naming.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import type { NamingOverride as NamingOverrideDto, NamingSettings } from "@umt/contracts"; +import type { LibraryRootRow, MediaEntityRow } from "@umt/db"; +import { + LEGACY_NAMING_VERSION, + LEGACY_V1_NAMING, + MP_DEFAULT_NAMING, + type NamingEntityInput, + type NamingKind, + type NamingSource, + type NamingTemplateSet, + type NamingTemplates, + namingCanonicalJson, + namingKindOf, + resolveNamingTemplates, + validateNamingTemplate, +} from "@umt/domain"; +import type { Exec } from "./common.ts"; + +// 命名模板的存取:三级回落(作品 > 库根 > 全局)、生效模板集的版本登记、命名变量来源(英文名/外部 ID/季集元数据)。 + +const KINDS: readonly NamingKind[] = ["movie", "tv", "variety"]; + +/** jsonb 覆盖值 → 只保留非空字符串的类型键 */ +export function toOverride(v: unknown): NamingOverrideDto | null { + if (!v || typeof v !== "object" || Array.isArray(v)) return null; + const out: NamingOverrideDto = {}; + for (const k of KINDS) { + const s = (v as Record)[k]; + if (typeof s === "string" && s.trim()) out[k] = s; + } + return Object.keys(out).length ? out : null; +} + +export function globalTemplates(s: NamingSettings | undefined): NamingTemplates { + return s ? { movie: s.movie, tv: s.tv, variety: s.variety } : { ...MP_DEFAULT_NAMING }; +} + +export interface EffectiveNaming { + templates: NamingTemplates; + sources: Record; +} + +export function effectiveNaming( + global: NamingSettings | undefined, + root: Pick | null | undefined, + entity: Pick | null | undefined, +): EffectiveNaming { + return resolveNamingTemplates({ + global: globalTemplates(global), + root: toOverride(root?.naming), + entity: toOverride(entity?.naming_override), + }); +} + +/** 保存前校验一组模板;返回 `类型: 行:列 原因` 列表(空 = 通过) */ +export function namingErrors(t: Partial>): string[] { + const out: string[] = []; + for (const k of KINDS) { + const src = t[k]; + if (src === undefined) continue; + for (const e of validateNamingTemplate(src, k).errors) out.push(`${k}: ${e.line}:${e.col} ${e.message}`); + } + return out; +} + +const fingerprint = (t: NamingTemplates) => createHash("sha256").update(namingCanonicalJson(t)).digest("hex"); + +/** 生效模板集 → 版本号(按内容指纹登记,相同内容复用同一版本) */ +export async function ensureNamingVersion(exec: Exec, t: NamingTemplates): Promise { + const fp = fingerprint(t); + const found = await exec + .selectFrom("naming_template_version") + .select("version") + .where("fingerprint", "=", fp) + .executeTakeFirst(); + if (found) return { ...t, version: found.version }; + await exec + .insertInto("naming_template_version") + .values((eb) => ({ + version: eb.fn("nextval", [eb.val("naming_template_version_seq")]), + fingerprint: fp, + templates: JSON.stringify(t), + })) + .onConflict((oc) => oc.column("fingerprint").doNothing()) + .execute(); + const row = await exec + .selectFrom("naming_template_version") + .select("version") + .where("fingerprint", "=", fp) + .executeTakeFirstOrThrow(); + return { ...t, version: row.version }; +} + +/** 按版本取回当时的模板集(锁定文件名模板用);找不到返回 undefined */ +export async function namingByVersion(exec: Exec, version: number): Promise { + if (version === LEGACY_NAMING_VERSION) return { ...LEGACY_V1_NAMING, version }; + const row = await exec + .selectFrom("naming_template_version") + .select(["version", "templates"]) + .where("version", "=", version) + .executeTakeFirst(); + if (!row) return undefined; + const t = row.templates as Partial; + if (typeof t.movie !== "string" || typeof t.tv !== "string" || typeof t.variety !== "string") return undefined; + return { movie: t.movie, tv: t.tv, variety: t.variety, version: row.version }; +} + +/** 命名变量来源:作品 + 英文名(title_alias en*)+ 外部 ID + 季/集元数据 + 库根名 + 版本目标名 */ +export async function loadNamingEntity( + exec: Exec, + entity: MediaEntityRow, + extra: { libraryRoot?: string | undefined; slotId?: string | undefined } = {}, +): Promise { + const [aliases, ids, units, slot] = await Promise.all([ + exec + .selectFrom("title_alias") + .select(["alias", "language", "locked"]) + .where("media_entity_id", "=", entity.id) + .where("content_unit_id", "is", null) + .execute(), + exec + .selectFrom("external_identity") + .select(["provider", "external_id"]) + .where("media_entity_id", "=", entity.id) + .where("content_unit_id", "is", null) + .where("status", "=", "accepted") + .execute(), + exec + .selectFrom("content_unit") + .select(["kind", "season_number", "episode_number", "title", "air_date", "episode_total", "metadata"]) + .where("media_entity_id", "=", entity.id) + .where("season_number", "is not", null) + .execute(), + extra.slotId + ? exec.selectFrom("acquisition_slot").select("name").where("id", "=", extra.slotId).executeTakeFirst() + : Promise.resolve(undefined), + ]); + const en = aliases + .filter((a) => /^en\b|^en-/i.test(a.language ?? "")) + .sort((a, b) => Number(b.locked) - Number(a.locked))[0]?.alias; + const idOf = (p: string) => ids.find((i) => i.provider === p)?.external_id; + const seasons: Record = {}; + const episodes: Record = {}; + for (const u of units) { + const s = u.season_number!; + if (u.episode_number === null) { + const year = u.air_date ? Number(String(u.air_date).slice(0, 4)) : undefined; + seasons[s] = { + ...(year ? { year } : {}), + ...(u.episode_total !== null ? { episodeTotal: u.episode_total } : {}), + }; + } else { + episodes[`${s}:${u.episode_number}`] = { + ...(u.title ? { title: u.title } : {}), + ...(u.air_date ? { airDate: String(u.air_date).slice(0, 10) } : {}), + }; + } + } + const meta = (entity.metadata ?? {}) as Record; + return { + title: entity.title, + ...(entity.original_title ? { originalTitle: entity.original_title } : {}), + ...(en ? { enTitle: en } : {}), + ...(entity.year ? { year: entity.year } : {}), + kind: namingKindOf(entity.kind), + ...(typeof meta.category === "string" && meta.category ? { category: meta.category } : {}), + ...(idOf("tmdb") ? { tmdbId: idOf("tmdb") } : {}), + ...(idOf("imdb") ? { imdbId: idOf("imdb") } : {}), + ...(idOf("douban") ? { doubanId: idOf("douban") } : {}), + ...(extra.libraryRoot ? { libraryRoot: extra.libraryRoot } : {}), + ...(slot?.name ? { slotName: slot.name } : {}), + seasons, + episodes, + }; +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index cda25f4e..c764795d 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -19,7 +19,6 @@ import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecor import { sql } from "@umt/db"; import { buildNamingContext, - DEFAULT_TEMPLATES, type PathBinding as DomainBinding, type ExistingPolicy, type ExistingTarget, @@ -40,6 +39,7 @@ import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; import { applySlotEvent } from "./downloads.ts"; +import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts"; import { PathGuardError } from "./organize-guard.ts"; import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts"; import { hnrSatisfied } from "./retention.ts"; @@ -414,11 +414,16 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise 库根 > 全局 回落并登记版本;目录只在首次绑定时渲染,文件名每次按当前模板 ── + const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined); + const namingEntity = await loadNamingEntity(db, entity, { libraryRoot: root.name, slotId: req.slotId }); + const templates = await ensureNamingVersion(db, effectiveNaming(namingSettings, root, entity).templates); + const boundVersion = bindings.find((b) => b.scope === "entity")?.templateVersion; + const fileTemplates = + namingSettings?.lockFileNameForBound && boundVersion + ? ((await namingByVersion(db, boundVersion)) ?? templates) + : templates; + const naming = buildNamingContext(namingEntity); const facts: FrozenPlan["facts"] = {}; const seasons = new Set(); for (const f of files) { @@ -445,10 +450,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise, season?: number): Promise => { if (dec.kind === "conflict" || dec.kind === "error") { @@ -514,11 +520,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise < >= <=`(整数)、`and`/`or`/括号 | +| `{# 注释 #}`、`{%-`/`-%}` | 注释丢弃;`-` 去掉相邻空白 | + +过滤器:`default('x')`、`pad(n)`(数字补零,`1-3` → `01-03`)、`pad2`、`upper`、`lower`、`trim`、`replace('a','b')`、`truncate(n)`(按字符)、`date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')`、`join(sep)`。 + +拒绝(编译错误):`for`/`set`/宏/`include` 等语句、属性访问 `a.b`、下标 `a[0]`、函数调用、`~` 拼接、`%` 格式化与算术、行内 `x if c else y`、未知变量(提示"是否指 videoFormat")、未知过滤器。限制:模板 ≤ 1024 字符,渲染结果 ≤ 4096 字节。 + +## 变量 + +`GET /naming/variables` 返回完整清单(名称、分组、说明、示例值、`ours`)。名称照抄 MP: + +- 标题:`title` `en_title` `original_title` `name` `year` `title_year` `type` `category` +- 季集:`season`(不补零,剧集缺季号为 1)`season_fmt` `season_year` `episode`(多集 `1-3`)`season_episode`(多集 `S01E01-E03`)`total_episodes` `episode_title` `episode_date` `part` +- 技术:`videoFormat` `resourceType` `effect` `edition` `resource_term` `releaseGroup` `videoCodec` `videoBit` `audioCodec` `webSource` `fps` `customization` `fileExt`(带点,小写) +- ID:`tmdbid` `imdbid` `doubanid` +- 我们特有:`cut` `contentType` `segment` `airDate` `issue` `slotName` `libraryRoot` + +来源:作品名/原名/年份取 `media_entity`;英文名取 `title_alias`(language 以 en 开头,锁定优先);外部 ID 取已接受的 `external_identity`;`season_year`/`total_episodes`/`episode_title`/`episode_date` 取 `content_unit`;`category` 取 `media_entity.metadata.category`;`slotName` 取整理请求的版本目标;其余来自文件名解析。 + +## 目录层级与段角色 + +模板里的 `/` 切段(`\` 视同 `/`,多余的 `/` 合并)。最后一段是文件名,必须以 `{{fileExt}}` 结尾。从后往前第一个含标题变量(`title`/`title_year`/`en_title`/`original_title`/`name`)且不含季变量的目录段是**作品根**;作品根之后含季变量的段是**季目录**,其余是**子目录**;作品根之前是**分类前缀**。分类/子目录段渲染为空时整段省略,作品根或文件名为空报错。 + +## 非法字符与长度 + +- 变量值里的 `\/:*?"<>|` 转全角,删控制字符。所有变量都转(MP 只转标题类),值里的 `/` 不会多出目录。 +- 模板字面量里的这些字符(`/` 除外)也转全角。 +- 每段合并连续空白、去首尾空白和点;Windows 保留名(CON、NUL、COM1…)加 `_`。 +- 每段 ≤ 255 字节(UTF-8),文件名截主干保留扩展名;全路径 ≤ 4096 字节。 + +## 覆盖顺序与路径绑定 + +- 生效顺序:作品(`media_entity.naming_override`)> 库根(`library_root.naming`)> 全局(`settings.naming`),按 movie/tv/variety 分别回落。 +- 作品类型:`movie`(及 collection/other)用 movie;`series` 用 tv;`variety` 用 variety。剧集文件只有播出日期没有集号时也用 variety。 +- **路径绑定不变**:作品根与季目录只在首次建立 `path_binding` 时按模板渲染;之后入库直接用绑定路径,模板和译名变化都不改已有目录。绑定之后模板里剩余的子目录段(如 `{{edition}}`)照常渲染。 +- 文件名每次入库按当前生效模板渲染,已入库文件不回溯改名。设置 `naming.lockFileNameForBound = true` 后,已有作品绑定的作品按绑定记录的模板版本出文件名。 +- 模板版本:生效模板集按内容指纹(sha256)登记在 `naming_template_version`,新建绑定的 `template_version` 就是这个版本号。1 = v1 旧模板(迁移后的新语法写法),2 = MP 默认,之后的从 3 起。 + +## 接口 + +| 方法 路径 | 权限 | 说明 | +| --- | --- | --- | +| `GET /naming/variables` | read | 变量表、过滤器说明、6 条内置样本 | +| `POST /naming/preview` | read | `{template?, kind, sample?: {fileName}\|{sourceReleaseId}\|{assetId}\|{builtin}, libraryRootId?, entityId?}` → `{ok, relativePath, segments[{text, role}], errors[{code, message, line, col}], warnings, missing, usesBinding, templateSource, template, sample, vars}`。不传 template 用三级回落的生效模板;作品已有绑定时目录用绑定路径 | +| `GET /naming/templates?libraryRootId&entityId` | read | 全局、库根、作品三级模板,MP 默认,按类型的生效模板与来源 | +| `PUT /naming/templates` | admin | 保存全局模板(部分字段),逐条编译,有错 422 | +| `PUT /library-roots/{id}/naming` | admin | `{naming: {movie?, tv?, variety?} \| null}` | +| `PUT /media-entities/{id}/naming-override` | admin | `{namingOverride: {movie?, tv?, variety?} \| null}` | + +`PATCH /settings` 也能改 `naming`,但只做形状校验,不编译模板;前端保存请走 `PUT /naming/templates`。 + +## 与 MP 的差分 + +`packages/domain/test/differential/naming-cases.json`(34 条)+ `naming-mp.json`(MP 输出)。没有登记 deviation 的 21 条与 MP 逐字一致;登记的 13 条: + +| # | 差异 | 理由 | +| --- | --- | --- | +| N07 | E01-E03 保留区间;MP 截成 `S01E01 - 第 1 集` | 集账本要登记 3 集,截断会让后两集看起来缺失 | +| N11 | 扩展名统一小写;MP 保留 `.MKV` | 避免同一文件因大小写不同被当成两个目标 | +| N14 | 合并连续空白;MP 的 `resource_term` 带前导空格,出现 `- WEB-DL` | 只在变量为空或 MP 自带空格时有差异 | +| N15 | `releaseGroup` 里的 `/` 转全角;MP 多出一层目录 | 值不应该决定目录层级 | +| N17 | `pad` 是我们的过滤器,MP 报错 | MP 的 `'%02d' % season\|int` 需要运算,安全子集不开放 | +| N19 | 每段截到 255 字节;MP 不截 | 超长由文件系统报错太晚 | +| N20 | 综艺模板(`airDate`、`issue`、`date()`)是我们特有 | MP 没有综艺类型 | +| N22、N23 | `for`、属性访问编译报错;MP 照常渲染 | 安全子集 | +| N24 | 模板缺 `{{fileExt}}` 报错;MP 生成无扩展名文件 | | +| N26 | `videoCodec` 输出规范值 `H265`;MP 保留原词 `HEVC` | 解析器把 HEVC/H.265/x265 归一 | +| N33 | Windows 保留名加 `_` | MP 不处理 | +| N34 | 模板字面量里的 `:` 转全角 | MP 原样保留 | + +另外(不在差分文件里):已有作品绑定 `旧名 (2008)`、新模板渲染出 `新名 (2008)` 时,目录仍用 `旧名 (2008)/Season 1/…`,文件名用新模板(`packages/domain/test/naming.test.ts` "路径绑定不变")。 + +重新生成 MP 输出(改了样例或跟进上游后): + +```sh +cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \ + .venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/gen-naming-mp.py +``` + +测试断言:无 deviation 的样例与 MP 一致;有 deviation 的必须确实不同,差异消除后删掉登记。 + +## 迁移说明 + +- 0032 之前的默认模板(v1:`{var}` + `[[可选段]]`)已由 `upgradeLegacyTemplate` 改写为新语法,作为版本 1 登记;已有绑定(`template_version = 1`)不动。 +- 升级后新建的绑定和所有新入库文件按 MP 默认(版本 2)命名:剧集文件名多出 ` - 第 N 集`。要保持旧文件名,打开 `naming.lockFileNameForBound`(只对已有绑定的作品生效),或把全局模板改回 v1 写法(`LEGACY_V1_NAMING`)。