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