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

+2
View File
@@ -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"]);
});
});