feat(subtitles): 补字幕第 1 步——上传字幕 / zip,识别语言与季集,配对写入视频同目录,历史与撤销
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
feee481915
commit
ef29bd62ce
22 files changed
+1554
-2
No files matched your search
@@ -0,0 +1,179 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user