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);
|
||||
}
|
||||
Reference in new issue
Block a user