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
@@ -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 的完整路径 */
+48
View File
@@ -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;
+1
View File
@@ -27,3 +27,4 @@ export * from "./redact.ts";
export * from "./scrape.ts";
export * from "./settings.ts";
export * from "./sources.ts";
export * from "./subtitles.ts";
+124
View File
@@ -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<typeof SubtitleLanguage>;
export const SubtitleExt = z.enum(["srt", "ass", "ssa", "vtt", "sub", "sbv"]);
export const SubtitleSource = z.enum(["upload", "subhd", "zimuku"]);
export type SubtitleSource = z.infer<typeof SubtitleSource>;
/** 可写字幕的视频(作品在媒体库里的视频位置) */
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<typeof SubtitleTarget>;
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<typeof SubtitleTargetsResponse>;
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<typeof SubtitleParseRequest>;
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<typeof SubtitleParsedItem>;
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<typeof SubtitleParseResponse>;
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<typeof SubtitleApplyRequest>;
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<typeof SubtitleWriteView>;
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<typeof SubtitleApplyResult>;
export const SubtitleWritesQuery = z.object({ entityId: Id });
export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) });
@@ -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<any>): Promise<void> {
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<any>): Promise<void> {
await execDdl(db, "drop table if exists subtitle_write;");
}
+22
View File
@@ -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<string>;
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<string>;
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<SubscriptionTable>;
export type SubscriptionHistoryRow = Selectable<SubscriptionHistoryTable>;
export type NewSubscriptionHistory = Insertable<SubscriptionHistoryTable>;
export type SubscriptionHistoryUpdate = Updateable<SubscriptionHistoryTable>;
export type SubtitleWriteRow = Selectable<SubtitleWriteTable>;
export type NewSubtitleWrite = Insertable<SubtitleWriteTable>;
export type SubtitleWriteUpdate = Updateable<SubtitleWriteTable>;
export type TaskRow = Selectable<TaskTable>;
export type NewTask = Insertable<TaskTable>;
export type TaskUpdate = Updateable<TaskTable>;
+1
View File
@@ -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";
+195
View File
@@ -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<SubtitleLanguage, string> = {
"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<SubtitleLanguage, string[]> = {
"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();
}
+69
View File
@@ -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);
});
});