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
@@ -10,6 +10,7 @@ import { piModule } from "./pi/index.ts";
|
||||
import { schedulesModule } from "./schedules/index.ts";
|
||||
import { searchModule } from "./search/index.ts";
|
||||
import { sourcesIdentityModule } from "./sources-identity/index.ts";
|
||||
import { subtitlesModule } from "./subtitles/index.ts";
|
||||
import { systemModule } from "./system/index.ts";
|
||||
import type { ServerModule } from "./types.ts";
|
||||
import { webSearchModule } from "./websearch/index.ts";
|
||||
@@ -29,6 +30,7 @@ export const SERVER_MODULES: readonly ServerModule[] = [
|
||||
piModule,
|
||||
schedulesModule,
|
||||
searchModule,
|
||||
subtitlesModule,
|
||||
browserModule,
|
||||
desktopModule,
|
||||
mcpModule,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Api, type SubtitleApplyRequest } from "@umt/contracts";
|
||||
import { route } from "../agent/http.ts";
|
||||
import type { ServerModule } from "../types.ts";
|
||||
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||
|
||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
||||
export function createSubtitlesModule(): ServerModule {
|
||||
return {
|
||||
name: "subtitles",
|
||||
registerRoutes(app, ctx) {
|
||||
const tag = { tag: "subtitles" };
|
||||
route(app, Api.subtitles.targets, ({ query }) => subtitleTargets(ctx, query.entityId), tag);
|
||||
route(app, Api.subtitles.parse, ({ body }) => parseSubtitles(ctx, body.files, body.entityId), tag);
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.apply,
|
||||
({ body, actorId }) => applySubtitles(ctx, body as SubtitleApplyRequest, actorId),
|
||||
tag,
|
||||
);
|
||||
route(
|
||||
app,
|
||||
Api.subtitles.writes,
|
||||
async ({ query }) => ({ items: await subtitleWrites(ctx, query.entityId) }),
|
||||
tag,
|
||||
);
|
||||
route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag);
|
||||
},
|
||||
registerTasks() {},
|
||||
};
|
||||
}
|
||||
|
||||
export const subtitlesModule = createSubtitlesModule();
|
||||
@@ -0,0 +1,382 @@
|
||||
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
SubtitleApplyRequest,
|
||||
SubtitleApplyResult,
|
||||
SubtitleLanguage,
|
||||
SubtitleParsedItem,
|
||||
SubtitleParseResponse,
|
||||
SubtitleSource,
|
||||
SubtitleTarget,
|
||||
SubtitleTargetsResponse,
|
||||
SubtitleWriteView,
|
||||
} from "@umt/contracts";
|
||||
import type { SubtitleWriteTable } from "@umt/db";
|
||||
import {
|
||||
type DefaultSubtitleLang,
|
||||
decodeSubtitle,
|
||||
detectSubtitleLanguage,
|
||||
isSidecarOf,
|
||||
pairSubtitle,
|
||||
type SubtitleExt,
|
||||
subtitleEpisode,
|
||||
subtitleExtOf,
|
||||
subtitleFileName,
|
||||
} from "@umt/domain";
|
||||
import { unzipSync } from "fflate";
|
||||
import type { Selectable } from "kysely";
|
||||
import { dataPaths } from "../../config.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// 补字幕(docs/plan/subtitles.md;对照 MP 插件「海拉鲁字幕大师」upload / matching / subtitle_writer):
|
||||
// 解析(编码转 UTF-8、语言、季集)→ 配对到作品的视频 → 写到视频同目录(主干名 + 语言后缀)→ 登记字幕资产 + 历史,可撤销。
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_ARCHIVE_ENTRIES = 300;
|
||||
|
||||
const b64 = (bytes: Uint8Array) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(s);
|
||||
};
|
||||
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
const fileName = (p: string) => p.replace(/\\/g, "/").split("/").pop() ?? p;
|
||||
|
||||
// ── 目标 ──
|
||||
|
||||
interface VideoTarget extends SubtitleTarget {
|
||||
rootPath: string;
|
||||
libraryRootId: string;
|
||||
}
|
||||
|
||||
async function videoTargets(
|
||||
ctx: ServerContext,
|
||||
entityId: string,
|
||||
): Promise<{ kind: "movie" | "tv"; targets: VideoTarget[] }> {
|
||||
const entity = await ctx.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["id", "kind"])
|
||||
.where("id", "=", entityId)
|
||||
.executeTakeFirst();
|
||||
if (!entity) throw new ApiException("not_found", "作品不存在");
|
||||
const rows = await ctx.db
|
||||
.selectFrom("media_asset as a")
|
||||
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
|
||||
.innerJoin("library_root as r", "r.id", "l.library_root_id")
|
||||
.leftJoin("asset_unit_coverage as c", "c.media_asset_id", "a.id")
|
||||
.leftJoin("content_unit as u", "u.id", "c.content_unit_id")
|
||||
.select([
|
||||
"l.id as locationId",
|
||||
"l.relative_path as relativePath",
|
||||
"r.id as rootId",
|
||||
"r.name as rootName",
|
||||
"r.local_path as rootPath",
|
||||
"u.season_number as season",
|
||||
"u.episode_number as episode",
|
||||
])
|
||||
.where("a.media_entity_id", "=", entityId)
|
||||
.where("a.kind", "=", "video")
|
||||
.where("a.status", "=", "active")
|
||||
.where("l.location_kind", "=", "library")
|
||||
.where("l.status", "=", "available")
|
||||
.where("r.enabled", "=", true)
|
||||
.orderBy("u.season_number")
|
||||
.orderBy("u.episode_number")
|
||||
.orderBy("l.relative_path")
|
||||
.execute();
|
||||
// 一个视频覆盖多集(合集)时取第一集
|
||||
const seen = new Set<string>();
|
||||
const targets: VideoTarget[] = [];
|
||||
const dirCache = new Map<string, string[]>();
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.locationId)) continue;
|
||||
seen.add(r.locationId);
|
||||
const abs = join(r.rootPath, r.relativePath);
|
||||
const dir = dirname(abs);
|
||||
if (!dirCache.has(dir)) dirCache.set(dir, await readdir(dir).catch(() => []));
|
||||
const existing = dirCache
|
||||
.get(dir)!
|
||||
.filter((n) => subtitleExtOf(n) && isSidecarOf(n, r.relativePath))
|
||||
.map((n) => ({ fileName: n, language: detectSubtitleLanguage(n, "") as SubtitleLanguage }));
|
||||
targets.push({
|
||||
locationId: r.locationId,
|
||||
season: entity.kind === "movie" ? null : r.season,
|
||||
episode: entity.kind === "movie" ? null : r.episode,
|
||||
fileName: fileName(r.relativePath),
|
||||
relativePath: r.relativePath,
|
||||
libraryRootName: r.rootName,
|
||||
libraryRootId: r.rootId,
|
||||
rootPath: r.rootPath,
|
||||
existing,
|
||||
});
|
||||
}
|
||||
return { kind: entity.kind === "movie" ? "movie" : "tv", targets };
|
||||
}
|
||||
|
||||
export async function subtitleTargets(ctx: ServerContext, entityId: string): Promise<SubtitleTargetsResponse> {
|
||||
const { kind, targets } = await videoTargets(ctx, entityId);
|
||||
return { entityId, kind, targets: targets.map(({ rootPath: _r, libraryRootId: _i, ...t }) => t) };
|
||||
}
|
||||
|
||||
// ── 解析 ──
|
||||
|
||||
export async function parseSubtitles(
|
||||
ctx: ServerContext,
|
||||
files: { name: string; base64: string }[],
|
||||
entityId?: string,
|
||||
): Promise<SubtitleParseResponse> {
|
||||
const targets = entityId
|
||||
? (await videoTargets(ctx, entityId)).targets.map((t) => ({
|
||||
id: t.locationId,
|
||||
season: t.season,
|
||||
episode: t.episode,
|
||||
}))
|
||||
: [];
|
||||
const items: SubtitleParsedItem[] = [];
|
||||
const skipped: SubtitleParseResponse["skipped"] = [];
|
||||
const one = (name: string, bytes: Uint8Array, archive: string | null) => {
|
||||
const ext = subtitleExtOf(name);
|
||||
if (!ext) return skipped.push({ name, reason: "不是字幕文件" });
|
||||
if (bytes.length > MAX_FILE_BYTES) return skipped.push({ name, reason: "文件超过 10 MB" });
|
||||
const { text, encoding } = decodeSubtitle(bytes);
|
||||
const ep = subtitleEpisode(name);
|
||||
items.push({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
archive,
|
||||
ext,
|
||||
language: detectSubtitleLanguage(name, text) as SubtitleLanguage,
|
||||
season: ep.season,
|
||||
seasonInferred: ep.seasonInferred,
|
||||
episode: ep.episode,
|
||||
encoding,
|
||||
sizeBytes: bytes.length,
|
||||
content: b64(new TextEncoder().encode(text)),
|
||||
targetLocationId: targets.length ? pairSubtitle(ep, targets) : null,
|
||||
});
|
||||
};
|
||||
for (const f of files) {
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = unb64(f.base64);
|
||||
} catch {
|
||||
skipped.push({ name: f.name, reason: "内容不是有效的 base64" });
|
||||
continue;
|
||||
}
|
||||
if (/\.zip$/i.test(f.name)) {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
let n = 0;
|
||||
entries = unzipSync(bytes, {
|
||||
filter: (e) =>
|
||||
!e.name.endsWith("/") &&
|
||||
!!subtitleExtOf(e.name) &&
|
||||
e.originalSize <= MAX_FILE_BYTES &&
|
||||
++n <= MAX_ARCHIVE_ENTRIES,
|
||||
});
|
||||
} catch {
|
||||
skipped.push({ name: f.name, reason: "压缩包损坏或加密" });
|
||||
continue;
|
||||
}
|
||||
const names = Object.keys(entries);
|
||||
if (!names.length) skipped.push({ name: f.name, reason: "压缩包里没有字幕文件" });
|
||||
for (const n of names) one(n, entries[n]!, f.name);
|
||||
} else if (/\.(rar|7z)$/i.test(f.name)) {
|
||||
skipped.push({ name: f.name, reason: "暂不支持 rar / 7z,请解压后上传或改用 zip" });
|
||||
} else one(f.name, bytes, null);
|
||||
}
|
||||
return { items, skipped };
|
||||
}
|
||||
|
||||
// ── 写入 ──
|
||||
|
||||
function writeView(r: Selectable<SubtitleWriteTable>): SubtitleWriteView {
|
||||
return {
|
||||
id: r.id,
|
||||
mediaEntityId: r.media_entity_id,
|
||||
videoLocationId: r.video_location_id,
|
||||
fileName: fileName(r.relative_path),
|
||||
relativePath: r.relative_path,
|
||||
language: r.language as SubtitleLanguage,
|
||||
source: r.source as SubtitleSource,
|
||||
sourceName: r.source_name,
|
||||
replaced: !!r.backup_path,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
undoneAt: r.undone_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const exists = (p: string) =>
|
||||
stat(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
/** 临时文件 + 改名:不留半个字幕文件 */
|
||||
async function atomicWrite(path: string, bytes: Uint8Array): Promise<void> {
|
||||
const tmp = `${path}.umt-${crypto.randomUUID().slice(0, 8)}.tmp`;
|
||||
await writeFile(tmp, bytes);
|
||||
try {
|
||||
await rename(tmp, path);
|
||||
} catch (e) {
|
||||
await rm(tmp, { force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySubtitles(
|
||||
ctx: ServerContext,
|
||||
body: SubtitleApplyRequest,
|
||||
actor: string,
|
||||
): Promise<SubtitleApplyResult> {
|
||||
const { targets } = await videoTargets(ctx, body.entityId);
|
||||
const byId = new Map(targets.map((t) => [t.locationId, t]));
|
||||
const defaultLang = ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG as DefaultSubtitleLang | undefined;
|
||||
const backupRoot = join(dataPaths(ctx.config).backupsDir, "subtitles");
|
||||
const written: SubtitleWriteView[] = [];
|
||||
const failed: SubtitleApplyResult["failed"] = [];
|
||||
const planned = new Set<string>();
|
||||
for (const it of body.items) {
|
||||
const t = byId.get(it.targetLocationId);
|
||||
if (!t) {
|
||||
failed.push({ name: it.name, reason: "目标视频不在这部作品的媒体库里" });
|
||||
continue;
|
||||
}
|
||||
const name = subtitleFileName(t.relativePath, it.language, it.ext as SubtitleExt, defaultLang);
|
||||
const rel = join(dirname(t.relativePath), name);
|
||||
if (planned.has(rel)) {
|
||||
failed.push({ name: it.name, reason: `与本次另一个字幕写到同一个文件 ${name}` });
|
||||
continue;
|
||||
}
|
||||
planned.add(rel);
|
||||
const abs = join(t.rootPath, rel);
|
||||
const id = crypto.randomUUID();
|
||||
try {
|
||||
let backup: string | null = null;
|
||||
if (await exists(abs)) {
|
||||
backup = join(backupRoot, id, name);
|
||||
await mkdir(dirname(backup), { recursive: true });
|
||||
await copyFile(abs, backup);
|
||||
}
|
||||
const bytes = unb64(it.content);
|
||||
await atomicWrite(abs, bytes);
|
||||
const row = await ctx.db.transaction().execute(async (trx) => {
|
||||
// 同路径已登记的字幕资产(覆盖)沿用;否则新建字幕资产,位置挂在视频下
|
||||
const prev = await trx
|
||||
.selectFrom("asset_location")
|
||||
.select(["id", "media_asset_id"])
|
||||
.where("library_root_id", "=", t.libraryRootId)
|
||||
.where("relative_path", "=", rel)
|
||||
.executeTakeFirst();
|
||||
let assetId = prev?.media_asset_id ?? null;
|
||||
if (prev) {
|
||||
await trx
|
||||
.updateTable("asset_location")
|
||||
.set({ status: "available", size_bytes: bytes.length, last_seen_at: new Date() })
|
||||
.where("id", "=", prev.id)
|
||||
.execute();
|
||||
await trx
|
||||
.updateTable("media_asset")
|
||||
.set({ status: "active", size_bytes: bytes.length })
|
||||
.where("id", "=", assetId!)
|
||||
.execute();
|
||||
} else {
|
||||
const a = await trx
|
||||
.insertInto("media_asset")
|
||||
.values({ media_entity_id: body.entityId, kind: "subtitle", size_bytes: bytes.length, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
assetId = a.id;
|
||||
await trx
|
||||
.insertInto("asset_location")
|
||||
.values({
|
||||
media_asset_id: a.id,
|
||||
location_kind: "library",
|
||||
library_root_id: t.libraryRootId,
|
||||
relative_path: rel,
|
||||
status: "available",
|
||||
is_primary: true,
|
||||
size_bytes: bytes.length,
|
||||
sidecar_of_location_id: t.locationId,
|
||||
last_seen_at: new Date(),
|
||||
last_checked_at: new Date(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
return await trx
|
||||
.insertInto("subtitle_write")
|
||||
.values({
|
||||
id,
|
||||
media_entity_id: body.entityId,
|
||||
video_location_id: t.locationId,
|
||||
subtitle_asset_id: assetId,
|
||||
library_root_id: t.libraryRootId,
|
||||
relative_path: rel,
|
||||
language: it.language,
|
||||
source: body.source ?? "upload",
|
||||
source_name: it.name,
|
||||
source_ref: it.sourceRef ?? null,
|
||||
backup_path: backup,
|
||||
actor,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
});
|
||||
written.push(writeView(row));
|
||||
} catch (e) {
|
||||
failed.push({ name: it.name, reason: `写入失败:${(e as Error).message}` });
|
||||
}
|
||||
}
|
||||
return { written, failed };
|
||||
}
|
||||
|
||||
export async function subtitleWrites(ctx: ServerContext, entityId: string): Promise<SubtitleWriteView[]> {
|
||||
const rows = await ctx.db
|
||||
.selectFrom("subtitle_write")
|
||||
.selectAll()
|
||||
.where("media_entity_id", "=", entityId)
|
||||
.orderBy("created_at", "desc")
|
||||
.limit(200)
|
||||
.execute();
|
||||
return rows.map(writeView);
|
||||
}
|
||||
|
||||
/** 撤销:有备份就还原,没有就删掉写入的文件并把字幕资产标为失效 */
|
||||
export async function undoSubtitleWrite(ctx: ServerContext, id: string): Promise<SubtitleWriteView> {
|
||||
const row = await ctx.db.selectFrom("subtitle_write").selectAll().where("id", "=", id).executeTakeFirst();
|
||||
if (!row) throw new ApiException("not_found", "记录不存在");
|
||||
if (row.undone_at) throw new ApiException("conflict", "已经撤销过了");
|
||||
const root = await ctx.db
|
||||
.selectFrom("library_root")
|
||||
.select("local_path")
|
||||
.where("id", "=", row.library_root_id)
|
||||
.executeTakeFirstOrThrow();
|
||||
const abs = join(root.local_path, row.relative_path);
|
||||
if (row.backup_path && (await exists(row.backup_path))) {
|
||||
await copyFile(row.backup_path, `${abs}.umt-restore.tmp`);
|
||||
await rename(`${abs}.umt-restore.tmp`, abs);
|
||||
} else {
|
||||
await rm(abs, { force: true });
|
||||
if (row.subtitle_asset_id) {
|
||||
await ctx.db
|
||||
.updateTable("asset_location")
|
||||
.set({ status: "removed" })
|
||||
.where("media_asset_id", "=", row.subtitle_asset_id)
|
||||
.where("relative_path", "=", row.relative_path)
|
||||
.execute();
|
||||
await ctx.db
|
||||
.updateTable("media_asset")
|
||||
.set({ status: "retired" })
|
||||
.where("id", "=", row.subtitle_asset_id)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
const out = await ctx.db
|
||||
.updateTable("subtitle_write")
|
||||
.set({ undone_at: new Date() })
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
return writeView(out);
|
||||
}
|
||||
@@ -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