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; /** TMDB 风格(genre id → 中文名,对照 MP TheMovieDbView 的字典;不另请求 /genre/list) */ export const TMDB_MOVIE_GENRES: Record = { "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 = { "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 = { zh: "中文", en: "英语", ja: "日语", ko: "韩语", fr: "法语", de: "德语", es: "西班牙语", it: "意大利语", ru: "俄语", pt: "葡萄牙语", th: "泰语", hi: "印地语", }; /** 出品地区(with_origin_country,ISO 3166-1) */ export const TMDB_REGIONS: Record = { 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; export const TMDB_SORTS: Record = { 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; /** 只对一种类型有的列表 */ export const TMDB_LIST_TYPES: Partial> = { 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 = { 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; export const DOUBAN_SORTS: Record = { 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; 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; export const DiscoverTmdbRef = z.object({ type: z.enum(["movie", "tv"]), id: z.string() }); export type DiscoverTmdbRef = z.infer; 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; export const DiscoverPage = z.object({ items: z.array(DiscoverItem), page: z.number().int(), hasMore: z.boolean(), }); export type DiscoverPage = z.infer; 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; // ── 查询参数 ── 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; 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; 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; // ── 匹配 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; 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; export const DiscoverMatchResult = z.object({ /** 能确定的 TMDB 作品;不确定为 null,看 candidates */ match: DiscoverMatchCandidate.nullable(), candidates: z.array(DiscoverMatchCandidate), }); export type DiscoverMatchResult = z.infer;