diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab0a9f7..22485ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 新增 +- 补字幕(第 1 步:手动上传,参考 MP 插件「海拉鲁字幕大师」):作品页「补字幕」,可一次上传多个字幕或 zip;自动转 UTF-8(GBK / Big5)、识别语言(简中 / 繁中 / 双语 / 英日韩)和季集并配到对应的视频,预览里可改语言和目标;写到视频同目录,文件名是视频名加语言后缀(沿用整理规则),同名字幕先备份;登记为字幕资产,有历史,可撤销。迁移 `0064_subtitle_write`。 + ## [0.3.21] - 2026-10-03 ### 新增 diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index 514e3025..bd0909b1 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -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, diff --git a/apps/server/src/modules/subtitles/index.ts b/apps/server/src/modules/subtitles/index.ts new file mode 100644 index 00000000..a803da48 --- /dev/null +++ b/apps/server/src/modules/subtitles/index.ts @@ -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(); diff --git a/apps/server/src/modules/subtitles/service.ts b/apps/server/src/modules/subtitles/service.ts new file mode 100644 index 00000000..dcc011e7 --- /dev/null +++ b/apps/server/src/modules/subtitles/service.ts @@ -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(); + const targets: VideoTarget[] = []; + const dirCache = new Map(); + 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 { + 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 { + 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; + 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): 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 { + 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 { + 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(); + 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 { + 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 { + 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); +} diff --git a/apps/server/test/subtitles/subtitle-upload.test.ts b/apps/server/test/subtitles/subtitle-upload.test.ts new file mode 100644 index 00000000..268b3e13 --- /dev/null +++ b/apps/server/test/subtitles/subtitle-upload.test.ts @@ -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 = {}; + 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"]); + }); +}); diff --git a/apps/web/app/api/subtitles.ts b/apps/web/app/api/subtitles.ts new file mode 100644 index 00000000..d7fa5eb1 --- /dev/null +++ b/apps/web/app/api/subtitles.ts @@ -0,0 +1,84 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type SubtitleApplyRequest, + SubtitleApplyResult, + SubtitleParseResponse, + SubtitleTargetsResponse, + SubtitleWritesResponse, + SubtitleWriteView, +} from "@umt/contracts"; +import { api } from "~/lib/api.ts"; +import { getParsed, parseOrThrow } from "./library.ts"; + +// 补字幕(docs/plan/subtitles.md):目标视频、解析上传、写入、历史与撤销 + +export const subtitleKeys = { + targets: (entityId: string) => ["subtitles", "targets", entityId] as const, + writes: (entityId: string) => ["subtitles", "writes", entityId] as const, +}; + +export function useSubtitleTargets(entityId: string, enabled = true) { + return useQuery({ + queryKey: subtitleKeys.targets(entityId), + enabled, + queryFn: ({ signal }) => getParsed(SubtitleTargetsResponse, "/subtitles/targets", { entityId }, signal), + }); +} + +export function useSubtitleWrites(entityId: string, enabled = true) { + return useQuery({ + queryKey: subtitleKeys.writes(entityId), + enabled, + queryFn: ({ signal }) => getParsed(SubtitleWritesResponse, "/subtitles/writes", { entityId }, signal), + }); +} + +/** 读本地文件为 base64(字幕与 zip 都很小,直接放进 JSON) */ +async function fileBase64(f: File): Promise { + const buf = new Uint8Array(await f.arrayBuffer()); + let s = ""; + for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000)); + return btoa(s); +} + +export function useParseSubtitles() { + return useMutation({ + mutationFn: async ({ entityId, files }: { entityId: string; files: File[] }) => + parseOrThrow( + SubtitleParseResponse, + await api.post("/subtitles/parse", { + entityId, + files: await Promise.all(files.map(async (f) => ({ name: f.name, base64: await fileBase64(f) }))), + }), + "/subtitles/parse", + ), + }); +} + +export function useApplySubtitles() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: SubtitleApplyRequest) => + parseOrThrow(SubtitleApplyResult, await api.post("/subtitles/apply", body), "/subtitles/apply"), + onSuccess: (_r, body) => { + qc.invalidateQueries({ queryKey: subtitleKeys.targets(body.entityId) }); + qc.invalidateQueries({ queryKey: subtitleKeys.writes(body.entityId) }); + }, + }); +} + +export function useUndoSubtitle(entityId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => + parseOrThrow( + SubtitleWriteView, + await api.post(`/subtitles/writes/${id}/undo`), + "/subtitles/writes/undo", + ), + onSuccess: () => { + qc.invalidateQueries({ queryKey: subtitleKeys.targets(entityId) }); + qc.invalidateQueries({ queryKey: subtitleKeys.writes(entityId) }); + }, + }); +} diff --git a/apps/web/app/components/sites/site-add-dialog.tsx b/apps/web/app/components/sites/site-add-dialog.tsx index 7380277f..7ed3bf6f 100644 --- a/apps/web/app/components/sites/site-add-dialog.tsx +++ b/apps/web/app/components/sites/site-add-dialog.tsx @@ -1,6 +1,4 @@ import { - cursorPage, - FeedSource, SITE_KINDS, type SiteDefinitionView, type SiteHealthResult, diff --git a/apps/web/app/components/subtitles/subtitle-dialog.tsx b/apps/web/app/components/subtitles/subtitle-dialog.tsx new file mode 100644 index 00000000..d69de11c --- /dev/null +++ b/apps/web/app/components/subtitles/subtitle-dialog.tsx @@ -0,0 +1,320 @@ +import type { SubtitleLanguage, SubtitleParsedItem, SubtitleTarget, SubtitleWriteView } from "@umt/contracts"; +import { FileUp, LoaderCircle, Undo2 } from "lucide-react"; +import { useId, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + useApplySubtitles, + useParseSubtitles, + useSubtitleTargets, + useSubtitleWrites, + useUndoSubtitle, +} from "~/api/subtitles.ts"; +import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { relTime } from "~/components/workbench/release-fields.ts"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 补字幕(docs/plan/subtitles.md):上传字幕 / zip → 预览(语言、写到哪一集,可改)→ 写到视频同目录;下面是历史,可撤销。 + +export const SUBTITLE_LANGUAGE_LABEL: Record = { + "zh-Hans": "简中", + "zh-Hant": "繁中", + "zh-Hans&en": "简英双语", + "zh-Hant&en": "繁英双语", + "zh&ja": "中日双语", + "zh&ko": "中韩双语", + en: "英文", + ja: "日文", + ko: "韩文", + unknown: "未知", +}; +const SOURCE_LABEL = { upload: "上传", subhd: "SubHD", zimuku: "字幕库" } as const; +const SKIP = "__skip__"; +const ACCEPT = ".srt,.ass,.ssa,.vtt,.sub,.sbv,.zip"; + +const targetLabel = (t: SubtitleTarget) => + t.episode !== null + ? `S${String(t.season ?? 1).padStart(2, "0")}E${String(t.episode).padStart(2, "0")} · ${t.fileName}` + : t.fileName; + +type Row = SubtitleParsedItem & { target: string }; + +/** 同一后缀的语言(写出来是同一个文件名):双语按中文,与整理规则一致 */ +const suffixGroup = (l: SubtitleLanguage) => (l.startsWith("zh-Hant") ? "zh-tw" : l.startsWith("zh") ? "zh-cn" : l); + +export function SubtitleDialog({ + entityId, + title, + open, + onOpenChange, +}: { + entityId: string; + title: string; + open: boolean; + onOpenChange: (o: boolean) => void; +}) { + const inputId = useId(); + const input = useRef(null); + const targets = useSubtitleTargets(entityId, open); + const writes = useSubtitleWrites(entityId, open); + const parse = useParseSubtitles(); + const apply = useApplySubtitles(); + const undo = useUndoSubtitle(entityId); + const [rows, setRows] = useState([]); + const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]); + const [dragging, setDragging] = useState(false); + const list = targets.data?.targets ?? []; + const busy = parse.isPending || apply.isPending; + + const load = async (files: File[]) => { + if (!files.length) return; + try { + const r = await parse.mutateAsync({ entityId, files }); + setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]); + setSkipped((prev) => [...prev, ...r.skipped]); + } catch (err) { + toast.error(errorMessage(err, "解析字幕失败")); + } + }; + const patch = (id: string, p: Partial) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r))); + const chosen = rows.filter((r) => r.target !== SKIP); + // 同一个视频选了两份同语言字幕:写入时后一份会被拒绝,先提示 + const dup = useMemo(() => { + const seen = new Set(); + const out = new Set(); + for (const r of chosen) { + const k = `${r.target}|${r.language}|${r.ext}`; + if (seen.has(k)) out.add(r.id); + seen.add(k); + } + return out; + }, [chosen]); + + const submit = async () => { + try { + const r = await apply.mutateAsync({ + entityId, + source: "upload", + items: chosen.map((x) => ({ + name: x.name, + ext: x.ext, + language: x.language, + content: x.content, + targetLocationId: x.target, + })), + }); + if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`); + if (r.failed.length) toast.error(r.failed.map((f) => `${f.name}:${f.reason}`).join("\n")); + setRows((rs) => rs.filter((x) => r.failed.some((f) => f.name === x.name))); + setSkipped([]); + } catch (err) { + toast.error(errorMessage(err, "写入失败")); + } + }; + + return ( + !busy && onOpenChange(o)}> + + + 补字幕 + + {title}:字幕写到视频同目录,文件名是视频名加语言后缀;同名字幕会先备份,可以撤销。 + + + + {targets.isPending ? ( + + ) : targets.error ? ( + void targets.refetch()} /> + ) : !list.length ? ( + + ) : ( +
+ + + {rows.length ? ( +
    + {rows.map((r) => ( +
  • +
    +

    + {r.name.split("/").pop()} +

    +

    + {[ + r.archive ? `来自 ${r.archive}` : null, + r.episode !== null ? `识别为第 ${r.episode} 集` : null, + r.encoding !== "utf-8" ? `${r.encoding} 已转 UTF-8` : null, + ] + .filter(Boolean) + .join(" · ")} +

    + {dup.has(r.id) ?

    同一集已选了同语言的字幕

    : null} + {(() => { + const old = list + .find((t) => t.locationId === r.target) + ?.existing.find( + (x) => + suffixGroup(x.language) === suffixGroup(r.language) && x.fileName.endsWith(`.${r.ext}`), + ); + return old ? ( +

    会覆盖已有的 {old.fileName}(先备份)

    + ) : null; + })()} +
    + + +
  • + ))} +
+ ) : null} + {skipped.length ? ( +

+ 跳过:{skipped.map((s) => `${s.name}(${s.reason})`).join("、")} +

+ ) : null} + + undo.mutate(w.id)} undoing={undo.isPending} /> +
+ )} + + + {rows.length ? ( + + ) : null} + + +
+
+ ); +} + +function History({ + items, + onUndo, + undoing, +}: { + items: SubtitleWriteView[]; + onUndo: (w: SubtitleWriteView) => void; + undoing: boolean; +}) { + if (!items.length) return null; + return ( +
+

补字幕记录

+
    + {items.slice(0, 30).map((w) => ( +
  • + + {w.fileName} + + + {SOURCE_LABEL[w.source]} · {relTime(w.createdAt)} + + {w.replaced && !w.undoneAt ? 覆盖了旧字幕 : null} + {w.undoneAt ? ( + 已撤销 + ) : ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 610cd021..f50663f6 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -14,6 +14,7 @@ import { ArrowLeft, CalendarClock, CalendarPlus, + Captions, ChevronRight, ExternalLink, FolderTree, @@ -88,6 +89,7 @@ import { useReleaseSearchLauncher } from "~/components/search/search-sections.ts import { EmbyBadge } from "~/components/search/work-card.tsx"; import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts"; import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; +import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { useUiPrefs } from "~/lib/ui-prefs.ts"; @@ -1104,6 +1106,7 @@ export default function LibraryDetailPage() { const entity = useEntityDetail(id); const [merging, setMerging] = useState(false); const [subscribing, setSubscribing] = useState(false); + const [subtitling, setSubtitling] = useState(false); const launcher = useReleaseSearchLauncher(); const ms = mediaServers.useList(); @@ -1193,6 +1196,12 @@ export default function LibraryDetailPage() { 合并作品 ) : null} + {e.status !== "merged" ? ( + + ) : null} @@ -1230,6 +1239,9 @@ export default function LibraryDetailPage() { onClose={() => setSubscribing(false)} /> ) : null} + {subtitling ? ( + !o && setSubtitling(false)} /> + ) : null} {launcher.dialog} ); diff --git a/deno.json b/deno.json index a5fe05de..60167ca4 100644 --- a/deno.json +++ b/deno.json @@ -30,6 +30,7 @@ "@xmldom/xmldom": "npm:@xmldom/xmldom@0.9.12", "cheerio": "npm:cheerio@1.2.0", "fast-xml-parser": "npm:fast-xml-parser@5.11.2", + "fflate": "npm:fflate@0.8.3", "hash-wasm": "npm:hash-wasm@4.12.0", "hono": "npm:hono@4.13.12", "hono/": "npm:/hono@4.13.12/", diff --git a/deno.lock b/deno.lock index caa183c2..57d9d8f5 100644 --- a/deno.lock +++ b/deno.lock @@ -30,6 +30,7 @@ "npm:clsx@^2.1.1": "2.1.1", "npm:date-fns@^4.4.0": "4.4.0", "npm:fast-xml-parser@5.11.2": "5.11.2", + "npm:fflate@0.8.3": "0.8.3", "npm:hash-wasm@4.12.0": "4.12.0", "npm:hono@4.13.12": "4.13.12", "npm:isbot@^5.2.2": "5.2.2", @@ -3114,6 +3115,9 @@ "web-streams-polyfill" ] }, + "fflate@0.8.3": { + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, "finalhandler@2.1.1": { "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dependencies": [ @@ -4634,6 +4638,7 @@ "npm:@xmldom/xmldom@0.9.12", "npm:cheerio@1.2.0", "npm:fast-xml-parser@5.11.2", + "npm:fflate@0.8.3", "npm:hash-wasm@4.12.0", "npm:hono@4.13.12", "npm:jpeg-js@0.4.4", diff --git a/docs/plan/README.md b/docs/plan/README.md index 43b08f7b..7a33a657 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -29,6 +29,10 @@ - `BrowserService`(`apps/server/src/modules/browser/service.ts`,约 2300 行)仍是单个类。纯函数已移到 `session-view.ts`;Cloudflare 挑战流程(`beginChallenge`~`fetchSiteHtml`,约 400 行)要拆成独立协作对象,需要先把它用到的私有方法改成公开接口,下次改挑战流程时做。 - 超长函数:`planImport`(`organize-plan.ts`,约 830 行)、`finalizeRecord`(`organize-finalize.ts`,约 460 行)、`OrganizeWizard` 组件(约 800 行)。文件已按阶段拆开,函数内部等改到相关逻辑时再拆,不单独重构。 +### 进行中 + +- 补字幕:[subtitles.md](subtitles.md),第 1 步(手动上传)已完成,下一步字幕站(SubHD、字幕库)在线搜索。 + ### 业务(需要真实环境或决定) - R06 通用 HTTP 失败转浏览器:要先定"哪些失败转浏览器、每站每小时上限、失败后怎么提示",再实现;真实 Cloudflare 挑战需要一个会触发挑战的站点账号验收。 diff --git a/docs/plan/subtitles.md b/docs/plan/subtitles.md new file mode 100644 index 00000000..23afa4ad --- /dev/null +++ b/docs/plan/subtitles.md @@ -0,0 +1,36 @@ +# 补字幕(2026-10-03 定) + +参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。 + +## 拆分 + +| 层面 | 放在哪 | +| --- | --- | +| 字幕站的登记、代理、限流、健康、浏览器会话 | 复用站点体系:新站点类型「字幕站」(`site.kind = subtitle`),站点页单列一组,添加站点时选「字幕站」 | +| 解析、配对、写入、历史、在线搜索与下载 | 新模块 `subtitles`(server)+ `packages/domain/src/subtitles`(纯函数)+ `packages/adapters/src/subtitles`(SubHD / 字幕库) | +| 入口 | 作品页「补字幕」:上传 / 在线搜索 → 预览配对 → 写入;同页看历史、撤销 | + +## 写入规则(用户确认) + +- 写在视频同目录,`视频主干名 + 语言后缀 + 扩展名`;语言后缀沿用整理规则 `sidecarLanguageSuffix`(`.chi.zh-cn` / `.zh-tw` / `.eng` / `.ja` / `.ko`)。 +- 同名字幕已存在:先备份到数据目录(不在媒体库里留 `.bak`),再临时文件 + 改名原子写入。 +- 写入后登记为字幕资产(`media_asset.kind = subtitle`,位置 `sidecar_of_location_id` 指向视频),记一条历史;撤销 = 删掉写入的文件,有备份就还原。 +- 只写进作品在媒体库里的视频所在目录(媒体库根目录已启用);字幕和压缩包内容走 JSON(base64),服务端不留临时文件。 + +## 解析与配对(对照插件 `matching/subtitle_language.py`、`upload/upload_session.py`) + +- 支持 `.srt .ass .ssa .vtt .sub .sbv` 和 `.zip`(rar / 7z 先不做,插件也要依赖容器里的 unar)。 +- 语言:先看文件名标签(chs / cht / 简体 / 繁體 / eng / 双语 …),再看内容(CJK、假名、谚文、繁体常用字比例);中英双语按简中处理(与整理规则一致)。 +- 季集:用文件名解析(复用资源名解析器);电影只有一个目标。对不上的留空让人选;同一集多个字幕按语言区分,同语言重复的提示。 + +## 在线(SubHD、字幕库) + +- 搜索词:作品中文名、原名、年份,剧集带季号;结果按标题与季集匹配度排序,对不上的不列。 +- 验证码三层兜底:① 代码识别(SubHD SVG 路径长度表、字幕库 BMP 采样模板,插件里各 80 行左右);② 浏览器服务打开页面(与 PT 站过人机验证同一流程,可人工接管);③ 智能体视觉处理页面挑战。 +- 下载结果走与上传相同的解析 → 预览 → 写入。 + +## 分步 + +1. 手动上传:解析、配对预览、写入、历史与撤销、作品页入口。(v0.3.22 完成) +2. 字幕站类型 + SubHD / 字幕库搜索下载(第 ① 层验证码),站点页与添加站点。 +3. 验证码第 ② ③ 层兜底(浏览器、智能体)。 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 013f2106..59c04b4c 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -12,6 +12,7 @@ import { rulesApi } from "./api/rules.ts"; import { schedulesApi } from "./api/schedules.ts"; import { API_PREFIX, type EndpointDef } from "./api/shared.ts"; import { sourcesApi } from "./api/sources.ts"; +import { subtitlesApi } from "./api/subtitles.ts"; import { systemApi } from "./api/system.ts"; export type { EndpointAuth, EndpointDef, HttpMethod } from "./api/shared.ts"; @@ -42,6 +43,7 @@ export const Api = { ...libraryApi, ...browserApi, ...schedulesApi, + ...subtitlesApi, } as const; /** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */ diff --git a/packages/contracts/src/api/subtitles.ts b/packages/contracts/src/api/subtitles.ts new file mode 100644 index 00000000..eb81f600 --- /dev/null +++ b/packages/contracts/src/api/subtitles.ts @@ -0,0 +1,48 @@ +import { IdParam } from "../common.ts"; +import * as subtitles from "../subtitles.ts"; +import { ep } from "./shared.ts"; + +export const subtitlesApi = { + subtitles: { + targets: ep({ + method: "get", + path: "/subtitles/targets", + scope: "read", + summary: "作品可写字幕的视频(媒体库里的视频位置、季集、同目录已有字幕)", + query: subtitles.SubtitleTargetsQuery, + response: subtitles.SubtitleTargetsResponse, + }), + parse: ep({ + method: "post", + path: "/subtitles/parse", + scope: "read", + summary: "解析上传的字幕 / zip:编码转 UTF-8、识别语言与季集,给出配对建议(不写文件)", + body: subtitles.SubtitleParseRequest, + response: subtitles.SubtitleParseResponse, + }), + apply: ep({ + method: "post", + path: "/subtitles/apply", + scope: "organize.execute", + summary: "写入字幕:视频同目录、视频主干名 + 语言后缀;同名字幕先备份;登记为字幕资产并记历史", + body: subtitles.SubtitleApplyRequest, + response: subtitles.SubtitleApplyResult, + }), + writes: ep({ + method: "get", + path: "/subtitles/writes", + scope: "read", + summary: "作品的补字幕历史", + query: subtitles.SubtitleWritesQuery, + response: subtitles.SubtitleWritesResponse, + }), + undo: ep({ + method: "post", + path: "/subtitles/writes/{id}/undo", + scope: "organize.execute", + summary: "撤销一次写入:删掉写入的字幕,覆盖过的还原备份", + params: IdParam, + response: subtitles.SubtitleWriteView, + }), + }, +} as const; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ae447b31..4ace6da3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -27,3 +27,4 @@ export * from "./redact.ts"; export * from "./scrape.ts"; export * from "./settings.ts"; export * from "./sources.ts"; +export * from "./subtitles.ts"; diff --git a/packages/contracts/src/subtitles.ts b/packages/contracts/src/subtitles.ts new file mode 100644 index 00000000..9fcc8125 --- /dev/null +++ b/packages/contracts/src/subtitles.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import { Id, Timestamp } from "./common.ts"; + +// 补字幕(docs/plan/subtitles.md):上传 / 在线下载 → 解析(语言、季集)→ 配对到视频 → 写到视频同目录 → 历史可撤销。 + +export const SubtitleLanguage = z.enum([ + "zh-Hans", + "zh-Hant", + "zh-Hans&en", + "zh-Hant&en", + "zh&ja", + "zh&ko", + "en", + "ja", + "ko", + "unknown", +]); +export type SubtitleLanguage = z.infer; +export const SubtitleExt = z.enum(["srt", "ass", "ssa", "vtt", "sub", "sbv"]); +export const SubtitleSource = z.enum(["upload", "subhd", "zimuku"]); +export type SubtitleSource = z.infer; + +/** 可写字幕的视频(作品在媒体库里的视频位置) */ +export const SubtitleTarget = z.object({ + locationId: Id, + season: z.number().int().nullable(), + episode: z.number().int().nullable(), + fileName: z.string(), + relativePath: z.string(), + libraryRootName: z.string(), + /** 同目录已有的同名字幕 */ + existing: z.array(z.object({ fileName: z.string(), language: SubtitleLanguage })), +}); +export type SubtitleTarget = z.infer; + +export const SubtitleTargetsQuery = z.object({ entityId: Id }); +export const SubtitleTargetsResponse = z.object({ + entityId: Id, + kind: z.enum(["movie", "tv"]), + targets: z.array(SubtitleTarget), +}); +export type SubtitleTargetsResponse = z.infer; + +export const SubtitleParseRequest = z.object({ + /** 字幕文件或 zip;内容 base64 */ + files: z + .array(z.object({ name: z.string().min(1).max(500), base64: z.string().max(40_000_000) })) + .min(1) + .max(200), + /** 给出时按这个作品的视频配对 */ + entityId: Id.optional(), +}); +export type SubtitleParseRequest = z.infer; + +export const SubtitleParsedItem = z.object({ + id: z.string(), + /** 文件名(压缩包里的带目录) */ + name: z.string(), + /** 来自哪个压缩包 */ + archive: z.string().nullable(), + ext: SubtitleExt, + language: SubtitleLanguage, + season: z.number().int().nullable(), + seasonInferred: z.boolean(), + episode: z.number().int().nullable(), + encoding: z.string(), + sizeBytes: z.number().int(), + /** 已转成 UTF-8 的内容(base64),写入时原样提交 */ + content: z.string(), + /** 建议的视频位置 */ + targetLocationId: Id.nullable(), +}); +export type SubtitleParsedItem = z.infer; + +export const SubtitleParseResponse = z.object({ + items: z.array(SubtitleParsedItem), + skipped: z.array(z.object({ name: z.string(), reason: z.string() })), +}); +export type SubtitleParseResponse = z.infer; + +export const SubtitleApplyRequest = z.object({ + entityId: Id, + source: SubtitleSource.default("upload"), + items: z + .array( + z.object({ + name: z.string().min(1).max(500), + ext: SubtitleExt, + language: SubtitleLanguage, + content: z.string().max(40_000_000), + targetLocationId: Id, + /** 在线来源的条目地址 */ + sourceRef: z.string().max(1000).optional(), + }), + ) + .min(1) + .max(200), +}); +export type SubtitleApplyRequest = z.input; + +export const SubtitleWriteView = z.object({ + id: Id, + mediaEntityId: Id, + videoLocationId: Id.nullable(), + fileName: z.string(), + relativePath: z.string(), + language: SubtitleLanguage, + source: SubtitleSource, + sourceName: z.string(), + /** 覆盖了同名字幕(撤销时还原) */ + replaced: z.boolean(), + createdAt: Timestamp, + undoneAt: Timestamp.nullable(), +}); +export type SubtitleWriteView = z.infer; + +export const SubtitleApplyResult = z.object({ + written: z.array(SubtitleWriteView), + failed: z.array(z.object({ name: z.string(), reason: z.string() })), +}); +export type SubtitleApplyResult = z.infer; + +export const SubtitleWritesQuery = z.object({ entityId: Id }); +export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) }); diff --git a/packages/db/migrations/0064_subtitle_write.ts b/packages/db/migrations/0064_subtitle_write.ts new file mode 100644 index 00000000..c178f9bb --- /dev/null +++ b/packages/db/migrations/0064_subtitle_write.ts @@ -0,0 +1,32 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 补字幕(docs/plan/subtitles.md):每次写入一个字幕文件记一行,撤销时删文件、有备份就还原。 +// 写入的字幕同时登记为 media_asset(kind = subtitle)+ asset_location(sidecar_of_location_id 指向视频位置)。 +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `create table if not exists subtitle_write ( + id uuid primary key default gen_random_uuid(), + media_entity_id uuid not null references media_entity (id) on delete cascade, + video_location_id uuid references asset_location (id) on delete set null, + subtitle_asset_id uuid references media_asset (id) on delete set null, + library_root_id uuid not null references library_root (id) on delete cascade, + relative_path text not null, + language text not null, + source text not null check (source in ('upload', 'subhd', 'zimuku')), + source_name text not null, + source_ref text, + backup_path text, + actor text, + created_at timestamptz not null default now(), + undone_at timestamptz + ); + create index if not exists subtitle_write_entity on subtitle_write (media_entity_id, created_at desc);`, + ); +} +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, "drop table if exists subtitle_write;"); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index ced92dde..c7f1dccc 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -257,6 +257,7 @@ export type SubscriptionSeasonScope = "current_season" | "following_seasons"; export type SubscriptionOrigin = "user" | "agent" | "policy" | "import"; export type SubscriptionPriority = "immediate" | "tracking" | "new_work" | "upgrade"; export type SubscriptionStatus = "active" | "paused" | "pending" | "completed" | "cancelled"; +export type SubtitleWriteSource = "upload" | "subhd" | "zimuku"; export type TaskStatus = "pending" | "running" | "waiting" | "succeeded" | "failed" | "cancelled" | "dead"; export type TasksOpLedgerStatus = "started" | "succeeded"; export type TitleAliasKind = "original" | "translated" | "short" | "site_title" | "romanized" | "other"; @@ -1603,6 +1604,23 @@ export interface SubscriptionHistoryTable { updated_at: GeneratedTimestamptz; } +export interface SubtitleWriteTable { + id: Generated; + media_entity_id: string; + video_location_id: string | null; + subtitle_asset_id: string | null; + library_root_id: string; + relative_path: string; + language: string; + source: SubtitleWriteSource; + source_name: string; + source_ref: string | null; + backup_path: string | null; + actor: string | null; + created_at: GeneratedTimestamptz; + undone_at: Timestamptz | null; +} + export interface TaskTable { id: Generated; kind: string; @@ -1781,6 +1799,7 @@ export interface DB { sse_event: SseEventTable; subscription: SubscriptionTable; subscription_history: SubscriptionHistoryTable; + subtitle_write: SubtitleWriteTable; task: TaskTable; tasks_fact: TasksFactTable; tasks_op_ledger: TasksOpLedgerTable; @@ -2021,6 +2040,9 @@ export type SubscriptionUpdate = Updateable; export type SubscriptionHistoryRow = Selectable; export type NewSubscriptionHistory = Insertable; export type SubscriptionHistoryUpdate = Updateable; +export type SubtitleWriteRow = Selectable; +export type NewSubtitleWrite = Insertable; +export type SubtitleWriteUpdate = Updateable; export type TaskRow = Selectable; export type NewTask = Insertable; export type TaskUpdate = Updateable; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 2fd236e2..6d7e68f3 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -14,3 +14,4 @@ export * from "./release-parser/index.ts"; export * from "./rules/index.ts"; export * from "./scrape/index.ts"; export * from "./slot/index.ts"; +export * from "./subtitles/index.ts"; diff --git a/packages/domain/src/subtitles/index.ts b/packages/domain/src/subtitles/index.ts new file mode 100644 index 00000000..4e0ba6e2 --- /dev/null +++ b/packages/domain/src/subtitles/index.ts @@ -0,0 +1,195 @@ +/** + * 补字幕的纯函数(docs/plan/subtitles.md):字幕文件识别、编码、语言、季集,配对到视频,目标文件名。 + * 语言规则对照 MoviePilot 插件「海拉鲁字幕大师」matching/subtitle_language.py;后缀沿用整理规则 sidecarLanguageSuffix。 + */ +import { + basename, + type DefaultSubtitleLang, + sidecarLanguageSuffix, + sidecarStem, + splitExt, +} from "../organize/sidecars.ts"; +import { parseRelease } from "../release-parser/index.ts"; + +export const SUBTITLE_EXTS = ["srt", "ass", "ssa", "vtt", "sub", "sbv"] as const; +export type SubtitleExt = (typeof SUBTITLE_EXTS)[number]; + +export function subtitleExtOf(name: string): SubtitleExt | null { + const ext = splitExt(basename(name))[1].toLowerCase(); + return (SUBTITLE_EXTS as readonly string[]).includes(ext) ? (ext as SubtitleExt) : null; +} + +// ── 编码 ── + +const bad = (s: string) => (s.match(/\uFFFD/g) ?? []).length; + +/** 字幕文本解码:BOM → UTF-8;UTF-8 有乱码再试 GB18030 / Big5,取替换字符最少的 */ +export function decodeSubtitle(bytes: Uint8Array): { text: string; encoding: string } { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) + return { text: new TextDecoder("utf-8").decode(bytes.subarray(3)), encoding: "utf-8" }; + if (bytes[0] === 0xff && bytes[1] === 0xfe) + return { text: new TextDecoder("utf-16le").decode(bytes.subarray(2)), encoding: "utf-16le" }; + if (bytes[0] === 0xfe && bytes[1] === 0xff) + return { text: new TextDecoder("utf-16be").decode(bytes.subarray(2)), encoding: "utf-16be" }; + const utf8 = new TextDecoder("utf-8").decode(bytes); + if (!bad(utf8)) return { text: utf8, encoding: "utf-8" }; + let best = { text: utf8, encoding: "utf-8" }; + for (const enc of ["gb18030", "big5"]) { + try { + const t = new TextDecoder(enc).decode(bytes); + if (bad(t) < bad(best.text)) best = { text: t, encoding: enc }; + } catch { + // 运行时不支持该编码:跳过 + } + } + return best; +} + +// ── 语言 ── + +export type SubtitleLanguage = + | "zh-Hans" + | "zh-Hant" + | "zh-Hans&en" + | "zh-Hant&en" + | "zh&ja" + | "zh&ko" + | "en" + | "ja" + | "ko" + | "unknown"; + +export const SUBTITLE_LANGUAGE_LABEL: Record = { + "zh-Hans": "简中", + "zh-Hant": "繁中", + "zh-Hans&en": "简英双语", + "zh-Hant&en": "繁英双语", + "zh&ja": "中日双语", + "zh&ko": "中韩双语", + en: "英文", + ja: "日文", + ko: "韩文", + unknown: "未知", +}; + +/** 语言 → 整理规则的语言标签(sidecarLanguageSuffix 的输入);双语按中文处理,与整理规则一致 */ +export const SUBTITLE_LANGUAGE_TAGS: Record = { + "zh-Hans": ["chs"], + "zh-Hant": ["cht"], + "zh-Hans&en": ["chs"], + "zh-Hant&en": ["cht"], + "zh&ja": ["chs"], + "zh&ko": ["chs"], + en: ["eng"], + ja: ["jpn"], + ko: ["kor"], + unknown: [], +}; + +// 繁体常用字(简繁不同形);占中文字符比例高于阈值判繁体 +const TRAD = + /[們這個說時會來對後們裡與麼還沒讓從無過開關問題點學實現發見頭聽話體愛應經義東車長門國電腦網買賣歡寫讀樣種邊]/g; +const SIMP = + /[们这个说时会来对后里与么还没让从无过开关问题点学实现发见头听话体爱应经义东车长门国电脑网买卖欢写读样种边]/g; + +/** 对白文本:去掉 ASS 头部 / 样式与覆盖标签、SRT 序号与时间轴 */ +function dialogueText(text: string): string { + const lines = text.split(/\r?\n/); + const out: string[] = []; + for (const l of lines) { + if (/^Dialogue:/i.test(l)) + out.push( + l + .split(",") + .slice(9) + .join(",") + .replace(/\{[^}]*\}/g, "") + .replace(/\\N/gi, " "), + ); + else if (!/^\s*(\d+|\d\d:\d\d:\d\d[,.]\d+\s*-->.*|\[.*\]|[A-Za-z]+:.*)\s*$/.test(l)) + out.push(l.replace(/<[^>]+>/g, "")); + } + return out.join("\n").slice(0, 200_000); +} + +/** 先看文件名标签,再看内容;对照插件 detect_language_profile */ +export function detectSubtitleLanguage(fileName: string, text: string): SubtitleLanguage { + const n = basename(fileName).toLowerCase(); + const tok = (re: RegExp) => re.test(n); + const nameTrad = tok(/(^|[\s._\-[\]()&])(cht|tc|big5|zh-tw|zh-hk|zh-hant|繁体|繁體|繁中)(?=$|[\s._\-[\]()&])|繁/); + const nameSimp = tok(/(^|[\s._\-[\]()&])(chs|sc|gb|zh-cn|zh-hans|chi|zho|zh|简体|简中)(?=$|[\s._\-[\]()&])|简/); + const nameEng = tok(/(^|[\s._\-[\]()&])(eng|en|english)(?=$|[\s._\-[\]()&])|英/); + const nameJpn = tok(/(^|[\s._\-[\]()&])(jpn|jp|ja|japanese)(?=$|[\s._\-[\]()&])|日/); + const nameKor = tok(/(^|[\s._\-[\]()&])(kor|ko|korean)(?=$|[\s._\-[\]()&])|韩|韓/); + const body = dialogueText(text); + const cjk = (body.match(/[\u4e00-\u9fff]/g) ?? []).length; + const kana = (body.match(/[\u3040-\u30ff]/g) ?? []).length; + const hangul = (body.match(/[\uac00-\ud7af]/g) ?? []).length; + const latinWords = (body.match(/\b[A-Za-z]{3,}\b/g) ?? []).length; + const trad = (body.match(TRAD) ?? []).length; + const simp = (body.match(SIMP) ?? []).length; + const hasZh = cjk > 20 || nameTrad || nameSimp; + const zhTrad = nameTrad || (!nameSimp && trad > simp * 1.5 && trad > 5); + const zh: "zh-Hans" | "zh-Hant" = zhTrad ? "zh-Hant" : "zh-Hans"; + if (hasZh && (nameJpn || kana > 50)) return "zh&ja"; + if (hasZh && (nameKor || hangul > 50)) return "zh&ko"; + if (hasZh && (nameEng || (cjk > 20 && latinWords > cjk / 3))) return zh === "zh-Hant" ? "zh-Hant&en" : "zh-Hans&en"; + if (hasZh) return zh; + if (nameJpn || kana > 50) return "ja"; + if (nameKor || hangul > 50) return "ko"; + if (nameEng || latinWords > 50) return "en"; + return "unknown"; +} + +// ── 季集 ── + +export interface SubtitleEpisode { + season: number | null; + /** 季号是推断的(文件名没写 S01 / 第 1 季) */ + seasonInferred: boolean; + episode: number | null; +} + +export function subtitleEpisode(fileName: string): SubtitleEpisode { + const p = parseRelease(splitExt(basename(fileName))[0]); + const ep = p.episodes && p.episodes.start === p.episodes.end ? p.episodes.start : null; + return { season: p.season ?? null, seasonInferred: !!p.seasonInferred || p.season === undefined, episode: ep }; +} + +// ── 配对 ── + +export interface SubtitleTarget { + /** 视频位置 ID */ + id: string; + season: number | null; + episode: number | null; +} + +/** + * 字幕 → 视频:电影(只有一个目标)全部给它;剧集按季 + 集,季号是推断的只按集号且该集号唯一时才配;配不上的为 null。 + */ +export function pairSubtitle(ep: SubtitleEpisode, targets: readonly SubtitleTarget[]): string | null { + if (targets.length === 1 && targets[0]!.episode === null) return targets[0]!.id; + if (ep.episode === null) return targets.length === 1 ? targets[0]!.id : null; + const sameEp = targets.filter((t) => t.episode === ep.episode); + if (!ep.seasonInferred && ep.season !== null) return sameEp.find((t) => t.season === ep.season)?.id ?? null; + return sameEp.length === 1 ? sameEp[0]!.id : null; +} + +// ── 目标文件名 ── + +/** 视频文件名(相对路径或文件名)→ 同目录字幕名:主干 + 语言后缀 + 扩展名 */ +export function subtitleFileName( + videoName: string, + language: SubtitleLanguage, + ext: SubtitleExt, + defaultLang?: DefaultSubtitleLang, +): string { + const stem = splitExt(basename(videoName))[0]; + return `${stem}${sidecarLanguageSuffix(SUBTITLE_LANGUAGE_TAGS[language], defaultLang)}.${ext}`; +} + +/** 已有的同目录字幕是否属于这个视频(主干相同) */ +export function isSidecarOf(subtitleName: string, videoName: string): boolean { + return sidecarStem(subtitleName).stem === splitExt(basename(videoName))[0].toLowerCase(); +} diff --git a/packages/domain/test/subtitles.test.ts b/packages/domain/test/subtitles.test.ts new file mode 100644 index 00000000..e122af82 --- /dev/null +++ b/packages/domain/test/subtitles.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + decodeSubtitle, + detectSubtitleLanguage, + isSidecarOf, + pairSubtitle, + subtitleEpisode, + subtitleExtOf, + subtitleFileName, +} from "../src/index.ts"; + +const srt = (lines: string[]) => + lines.map((l, i) => `${i + 1}\n00:00:0${i},000 --> 00:00:0${i},500\n${l}\n`).join("\n"); + +describe("补字幕:识别", () => { + it("扩展名", () => { + expect(subtitleExtOf("a.ASS")).toBe("ass"); + expect(subtitleExtOf("a.mkv")).toBeNull(); + }); + + it("编码:UTF-8 BOM、GB18030", () => { + expect(decodeSubtitle(new Uint8Array([0xef, 0xbb, 0xbf, 0x61])).text).toBe("a"); + // 「你好」的 GB18030 编码 + const gb = new Uint8Array([0xc4, 0xe3, 0xba, 0xc3]); + expect(decodeSubtitle(gb)).toMatchObject({ text: "你好", encoding: "gb18030" }); + }); + + it("语言:文件名标签优先,其次内容;中英双语、繁体按字形", () => { + expect(detectSubtitleLanguage("Show.S01E01.cht.ass", "")).toBe("zh-Hant"); + expect(detectSubtitleLanguage("Show.S01E01.srt", srt(Array(30).fill("我们这个时候说的话")))).toBe("zh-Hans"); + expect(detectSubtitleLanguage("Show.S01E01.srt", srt(Array(30).fill("我們這個時候說的話")))).toBe("zh-Hant"); + expect( + detectSubtitleLanguage("Show.S01E01.srt", srt(Array(30).fill("我们这个时候 What are you doing over there"))), + ).toBe("zh-Hans&en"); + expect(detectSubtitleLanguage("Show.S01E01.srt", srt(Array(60).fill("What are you doing over there")))).toBe("en"); + expect(detectSubtitleLanguage("[SweetSub] Frieren [03][CHS&JPN].ass", "")).toBe("zh&ja"); + }); + + it("季集:SxxEyy、第N集;季号推断标记", () => { + expect(subtitleEpisode("Severance.S02E05.zh-cn.srt")).toEqual({ season: 2, seasonInferred: false, episode: 5 }); + expect(subtitleEpisode("第05集.srt")).toMatchObject({ episode: 5, seasonInferred: true }); + expect(subtitleEpisode("Dune.Part.Two.2024.chs.ass").episode).toBeNull(); + }); +}); + +describe("补字幕:配对与命名", () => { + const tv = [ + { id: "s1e1", season: 1, episode: 1 }, + { id: "s1e2", season: 1, episode: 2 }, + { id: "s2e1", season: 2, episode: 1 }, + ]; + it("明确季号按季 + 集;推断季号只在集号唯一时配;电影只有一个目标", () => { + expect(pairSubtitle({ season: 2, seasonInferred: false, episode: 1 }, tv)).toBe("s2e1"); + expect(pairSubtitle({ season: 1, seasonInferred: true, episode: 1 }, tv)).toBeNull(); + expect(pairSubtitle({ season: 1, seasonInferred: true, episode: 2 }, tv)).toBe("s1e2"); + expect( + pairSubtitle({ season: null, seasonInferred: true, episode: null }, [{ id: "m", season: null, episode: null }]), + ).toBe("m"); + }); + + it("目标文件名:视频主干 + 整理规则的语言后缀", () => { + expect(subtitleFileName("Season 1/Show - S01E01 - 1080p.mkv", "zh-Hans", "ass")).toBe( + "Show - S01E01 - 1080p.chi.zh-cn.ass", + ); + expect(subtitleFileName("Show.S01E01.mkv", "zh-Hant&en", "srt")).toBe("Show.S01E01.zh-tw.srt"); + expect(subtitleFileName("Movie (2024).mkv", "en", "srt", "en")).toBe("Movie (2024).eng.default.srt"); + expect(isSidecarOf("Show.S01E01.chi.zh-cn.ass", "Show.S01E01.mkv")).toBe(true); + }); +});