feat(subtitles): 补字幕第 1 步——上传字幕 / zip,识别语言与季集,配对写入视频同目录,历史与撤销
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
feee481915
commit
ef29bd62ce
22 files changed
+1554
-2
No files matched your search
@@ -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 的完整路径 */
|
||||
|
||||
@@ -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;
|
||||
@@ -27,3 +27,4 @@ export * from "./redact.ts";
|
||||
export * from "./scrape.ts";
|
||||
export * from "./settings.ts";
|
||||
export * from "./sources.ts";
|
||||
export * from "./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<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) });
|
||||
Reference in new issue
Block a user