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);
}