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 "../../src/modules/acquisition-organize/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" }, ], }); }); });