Files
UHD_Media_Tool/packages/contracts/src/discover.ts
T
rootandClaude Opus 5.5 e46eb0e3c3 feat(adapters): TMDB 列表与筛选、豆瓣榜单 / 标签 / 筛选 / 搜索、bgm 浏览与趋势,豆瓣 / bgm 请求缓存端口
- 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
2026-10-06 18:04:43 +08:00

359 lines
12 KiB
TypeScript

import { z } from "zod";
// ── 发现(对照 MP 推荐 / 探索,docs/porting/moviepilot-discover.md) ──
// 来源:TMDB(流行趋势、热门、正在上映 / 播出、即将上映、高分、按条件筛选、搜索)、豆瓣(榜单、标签、筛选、搜索)、
// Bangumi(每日放送、热门趋势、排行 / 最新、搜索)。豆瓣 / bgm 条目点开时按名称 + 年份匹配 TMDB 作品。
// 下面的字典是界面筛选器与服务端校验共用的唯一来源。
export const DiscoverSource = z.enum(["tmdb", "douban", "bangumi"]);
export type DiscoverSource = z.infer<typeof DiscoverSource>;
/** TMDB 风格(genre id → 中文名,对照 MP TheMovieDbView 的字典;不另请求 /genre/list) */
export const TMDB_MOVIE_GENRES: Record<string, string> = {
"28": "动作",
"12": "冒险",
"16": "动画",
"35": "喜剧",
"80": "犯罪",
"99": "纪录",
"18": "剧情",
"10751": "家庭",
"14": "奇幻",
"36": "历史",
"27": "恐怖",
"10402": "音乐",
"9648": "悬疑",
"10749": "爱情",
"878": "科幻",
"10770": "电视电影",
"53": "惊悚",
"10752": "战争",
"37": "西部",
};
export const TMDB_TV_GENRES: Record<string, string> = {
"10759": "动作冒险",
"16": "动画",
"35": "喜剧",
"80": "犯罪",
"99": "纪录",
"18": "剧情",
"10751": "家庭",
"10762": "儿童",
"9648": "悬疑",
"10763": "新闻",
"10764": "真人秀",
"10765": "科幻奇幻",
"10766": "肥皂剧",
"10767": "脱口秀",
"10768": "战争政治",
"37": "西部",
};
export const tmdbGenres = (type: "movie" | "tv") => (type === "movie" ? TMDB_MOVIE_GENRES : TMDB_TV_GENRES);
/** 原始语言(with_original_language) */
export const TMDB_LANGUAGES: Record<string, string> = {
zh: "中文",
en: "英语",
ja: "日语",
ko: "韩语",
fr: "法语",
de: "德语",
es: "西班牙语",
it: "意大利语",
ru: "俄语",
pt: "葡萄牙语",
th: "泰语",
hi: "印地语",
};
/** 出品地区(with_origin_country,ISO 3166-1) */
export const TMDB_REGIONS: Record<string, string> = {
CN: "中国大陆",
HK: "中国香港",
TW: "中国台湾",
US: "美国",
GB: "英国",
JP: "日本",
KR: "韩国",
FR: "法国",
DE: "德国",
IN: "印度",
TH: "泰国",
ES: "西班牙",
};
export const TmdbDiscoverSort = z.enum(["popularity", "date", "rating", "votes"]);
export type TmdbDiscoverSort = z.infer<typeof TmdbDiscoverSort>;
export const TMDB_SORTS: Record<TmdbDiscoverSort, string> = {
popularity: "热度",
date: "最新",
rating: "评分",
votes: "评分人数",
};
/** TMDB 列表:trending 流行趋势;popular 热门;now_playing 正在上映;upcoming 即将上映;on_the_air 正在播出;airing_today 今日播出;top_rated 高分;discover 按条件筛选;search 关键词 */
export const TmdbDiscoverList = z.enum([
"trending",
"popular",
"now_playing",
"upcoming",
"on_the_air",
"airing_today",
"top_rated",
"discover",
"search",
]);
export type TmdbDiscoverList = z.infer<typeof TmdbDiscoverList>;
/** 只对一种类型有的列表 */
export const TMDB_LIST_TYPES: Partial<Record<TmdbDiscoverList, "movie" | "tv">> = {
now_playing: "movie",
upcoming: "movie",
on_the_air: "tv",
airing_today: "tv",
};
/** 年份:单年 2024 或年代 2010s */
export const DISCOVER_YEAR = /^(19|20)\d{2}s?$/;
/** 豆瓣榜单(rexxar subject_collection;2026-10-06 协调者实测可用的 id) */
export const DOUBAN_COLLECTIONS: Record<string, { label: string; kind: "movie" | "tv" }> = {
movie_real_time_hotest: { label: "实时热门电影", kind: "movie" },
tv_real_time_hotest: { label: "实时热门剧集", kind: "tv" },
movie_showing: { label: "正在热映", kind: "movie" },
movie_hot_gaia: { label: "热门电影", kind: "movie" },
movie_soon: { label: "即将上映", kind: "movie" },
movie_top250: { label: "电影 Top250", kind: "movie" },
tv_hot: { label: "热门剧集", kind: "tv" },
tv_domestic: { label: "国产剧", kind: "tv" },
tv_american: { label: "欧美剧", kind: "tv" },
tv_japanese: { label: "日剧", kind: "tv" },
tv_korean: { label: "韩剧", kind: "tv" },
tv_animation: { label: "动画", kind: "tv" },
show_hot: { label: "综艺", kind: "tv" },
tv_chinese_best_weekly: { label: "华语口碑剧集榜", kind: "tv" },
tv_global_best_weekly: { label: "全球口碑剧集榜", kind: "tv" },
};
/** 豆瓣标签(movie.douban.com/j/search_subjects 的 tag) */
export const DOUBAN_TAGS: Record<"movie" | "tv", string[]> = {
movie: ["热门", "最新", "豆瓣高分", "冷门佳片", "华语", "欧美", "韩国", "日本"],
tv: ["热门", "国产剧", "美剧", "英剧", "韩剧", "日剧", "港剧", "日本动画", "综艺", "纪录片"],
};
/** 豆瓣筛选(rexxar movie|tv/recommend,对照 MP DoubanView) */
export const DoubanExploreSort = z.enum(["U", "R", "T", "S"]);
export type DoubanExploreSort = z.infer<typeof DoubanExploreSort>;
export const DOUBAN_SORTS: Record<DoubanExploreSort, string> = { U: "综合", T: "近期热度", R: "首映时间", S: "高分" };
export const DOUBAN_GENRES = [
"喜剧",
"爱情",
"动作",
"科幻",
"动画",
"悬疑",
"犯罪",
"惊悚",
"冒险",
"音乐",
"历史",
"奇幻",
"恐怖",
"战争",
"传记",
"歌舞",
"武侠",
"灾难",
"西部",
"纪录片",
"短片",
];
export const DOUBAN_REGIONS = [
"华语",
"欧美",
"韩国",
"日本",
"中国大陆",
"美国",
"中国香港",
"中国台湾",
"英国",
"法国",
"德国",
"意大利",
"西班牙",
"印度",
"泰国",
];
/** 年代或单年:2020年代 / 90年代 / 2024 */
export const DOUBAN_YEAR = /^((19|20)\d{2}|(19|20)\d0年代|[6-9]0年代)$/;
export const BangumiDiscoverList = z.enum(["trends", "rank", "latest", "search"]);
export type BangumiDiscoverList = z.infer<typeof BangumiDiscoverList>;
export const DISCOVER_PAGE_SIZE = 20;
// ── 条目与分页 ──
/** 本地状态:TMDB 身份对应的本地作品(豆瓣 / bgm 条目只有匹配过才知道) */
export const DiscoverLocal = z.object({
entityId: z.string(),
subscribed: z.boolean(),
versionCount: z.number().int(),
downloadingCount: z.number().int(),
});
export type DiscoverLocal = z.infer<typeof DiscoverLocal>;
export const DiscoverTmdbRef = z.object({ type: z.enum(["movie", "tv"]), id: z.string() });
export type DiscoverTmdbRef = z.infer<typeof DiscoverTmdbRef>;
export const DiscoverItem = z.object({
source: DiscoverSource,
id: z.string(),
title: z.string(),
aliases: z.array(z.string()),
year: z.number().int().nullable(),
kind: z.enum(["movie", "tv"]).nullable(),
/** 豆瓣 / bgm 封面完整地址;TMDB 用 posterPath */
cover: z.string().nullable(),
rating: z.number().nullable(),
date: z.string().nullable(),
episodes: z.number().int().nullable(),
url: z.string(),
genres: z.array(z.string()).default([]),
posterPath: z.string().nullable().default(null),
/** TMDB 条目自身;豆瓣 / bgm 条目匹配过且确定时给出 */
tmdb: DiscoverTmdbRef.nullable().default(null),
local: DiscoverLocal.nullable().default(null),
});
export type DiscoverItem = z.infer<typeof DiscoverItem>;
export const DiscoverPage = z.object({
items: z.array(DiscoverItem),
page: z.number().int(),
hasMore: z.boolean(),
});
export type DiscoverPage = z.infer<typeof DiscoverPage>;
export const DiscoverCalendar = z.object({
days: z.array(z.object({ weekday: z.number().int(), label: z.string(), items: z.array(DiscoverItem) })),
});
export type DiscoverCalendar = z.infer<typeof DiscoverCalendar>;
// ── 查询参数 ──
const Page = z.coerce.number().int().min(1).max(500).default(1);
const Q = z.string().trim().min(1).max(100);
const issue = (ctx: z.RefinementCtx, path: string, message: string) =>
ctx.addIssue({ code: "custom", path: [path], message });
export const DiscoverTmdbQuery = z
.object({
list: TmdbDiscoverList.default("trending"),
type: z.enum(["all", "movie", "tv"]).default("all"),
/** 只用于 trending */
window: z.enum(["day", "week"]).default("week"),
sort: TmdbDiscoverSort.optional(),
genre: z
.string()
.regex(/^\d{1,6}$/)
.optional(),
language: z
.string()
.regex(/^[a-z]{2}$/)
.optional(),
region: z
.string()
.regex(/^[A-Z]{2}$/)
.optional(),
year: z.string().regex(DISCOVER_YEAR).optional(),
minRating: z.coerce.number().min(0).max(10).optional(),
q: Q.optional(),
page: Page,
})
.superRefine((v, ctx) => {
const only = TMDB_LIST_TYPES[v.list];
if (only && v.type !== only) issue(ctx, "type", `${v.list} 只有${only === "movie" ? "电影" : "剧集"}`);
if (v.type === "all" && !["trending", "search"].includes(v.list) && !only)
issue(ctx, "type", "这个列表要选电影或剧集");
const filters = (["sort", "genre", "language", "region", "year", "minRating"] as const).filter(
(k) => v[k] !== undefined,
);
if (v.list !== "discover" && filters.length) issue(ctx, filters[0]!, "筛选条件只用于 list=discover");
if (v.genre && v.type !== "all" && !(v.genre in tmdbGenres(v.type))) issue(ctx, "genre", "没有这个风格");
if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词");
if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search");
});
export type DiscoverTmdbQuery = z.infer<typeof DiscoverTmdbQuery>;
export const DiscoverDoubanQuery = z
.object({
/** 榜单 id、tag(标签)、explore(筛选)、search(关键词) */
list: z.string().max(40).default("movie_real_time_hotest"),
type: z.enum(["movie", "tv"]).default("movie"),
tag: z.string().max(20).optional(),
sort: DoubanExploreSort.optional(),
genre: z.string().max(10).optional(),
region: z.string().max(10).optional(),
year: z.string().regex(DOUBAN_YEAR).optional(),
q: Q.optional(),
page: z.coerce.number().int().min(1).max(50).default(1),
})
.superRefine((v, ctx) => {
const special = ["tag", "explore", "search"];
if (!special.includes(v.list) && !(v.list in DOUBAN_COLLECTIONS)) issue(ctx, "list", "没有这个豆瓣榜单");
if (v.list === "tag" && !(v.tag && DOUBAN_TAGS[v.type].includes(v.tag))) issue(ctx, "tag", "没有这个标签");
if (v.list !== "tag" && v.tag) issue(ctx, "tag", "标签只用于 list=tag");
const filters = (["sort", "genre", "region", "year"] as const).filter((k) => v[k] !== undefined);
if (v.list !== "explore" && filters.length) issue(ctx, filters[0]!, "筛选条件只用于 list=explore");
if (v.genre && !DOUBAN_GENRES.includes(v.genre)) issue(ctx, "genre", "没有这个风格");
if (v.region && !DOUBAN_REGIONS.includes(v.region)) issue(ctx, "region", "没有这个地区");
if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词");
if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search");
});
export type DiscoverDoubanQuery = z.infer<typeof DiscoverDoubanQuery>;
export const DiscoverBangumiQuery = z
.object({
list: BangumiDiscoverList.default("trends"),
year: z
.string()
.regex(/^(19|20)\d{2}$/)
.optional(),
q: Q.optional(),
page: z.coerce.number().int().min(1).max(100).default(1),
})
.superRefine((v, ctx) => {
if (v.year && !["rank", "latest"].includes(v.list)) issue(ctx, "year", "年份只用于排行与最新");
if (v.list === "search" && !v.q) issue(ctx, "q", "搜索要给关键词");
if (v.list !== "search" && v.q) issue(ctx, "q", "关键词只用于 list=search");
});
export type DiscoverBangumiQuery = z.infer<typeof DiscoverBangumiQuery>;
// ── 匹配 TMDB ──
export const DiscoverMatchRequest = z.object({
source: z.enum(["douban", "bangumi"]),
id: z.string(),
title: z.string(),
aliases: z.array(z.string()),
year: z.number().int().nullable(),
kind: z.enum(["movie", "tv"]).nullable(),
});
export type DiscoverMatchRequest = z.infer<typeof DiscoverMatchRequest>;
export const DiscoverMatchCandidate = z.object({
type: z.enum(["movie", "tv"]),
id: z.string(),
title: z.string(),
year: z.number().int().nullable(),
posterPath: z.string().nullable(),
});
export type DiscoverMatchCandidate = z.infer<typeof DiscoverMatchCandidate>;
export const DiscoverMatchResult = z.object({
/** 能确定的 TMDB 作品;不确定为 null,看 candidates */
match: DiscoverMatchCandidate.nullable(),
candidates: z.array(DiscoverMatchCandidate),
});
export type DiscoverMatchResult = z.infer<typeof DiscoverMatchResult>;