feat(subtitles): 补字幕第 1 步——上传字幕 / zip,识别语言与季集,配对写入视频同目录,历史与撤销

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 01:08:05 +08:00
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"]);
});
});