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; export const SubtitleExt = z.enum(["srt", "ass", "ssa", "vtt", "sub", "sbv"]); export const SubtitleSource = z.enum(["upload", "subhd", "zimuku"]); export type SubtitleSource = z.infer; /** 可写字幕的视频(作品在媒体库里的视频位置) */ 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; 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; 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; 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; 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; 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; 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; 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; /** 表 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; 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; 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; /** 下载一条在线字幕:搜索结果给 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;