Files
UHD_Media_Tool/packages/contracts/src/subtitles.ts
T

202 lines
6.6 KiB
TypeScript

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(),
/** 在线下载的:字幕站与条目地址(写入时记进历史) */
source: SubtitleSource.optional(),
sourceRef: z.string().optional(),
});
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(),
/** 这一项的来源(不给用整批的 source) */
source: SubtitleSource.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>;
/** 表 subtitle_write 的行(0064) */
export const SubtitleWriteRow = z.object({
id: Id,
mediaEntityId: Id,
videoLocationId: Id.nullable(),
subtitleAssetId: Id.nullable(),
libraryRootId: Id,
relativePath: z.string(),
language: z.string(),
source: SubtitleSource,
sourceName: z.string(),
sourceRef: z.string().nullable(),
backupPath: z.string().nullable(),
actor: z.string().nullable(),
createdAt: Timestamp,
undoneAt: Timestamp.nullable(),
});
export const SubtitleWritesQuery = z.object({ entityId: Id });
export const SubtitleWritesResponse = z.object({ items: z.array(SubtitleWriteView) });
// ── 在线(字幕站:SubHD、字幕库) ──
export const SubtitleSearchRequest = z.object({
entityId: Id,
/** 剧集:只要这一季(集)的字幕;不给 = 全部 */
season: z.number().int().min(0).optional(),
episode: z.number().int().min(0).optional(),
/** 额外的搜索词(作品名搜不到时手动填) */
keyword: z.string().trim().max(100).optional(),
});
export type SubtitleSearchRequest = z.infer<typeof SubtitleSearchRequest>;
export const SubtitleSearchResult = z.object({
siteId: Id,
siteKey: z.string(),
siteName: z.string(),
/** 站内引用(下载时原样提交) */
ref: z.string(),
title: z.string(),
pageUrl: z.string(),
languages: z.array(z.string()),
format: z.string().nullable(),
downloads: z.number().int().nullable(),
uploader: z.string().nullable(),
publishedAt: z.string().nullable(),
season: z.number().int().nullable(),
episode: z.number().int().nullable(),
score: z.number(),
});
export type SubtitleSearchResult = z.infer<typeof SubtitleSearchResult>;
export const SubtitleSearchResponse = z.object({
results: z.array(SubtitleSearchResult),
/** 用到的搜索词 */
keywords: z.array(z.string()),
/** 各站出错 / 需要人工处理 */
errors: z.array(z.object({ siteName: z.string(), reason: z.string() })),
/** 没有启用的字幕站 */
noSites: z.boolean(),
});
export type SubtitleSearchResponse = z.infer<typeof SubtitleSearchResponse>;
/** 下载一条在线字幕:搜索结果给 siteId + ref;原站视图里人工打开的字幕页给 sessionId(取当前页与浏览器里的 Cookie) */
export const SubtitleFetchRequest = z
.object({
entityId: Id,
siteId: Id.optional(),
ref: z.string().min(1).max(500).optional(),
sessionId: Id.optional(),
title: z.string().max(500).default(""),
})
.refine((v) => !!v.sessionId || (!!v.siteId && !!v.ref), { message: "给 sessionId,或 siteId 加 ref" });
export type SubtitleFetchRequest = z.input<typeof SubtitleFetchRequest>;