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

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