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

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