- TMDB:流行趋势(日 / 周)、电影 / 剧集榜单、discover 参数映射、分页搜索;列表类请求 TTL 6 小时 - 豆瓣 / bgm:列表与搜索请求经 MetaRequestCache 与限流,失败(403 / 风控页)不缓存 - contracts:发现页条目、分页、查询校验与筛选字典;storage.cacheTtl 追加 meta* 字段 - testkit:mock TMDB 加列表接口 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
478 lines
19 KiB
TypeScript
478 lines
19 KiB
TypeScript
import { z } from "zod";
|
||
import { Id, NonNegInt, Timestamp } from "./common.ts";
|
||
import { MediaCredit, MediaEntityStatus, MediaKind } from "./content.ts";
|
||
import { ManualAcquisitionRequest } from "./execution.ts";
|
||
import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts";
|
||
import { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
|
||
|
||
export { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
|
||
|
||
// 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页):
|
||
// GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。
|
||
// SEARCH-2 两阶段:先查作品并与库存对比(scope=all 不再查站点),需要时再按作品显式搜资源(站点可选);
|
||
// 仅 TMDB 的作品详情走 GET /media/tmdb/{type}/{id}。
|
||
|
||
export const SearchScope = z.enum(["all", "media", "releases", "actions"]);
|
||
export type SearchScope = z.infer<typeof SearchScope>;
|
||
|
||
export const SearchQuery = z.object({
|
||
q: z.string().trim().min(1).max(200),
|
||
/** all = 作品 + 操作(不查站点);资源只在 scope=releases 或 /search/releases 显式查询(最慢一站可达 15s) */
|
||
scope: SearchScope.default("all"),
|
||
/** 每组最多返回条数 */
|
||
limit: z.coerce.number().int().min(1).max(50).default(10),
|
||
});
|
||
export type SearchQuery = z.infer<typeof SearchQuery>;
|
||
|
||
// ── 本地作品 ──
|
||
|
||
export const LocalMediaHit = z.object({
|
||
entity: z.object({
|
||
id: Id,
|
||
kind: MediaKind,
|
||
title: z.string(),
|
||
originalTitle: z.string().nullable(),
|
||
year: z.number().int().nullable(),
|
||
status: MediaEntityStatus,
|
||
}),
|
||
/** 0–1;精确命中 1,名称前缀 ≥ 0.9,其余为 trgm 相似度 */
|
||
score: z.number(),
|
||
matchedBy: z.enum(["exact", "prefix", "similar"]),
|
||
/** 命中的别名原文(可能是译名/原名/别名) */
|
||
matchedAlias: z.string().nullable(),
|
||
/** 收录版本状态摘要 + 库存 */
|
||
slotCounts: SlotStatusCounts,
|
||
versionCount: NonNegInt,
|
||
downloadingCount: NonNegInt,
|
||
subscribed: z.boolean(),
|
||
});
|
||
export type LocalMediaHit = z.infer<typeof LocalMediaHit>;
|
||
|
||
export const TmdbCandidate = z.object({
|
||
provider: z.literal("tmdb"),
|
||
entityType: z.enum(["movie", "tv"]),
|
||
externalId: z.string(),
|
||
title: z.string(),
|
||
originalTitle: z.string().nullable(),
|
||
year: z.number().int().nullable(),
|
||
overview: z.string().nullable(),
|
||
popularity: z.number().nullable(),
|
||
/** TMDB poster_path;前端经图片代理显示海报 */
|
||
posterPath: z.string().nullable().optional(),
|
||
/** 已有本地作品接受了这个 TMDB 身份时给出(跟随合并跳转),前端显示"查看"而不是"订阅" */
|
||
localEntityId: Id.nullable(),
|
||
});
|
||
export type TmdbCandidate = z.infer<typeof TmdbCandidate>;
|
||
|
||
// ── 跨站资源 ──
|
||
|
||
export const SearchReleaseParsed = z.object({
|
||
titleCn: z.string().nullable(),
|
||
titleEn: z.string().nullable(),
|
||
year: z.number().int().nullable(),
|
||
season: z.number().int().nullable(),
|
||
/** 多季合集的最后一季(S01-S03 = 3);单季 / 电影为 null */
|
||
seasonEnd: z.number().int().nullable().optional(),
|
||
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
|
||
resolution: z.string().nullable(),
|
||
source: z.string().nullable(),
|
||
videoCodec: z.string().nullable(),
|
||
hdr: z.array(z.string()),
|
||
releaseGroup: z.string().nullable(),
|
||
/** 音轨(`TrueHD`、`Atmos 7.1`…,SEARCH-4 快速筛选用);空 = 标题没写 */
|
||
audio: z.array(z.string()).optional(),
|
||
/** 字幕语言 zh / zh-Hans / zh-Hant / en / ja / ko;null = 标题没写 */
|
||
subtitles: z.array(z.string()).nullable().optional(),
|
||
/** 配音语言 cmn 国语 / yue 粤语 / en 英语;null = 标题没写 */
|
||
audioLangs: z.array(z.string()).nullable().optional(),
|
||
bitDepth: z.number().int().nullable().optional(),
|
||
fps: z.number().nullable().optional(),
|
||
/** 剪辑版(加长版、导演剪辑版…) */
|
||
cut: z.string().nullable().optional(),
|
||
});
|
||
export type SearchReleaseParsed = z.infer<typeof SearchReleaseParsed>;
|
||
|
||
/**
|
||
* 库存叠加状态(只在关联到作品时给出):
|
||
* downloading 有下载中任务 > gap 有未满足的收录版本 > owned 已有版本 > subscribed 只有订阅 > none 作品已登记但无库存/目标
|
||
*/
|
||
export const SearchInventoryState = z.enum(["downloading", "gap", "owned", "subscribed", "none"]);
|
||
export type SearchInventoryState = z.infer<typeof SearchInventoryState>;
|
||
|
||
export const SearchReleaseInventory = InventoryEntry.extend({
|
||
state: SearchInventoryState,
|
||
});
|
||
export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>;
|
||
|
||
/**
|
||
* 规则判定(只在关联到本地作品、且作品的收录版本配置了条件或规则组时给出):
|
||
* 按收录版本的评估口径(硬条件 + 规则组链 + 阶梯)判断这条资源,多个收录版本时取最好的一个。
|
||
*/
|
||
export const SearchRuleVerdict = z.object({
|
||
slotId: Id,
|
||
slotName: z.string(),
|
||
/** 作品共有几个收录版本参与判定;>1 时界面写出收录版本名 */
|
||
slotCount: z.number().int().positive(),
|
||
/** hit = 满足;miss = 不满足;unknown = 标题缺少判定需要的字段 */
|
||
outcome: z.enum(["hit", "miss", "unknown"]),
|
||
/** 命中的档位(1 起);没有分档或未命中为 null */
|
||
tier: z.number().int().positive().nullable(),
|
||
/** 档位名(规则组名·第 N 层 / 阶梯层名) */
|
||
tierName: z.string().nullable(),
|
||
/** 不满足(或缺字段)的维度:分辨率 / 来源 / 编码 / HDR / 字幕 / 体积 / 制作组 / 关键字 / 规则组…,去重 */
|
||
failed: z.array(z.string()),
|
||
/** 逐条说明(悬停提示) */
|
||
details: z.array(z.string()),
|
||
/**
|
||
* 与库里该收录版本当前版本相比:higher / same / lower;steps = 相差几档(按档位比较时给出)。
|
||
* 没有当前版本或无法比较时为 null。
|
||
*/
|
||
vsLibrary: z
|
||
.object({ cmp: z.enum(["higher", "same", "lower"]), steps: z.number().int().nonnegative().nullable() })
|
||
.nullable(),
|
||
});
|
||
export type SearchRuleVerdict = z.infer<typeof SearchRuleVerdict>;
|
||
|
||
export const SearchRelease = z.object({
|
||
/** `${siteId}:${siteReleaseId}`,前端列表 key */
|
||
key: z.string(),
|
||
/** 结果已写入资源表(source_release)的 ID;站点工作台 / 下载 / 委托 Agent 用它 */
|
||
sourceReleaseId: Id.nullable(),
|
||
siteId: Id,
|
||
siteName: z.string(),
|
||
siteReleaseId: z.string(),
|
||
title: z.string(),
|
||
subtitle: z.string().nullable(),
|
||
category: z.object({ id: z.string().nullable(), name: z.string().nullable() }),
|
||
sizeBytes: NonNegInt.nullable(),
|
||
seeders: z.number().int().nullable(),
|
||
leechers: z.number().int().nullable(),
|
||
snatched: z.number().int().nullable(),
|
||
promotion: z.object({
|
||
type: z.string(),
|
||
downloadFactor: z.number().nullable(),
|
||
uploadFactor: z.number().nullable(),
|
||
expiresAt: Timestamp.nullable(),
|
||
}),
|
||
/** 下载计量为 0(免费 / 2x 免费) */
|
||
free: z.boolean(),
|
||
/** H&R 三态;unknown 在清理判定中按有义务处理 */
|
||
hr: z.union([z.boolean(), z.literal("unknown")]),
|
||
uploadedAt: Timestamp.nullable(),
|
||
/** 不含凭证的详情页 */
|
||
detailUrl: z.string(),
|
||
observedAt: Timestamp,
|
||
parsed: SearchReleaseParsed,
|
||
mediaEntityId: Id.nullable(),
|
||
/** resolved = 资源已识别到作品;alias = 解析出的标题精确命中唯一作品别名(未确认) */
|
||
entityMatch: z.enum(["resolved", "alias"]).nullable(),
|
||
entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(),
|
||
/** 库存叠加;candidateReasons 只保留针对本条资源的评估 */
|
||
inventory: SearchReleaseInventory.nullable(),
|
||
/** 与本地作品收录规则的关系;没关联作品或没有规则时为 null */
|
||
ruleVerdict: SearchRuleVerdict.nullable(),
|
||
});
|
||
export type SearchRelease = z.infer<typeof SearchRelease>;
|
||
|
||
export const SearchSiteStatus = z.enum(["ok", "failed", "timeout", "skipped"]);
|
||
export type SearchSiteStatus = z.infer<typeof SearchSiteStatus>;
|
||
|
||
export const SearchSiteCoverage = z.object({
|
||
siteId: Id,
|
||
siteName: z.string(),
|
||
status: SearchSiteStatus,
|
||
count: NonNegInt,
|
||
tookMs: NonNegInt,
|
||
/** 失败/超时/跳过原因(已脱敏) */
|
||
error: z.string().nullable(),
|
||
});
|
||
export type SearchSiteCoverage = z.infer<typeof SearchSiteCoverage>;
|
||
|
||
/** 跨站搜索的覆盖信息:部分站点失败不影响其它站点结果 */
|
||
export const SearchReleaseCoverage = z.object({
|
||
/** 有站点失败/超时 */
|
||
partial: z.boolean(),
|
||
sites: z.array(SearchSiteCoverage),
|
||
failedSites: z.array(SearchSiteCoverage),
|
||
/** 命中 30s 短缓存 */
|
||
cached: z.boolean(),
|
||
/** 与同时进行的相同搜索合并(没有重复请求站点) */
|
||
coalesced: z.boolean(),
|
||
/** 站点结果抓取时间 */
|
||
fetchedAt: Timestamp,
|
||
});
|
||
export type SearchReleaseCoverage = z.infer<typeof SearchReleaseCoverage>;
|
||
|
||
// ── 页面与操作跳转(命令面板) ──
|
||
|
||
export const SearchAction = z.object({
|
||
id: z.string(),
|
||
kind: z.enum(["page", "action"]),
|
||
/** 分组(导航一级分类):概览 / 资源 / 订阅 / 整理 / 系统 */
|
||
group: z.string(),
|
||
title: z.string(),
|
||
/** 控制台内相对路径 */
|
||
href: z.string(),
|
||
keywords: z.array(z.string()),
|
||
});
|
||
export type SearchAction = z.infer<typeof SearchAction>;
|
||
|
||
export const SearchGroupBase = z.object({ tookMs: NonNegInt });
|
||
|
||
// ── 作品(本地 + TMDB 合并去重,本地优先)与库存对比 ──
|
||
|
||
/**
|
||
* 库内状态:not_collected 未收录(本地没有这部作品)> tracked 已建档但没有入库版本 > owned 已有版本 > downloading 正在下载。
|
||
* 只看本地账本;Emby 另查 GET /inventory/media-servers(未知 ≠ 缺失)。
|
||
*/
|
||
export const WorkInventoryState = z.enum(["not_collected", "tracked", "owned", "downloading"]);
|
||
export type WorkInventoryState = z.infer<typeof WorkInventoryState>;
|
||
|
||
export const WorkInventory = z.object({
|
||
state: WorkInventoryState,
|
||
subscribed: z.boolean(),
|
||
/** 已入库版本数(主视频资产) */
|
||
versionCount: NonNegInt,
|
||
downloadingCount: NonNegInt,
|
||
/** 收录版本:总数 / 已满足 */
|
||
slotTotal: NonNegInt,
|
||
slotSatisfied: NonNegInt,
|
||
/** 未满足的收录版本(empty / attention / upgrade_pending) */
|
||
missing: z.array(z.object({ slotId: Id, name: z.string() })),
|
||
});
|
||
export type WorkInventory = z.infer<typeof WorkInventory>;
|
||
|
||
export const SearchWork = z.object({
|
||
/** local:<作品 ID> 或 tmdb:<movie|tv>:<TMDB ID> */
|
||
key: z.string(),
|
||
source: z.enum(["local", "tmdb"]),
|
||
kind: MediaKind,
|
||
title: z.string(),
|
||
originalTitle: z.string().nullable(),
|
||
year: z.number().int().nullable(),
|
||
/** 简介摘要(≤ 300 字) */
|
||
overview: z.string().nullable(),
|
||
posterPath: z.string().nullable(),
|
||
/** 本地作品 ID(合并过的跟到最终作品);null = 未收录 */
|
||
localEntityId: Id.nullable(),
|
||
tmdb: z.object({ entityType: z.enum(["movie", "tv"]), externalId: z.string() }).nullable(),
|
||
inventory: WorkInventory,
|
||
});
|
||
export type SearchWork = z.infer<typeof SearchWork>;
|
||
|
||
export const SearchResponse = z.object({
|
||
q: z.string(),
|
||
scope: SearchScope,
|
||
media: z
|
||
.object({
|
||
local: SearchGroupBase.extend({ items: z.array(LocalMediaHit) }),
|
||
tmdb: SearchGroupBase.extend({
|
||
items: z.array(TmdbCandidate),
|
||
/** 未配置 TMDB 时为 false(不是"没有结果") */
|
||
configured: z.boolean(),
|
||
error: z.string().nullable(),
|
||
}),
|
||
/** 本地 + TMDB 合并去重(同一 TMDB 身份只出现一次,本地在前),带库存对比 */
|
||
works: z.array(SearchWork),
|
||
})
|
||
.nullable(),
|
||
releases: SearchGroupBase.extend({
|
||
items: z.array(SearchRelease),
|
||
/** 全部站点结果条数(items 只取前 limit 条,完整列表走 /search/releases) */
|
||
total: NonNegInt,
|
||
})
|
||
.extend(SearchReleaseCoverage.shape)
|
||
.nullable(),
|
||
actions: SearchGroupBase.extend({ items: z.array(SearchAction) }).nullable(),
|
||
tookMs: NonNegInt,
|
||
});
|
||
export type SearchResponse = z.infer<typeof SearchResponse>;
|
||
|
||
// ── 资源结果页 ──
|
||
|
||
const csv = z.string().max(4000).optional();
|
||
const bool = z.enum(["true", "false"]).optional();
|
||
|
||
export const ReleaseSearchSort = z.enum(["default", "seeders", "size", "uploadedAt", "site"]);
|
||
export type ReleaseSearchSort = z.infer<typeof ReleaseSearchSort>;
|
||
|
||
export const ReleaseSearchQuery = z.object({
|
||
/** 关键词;按作品搜时可省略,由服务端按作品生成查询词 */
|
||
q: z.string().trim().min(1).max(200).optional(),
|
||
/** 按本地作品搜:用作品名 / 原名 / 年份生成查询词 */
|
||
entityId: Id.optional(),
|
||
/** 按 TMDB 条目搜:`movie:157336` / `tv:1399` */
|
||
tmdb: z
|
||
.string()
|
||
.regex(/^(movie|tv):\d{1,12}$/)
|
||
.optional(),
|
||
/** 逗号分隔的站点 ID:只查这些站点;省略时按 settings.search.defaultSiteMode */
|
||
sites: csv,
|
||
/** 逗号分隔的站点 ID:只筛选结果(仍查 sites 里的全部站点,分面计数不变) */
|
||
siteFilter: csv,
|
||
/** 逗号分隔的解析原值:2160p,1080p…(旧参数;快速筛选用 qf) */
|
||
resolution: csv,
|
||
/** 逗号分隔的解析原值:REMUX,WEB-DL,BluRay…(旧参数;快速筛选用 qf) */
|
||
source: csv,
|
||
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`,同维度 OR、不同维度 AND,见 ReleaseQuickFacets */
|
||
qf: QuickFilterParam,
|
||
/** 字节 */
|
||
minSize: z.coerce.number().int().nonnegative().optional(),
|
||
maxSize: z.coerce.number().int().nonnegative().optional(),
|
||
/** true 只要免费;false 排除免费 */
|
||
free: bool,
|
||
/** true 只要 H&R;false 排除明确 H&R 的(unknown 保留) */
|
||
hr: bool,
|
||
/** default = 做种数降序、同分按发布时间新→旧 */
|
||
sort: ReleaseSearchSort.default("default"),
|
||
order: z.enum(["asc", "desc"]).default("desc"),
|
||
/** true 跳过 30s 缓存重新查站点(仍会合并并发) */
|
||
refresh: bool,
|
||
cursor: z.string().min(1).optional(),
|
||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||
});
|
||
export type ReleaseSearchQuery = z.infer<typeof ReleaseSearchQuery>;
|
||
|
||
export const ReleaseSearchPage = z
|
||
.object({
|
||
q: z.string(),
|
||
/** 实际发给站点的查询词(按作品搜时由服务端生成,每个站点每个词查一次) */
|
||
queries: z.array(z.string()).optional(),
|
||
items: z.array(SearchRelease),
|
||
nextCursor: z.string().nullable(),
|
||
/** 筛选后的条数 */
|
||
total: NonNegInt,
|
||
/** 未筛选的全部条数(按作品搜时只算属于这部作品的) */
|
||
unfilteredTotal: NonNegInt,
|
||
/**
|
||
* 按作品搜(entityId / tmdb)时判定不属于这部作品的资源(对照 MP match_torrent:识别到别的作品,
|
||
* 或名字、类型、年份、季集对不上);不参与筛选与分面,只在第一页给出(最多 200 条)。关键词搜索没有这两项
|
||
*/
|
||
unmatched: z.array(SearchRelease).optional(),
|
||
unmatchedTotal: NonNegInt.optional(),
|
||
/**
|
||
* sites / resolutions / sources:旧的筛选项计数(基于未筛选的全部结果);
|
||
* 其余为快速筛选分面(SEARCH-3,在其它筛选条件之后、按其它维度的已选条件计数)
|
||
*/
|
||
facets: ReleaseQuickFacets.extend({
|
||
sites: z.array(FacetCount),
|
||
resolutions: z.array(FacetCount),
|
||
sources: z.array(FacetCount),
|
||
}),
|
||
tookMs: NonNegInt,
|
||
})
|
||
.extend(SearchReleaseCoverage.shape);
|
||
export type ReleaseSearchPage = z.infer<typeof ReleaseSearchPage>;
|
||
|
||
export const SearchReleaseParams = z.object({ siteId: Id, siteReleaseId: z.string().min(1).max(200) });
|
||
export type SearchReleaseParams = z.infer<typeof SearchReleaseParams>;
|
||
|
||
/** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */
|
||
export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true });
|
||
export type SearchReleasePlanRequest = z.infer<typeof SearchReleasePlanRequest>;
|
||
|
||
// ── 仅 TMDB 作品的详情(SEARCH-2) ──
|
||
|
||
export const TmdbMediaParams = z.object({
|
||
type: z.enum(["movie", "tv"]),
|
||
id: z.string().regex(/^\d{1,12}$/),
|
||
});
|
||
export type TmdbMediaParams = z.infer<typeof TmdbMediaParams>;
|
||
|
||
export const TmdbMediaQuery = z.object({
|
||
/** mediaServers = 同时转调 GET /inventory/media-servers(最慢约 8s) */
|
||
include: z.enum(["mediaServers"]).optional(),
|
||
});
|
||
export type TmdbMediaQuery = z.infer<typeof TmdbMediaQuery>;
|
||
|
||
export const TmdbMediaSeason = z.object({
|
||
seasonNumber: z.number().int(),
|
||
episodeCount: NonNegInt,
|
||
name: z.string().nullable(),
|
||
airDate: z.string().nullable(),
|
||
/** 本地已入库的集数(整季资产按全季计);未收录为 null */
|
||
localHave: NonNegInt.nullable(),
|
||
overview: z.string().nullable().optional(),
|
||
posterPath: z.string().nullable().optional(),
|
||
});
|
||
export type TmdbMediaSeason = z.infer<typeof TmdbMediaSeason>;
|
||
|
||
export const TmdbMediaDetail = z.object({
|
||
provider: z.literal("tmdb"),
|
||
entityType: z.enum(["movie", "tv"]),
|
||
externalId: z.string(),
|
||
kind: MediaKind,
|
||
title: z.string(),
|
||
originalTitle: z.string().nullable(),
|
||
originalLanguage: z.string().nullable(),
|
||
year: z.number().int().nullable(),
|
||
releaseDate: z.string().nullable(),
|
||
overview: z.string().nullable(),
|
||
posterPath: z.string().nullable(),
|
||
backdropPath: z.string().nullable(),
|
||
originCountry: z.array(z.string()),
|
||
/** 别名 / 译名(最多 20 个) */
|
||
aliases: z.array(z.string()),
|
||
externalIds: z.object({ imdb: z.string().nullable(), tvdb: z.string().nullable() }),
|
||
seasons: z.array(TmdbMediaSeason),
|
||
/** 已建档:本地作品 ID(跟随合并) */
|
||
localEntityId: Id.nullable(),
|
||
/** 本地库存对比;未收录时 state = not_collected */
|
||
inventory: WorkInventory,
|
||
/** include=mediaServers 时给出;未知 ≠ 缺失 */
|
||
mediaServers: MediaServerInventory.nullable(),
|
||
/** 搜资源时服务端会用的查询词 */
|
||
queries: z.array(z.string()),
|
||
// MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示)
|
||
/** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */
|
||
titleZh: z.string().nullable().optional(),
|
||
tagline: z.string().nullable().optional(),
|
||
genres: z.array(z.string()).optional(),
|
||
voteAverage: z.number().nullable().optional(),
|
||
voteCount: z.number().int().nullable().optional(),
|
||
/** TMDB status 原文(Returning Series / Ended / Released …) */
|
||
airStatus: z.string().nullable().optional(),
|
||
/** 分钟:电影片长;剧集为单集时长 */
|
||
runtime: z.number().int().nullable().optional(),
|
||
episodeCount: z.number().int().nullable().optional(),
|
||
lastAirDate: z.string().nullable().optional(),
|
||
platform: z.string().nullable().optional(),
|
||
cast: z.array(MediaCredit).optional(),
|
||
crew: z.array(MediaCredit).optional(),
|
||
});
|
||
export type TmdbMediaDetail = z.infer<typeof TmdbMediaDetail>;
|
||
|
||
/** 一集的 TMDB 信息(标题 / 简介按语言回退链,language 记实际取到的语言) */
|
||
export const MediaEpisodeInfo = z.object({
|
||
seasonNumber: z.number().int(),
|
||
episodeNumber: z.number().int(),
|
||
name: z.string().nullable(),
|
||
nameLanguage: z.string().nullable(),
|
||
overview: z.string().nullable(),
|
||
overviewLanguage: z.string().nullable(),
|
||
airDate: z.string().nullable(),
|
||
runtime: z.number().int().nullable(),
|
||
stillPath: z.string().nullable(),
|
||
voteAverage: z.number().nullable(),
|
||
voteCount: z.number().int().nullable(),
|
||
});
|
||
export type MediaEpisodeInfo = z.infer<typeof MediaEpisodeInfo>;
|
||
|
||
export const TmdbSeasonParams = z.object({
|
||
id: z.string().regex(/^\d{1,12}$/),
|
||
season: z.coerce.number().int().min(0).max(999),
|
||
});
|
||
export type TmdbSeasonParams = z.infer<typeof TmdbSeasonParams>;
|
||
|
||
/** GET /media/tmdb/tv/{id}/season/{season}:季详情与集列表(仅 TMDB 作品详情页按季展开时拉取) */
|
||
export const TmdbSeasonDetailView = z.object({
|
||
tvId: z.string(),
|
||
seasonNumber: z.number().int(),
|
||
name: z.string().nullable(),
|
||
overview: z.string().nullable(),
|
||
airDate: z.string().nullable(),
|
||
posterPath: z.string().nullable(),
|
||
episodes: z.array(MediaEpisodeInfo),
|
||
});
|
||
export type TmdbSeasonDetailView = z.infer<typeof TmdbSeasonDetailView>;
|