- 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
359 lines
12 KiB
TypeScript
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>;
|