180 lines
8.0 KiB
TypeScript
180 lines
8.0 KiB
TypeScript
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<string, string> = {};
|
|
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"]);
|
|
});
|
|
});
|