import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { zipSync } from "fflate"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts"; import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts"; import { DEV_KEY } from "../helpers.ts"; // 补字幕(docs/plan/subtitles.md):上传(含 zip)→ 解析语言 / 季集 → 配对 → 写到视频同目录 → 字幕资产 + 历史 → 撤销 const hasPg = await pgAvailable(); const enc = (s: string) => new TextEncoder().encode(s); const b64 = (u: Uint8Array) => btoa(String.fromCharCode(...u)); const srt = (line: string) => `1\n00:00:01,000 --> 00:00:02,000\n${line}\n\n2\n00:00:03,000 --> 00:00:04,000\n${line}\n`; describe.skipIf(!hasPg)("补字幕:上传、写入、撤销(真实 PG + 临时目录)", () => { let h: Harness; let lib: string; let data: string; let entityId: string; const loc: Record = {}; const api = async (method: string, path: string, body?: unknown) => { const res = await h.app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), }); return { status: res.status, body: await res.json() }; }; beforeAll(async () => { h = await createHarness({ extraModules: [createSubtitlesModule()] }); lib = await mkdtemp(join(tmpdir(), "umt-sub-lib-")); data = await mkdtemp(join(tmpdir(), "umt-sub-data-")); (h.ctx.config as { DATA_DIR: string }).DATA_DIR = data; const db = h.db; const root = await db .insertInto("library_root") .values({ name: "剧集库", media_kind: "mixed", local_path: lib, management_mode: "managed" }) .returning("id") .executeTakeFirstOrThrow(); const e = await db .insertInto("media_entity") .values({ kind: "series", title: "示例剧", year: 2024 } as never) .returning("id") .executeTakeFirstOrThrow(); entityId = e.id; await mkdir(join(lib, "示例剧 (2024)/Season 1"), { recursive: true }); for (const ep of [1, 2]) { const rel = `示例剧 (2024)/Season 1/示例剧 - S01E0${ep}.mkv`; await writeFile(join(lib, rel), "video"); const a = await db .insertInto("media_asset") .values({ media_entity_id: entityId, kind: "video", status: "active" }) .returning("id") .executeTakeFirstOrThrow(); const l = await db .insertInto("asset_location") .values({ media_asset_id: a.id, location_kind: "library", library_root_id: root.id, relative_path: rel, status: "available", }) .returning("id") .executeTakeFirstOrThrow(); loc[`e${ep}`] = l.id; const u = await db .insertInto("content_unit") .values({ media_entity_id: entityId, kind: "episode", season_number: 1, episode_number: ep } as never) .returning("id") .executeTakeFirstOrThrow(); await db.insertInto("asset_unit_coverage").values({ media_asset_id: a.id, content_unit_id: u.id }).execute(); } // 第 2 集已有一份简中字幕(覆盖时要备份) await writeFile(join(lib, "示例剧 (2024)/Season 1/示例剧 - S01E02.chi.zh-cn.srt"), "旧字幕"); }, 120_000); afterAll(async () => { await h?.close(); await rm(lib, { recursive: true, force: true }); await rm(data, { recursive: true, force: true }); }); it("目标:作品的视频、季集、同目录已有字幕", async () => { const r = await api("GET", `/subtitles/targets?entityId=${entityId}`); expect(r.status).toBe(200); expect(r.body.data.kind).toBe("tv"); expect(r.body.data.targets.map((t: { episode: number }) => t.episode)).toEqual([1, 2]); expect(r.body.data.targets[1].existing).toEqual([ { fileName: "示例剧 - S01E02.chi.zh-cn.srt", language: "zh-Hans" }, ]); }); it("解析 zip + 单个文件:GBK 转 UTF-8、语言、季集与配对;非字幕跳过", async () => { const gbk = Uint8Array.from([0xc4, 0xe3, 0xba, 0xc3]); // 「你好」GB18030 const zip = zipSync({ "subs/Show.S01E01.cht.ass": enc("[Script Info]\nDialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,我們這個時候"), "subs/readme.txt": enc("ignore"), }); const r = await api("POST", "/subtitles/parse", { entityId, files: [ { name: "pack.zip", base64: b64(zip) }, { name: "示例剧.S01E02.简体.srt", base64: b64(Uint8Array.from([...enc("1\n00:00:01,000 --> 00:00:02,000\n"), ...gbk])), }, { name: "x.rar", base64: b64(enc("rar")) }, ], }); expect(r.status).toBe(200); const items = r.body.data.items; expect(items).toHaveLength(2); expect(items[0]).toMatchObject({ name: "subs/Show.S01E01.cht.ass", archive: "pack.zip", language: "zh-Hant", episode: 1, targetLocationId: loc.e1, }); expect(items[1]).toMatchObject({ language: "zh-Hans", encoding: "gb18030", episode: 2, targetLocationId: loc.e2 }); expect(new TextDecoder().decode(Uint8Array.from(atob(items[1].content), (c) => c.charCodeAt(0)))).toContain("你好"); expect(r.body.data.skipped.map((s: { name: string }) => s.name)).toEqual(["x.rar"]); }); it("写入:同目录主干名 + 语言后缀;覆盖先备份;登记字幕资产;撤销还原 / 删除", async () => { const content = (s: string) => b64(enc(s)); const r = await api("POST", "/subtitles/apply", { entityId, items: [ { name: "a.ass", ext: "ass", language: "zh-Hant", content: content("繁中"), targetLocationId: loc.e1 }, { name: "b.srt", ext: "srt", language: "zh-Hans&en", content: content(srt("新字幕")), targetLocationId: loc.e2, }, ], }); expect(r.status).toBe(200); expect(r.body.data.failed).toEqual([]); const [w1, w2] = r.body.data.written; expect(w1).toMatchObject({ fileName: "示例剧 - S01E01.zh-tw.ass", replaced: false, source: "upload" }); expect(w2).toMatchObject({ fileName: "示例剧 - S01E02.chi.zh-cn.srt", replaced: true }); const dir = join(lib, "示例剧 (2024)/Season 1"); expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toContain("新字幕"); expect((await readdir(dir)).some((n) => n.endsWith(".tmp"))).toBe(false); const subs = await h.db .selectFrom("asset_location") .select(["relative_path", "sidecar_of_location_id"]) .where("relative_path", "like", "%.zh-tw.ass") .execute(); expect(subs).toEqual([ { relative_path: "示例剧 (2024)/Season 1/示例剧 - S01E01.zh-tw.ass", sidecar_of_location_id: loc.e1 }, ]); const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`); expect(hist.body.data.items).toHaveLength(2); expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy(); expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕"); await api("POST", `/subtitles/writes/${w1.id}/undo`); expect((await readdir(dir)).includes("示例剧 - S01E01.zh-tw.ass")).toBe(false); expect((await api("POST", `/subtitles/writes/${w1.id}/undo`)).status).toBe(409); }); it("同一次里两个字幕写到同一个文件:后一个报错;目标不属于这部作品报错", async () => { const it1 = { name: "x.srt", ext: "srt", language: "en", content: b64(enc("x")), targetLocationId: loc.e1 }; const r = await api("POST", "/subtitles/apply", { entityId, items: [it1, { ...it1, name: "y.srt" }, { ...it1, name: "z.srt", targetLocationId: crypto.randomUUID() }], }); expect(r.body.data.written).toHaveLength(1); expect(r.body.data.failed.map((f: { name: string }) => f.name)).toEqual(["y.srt", "z.srt"]); }); });