import { DISCOVER_YEAR, type DiscoverItem, DOUBAN_COLLECTIONS, DOUBAN_GENRES, DOUBAN_REGIONS, DOUBAN_SORTS, DOUBAN_TAGS, DOUBAN_YEAR, TMDB_LANGUAGES, TMDB_LIST_TYPES, TMDB_REGIONS, TMDB_SORTS, type TmdbDiscoverList, tmdbGenres, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; // 发现页状态(都在 URL 里):view = recommend | explore;探索的来源 / 列表 / 筛选。 // 这里是纯函数:URL ↔ 状态、筛选器联动、状态 → 接口参数、卡片状态与去处。 export type Src = "tmdb" | "douban" | "bangumi"; export type View = "recommend" | "explore"; export interface ExploreState { src: Src; /** TMDB:TmdbDiscoverList;豆瓣:榜单 id / tag / explore / search;bgm:calendar / trends / rank / latest / search */ list: string; /** TMDB:all / movie / tv;豆瓣:movie / tv;bgm 不用 */ type: string; window?: string; sort?: string; genre?: string; language?: string; region?: string; year?: string; minRating?: string; tag?: string; q?: string; /** bgm 每日放送:1-7 */ weekday?: string; } export type Option = { value: string; label: string }; const opts = (r: Record): Option[] => Object.entries(r).map(([value, label]) => ({ value, label })); export const SOURCES: Option[] = [ { value: "tmdb", label: "TMDB" }, { value: "douban", label: "豆瓣" }, { value: "bangumi", label: "Bangumi" }, ]; export const DEFAULT_LIST: Record = { tmdb: "trending", douban: "movie_real_time_hotest", bangumi: "trends", }; /** TMDB 列表名(按类型):正在上映 / 即将上映只有电影,正在播出 / 今日播出只有剧集 */ export function tmdbListOptions(type: string): Option[] { const all: [TmdbDiscoverList, string][] = [ ["trending", "流行趋势"], ["popular", "热门"], ["now_playing", "正在上映"], ["upcoming", "即将上映"], ["on_the_air", "正在播出"], ["airing_today", "今日播出"], ["top_rated", "高分"], ["discover", "筛选"], ]; return all .filter(([v]) => { const only = TMDB_LIST_TYPES[v]; return !only || type === "all" || only === type; }) .map(([value, label]) => ({ value, label })); } export const TMDB_TYPES: Option[] = [ { value: "all", label: "全部" }, { value: "movie", label: "电影" }, { value: "tv", label: "剧集" }, ]; export const MEDIA_TYPES: Option[] = TMDB_TYPES.slice(1); export const WINDOWS: Option[] = [ { value: "week", label: "本周" }, { value: "day", label: "今日" }, ]; export const TMDB_SORT_OPTIONS = opts(TMDB_SORTS); export const TMDB_LANGUAGE_OPTIONS = opts(TMDB_LANGUAGES); export const TMDB_REGION_OPTIONS = opts(TMDB_REGIONS); export const RATING_OPTIONS: Option[] = ["6", "7", "8"].map((v) => ({ value: v, label: `${v} 分以上` })); /** 年份:最近 6 年 + 年代(TMDB 用 2010s,豆瓣用 2010年代) */ export function yearOptions(src: Src, now = new Date()): Option[] { const y = now.getFullYear(); const years = Array.from({ length: 6 }, (_, i) => String(y - i)); if (src === "bangumi") return Array.from({ length: 10 }, (_, i) => String(y - i)).map((v) => ({ value: v, label: v })); const decades = [2020, 2010, 2000, 1990, 1980]; return [ ...years.map((v) => ({ value: v, label: v })), ...decades.map((d) => src === "tmdb" ? { value: `${d}s`, label: `${d} 年代` } : { value: d >= 2000 ? `${d}年代` : `${String(d).slice(2)}年代`, label: `${d} 年代` }, ), ]; } export const DOUBAN_LIST_OPTIONS: Option[] = [ ...Object.entries(DOUBAN_COLLECTIONS).map(([value, c]) => ({ value, label: c.label })), { value: "tag", label: "按标签" }, { value: "explore", label: "筛选" }, ]; export const DOUBAN_SORT_OPTIONS = opts(DOUBAN_SORTS); export const DOUBAN_GENRE_OPTIONS = DOUBAN_GENRES.map((g) => ({ value: g, label: g })); export const DOUBAN_REGION_OPTIONS = DOUBAN_REGIONS.map((g) => ({ value: g, label: g })); export const BANGUMI_LIST_OPTIONS: Option[] = [ { value: "calendar", label: "每日放送" }, { value: "trends", label: "热门趋势" }, { value: "rank", label: "排行" }, { value: "latest", label: "最新" }, ]; export const WEEKDAYS: Option[] = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"].map((label, i) => ({ value: String(i + 1), label, })); /** bgm 周一 = 1 … 周日 = 7 */ export const todayWeekday = (d = new Date()) => String(((d.getDay() + 6) % 7) + 1); const KEYS = [ "src", "list", "type", "window", "sort", "genre", "language", "region", "year", "minRating", "tag", "q", "weekday", ] as const; const FILTER_KEYS = ["sort", "genre", "language", "region", "year", "minRating"] as const; /** 规整:去掉与当前来源 / 列表 / 类型不配的参数(和服务端校验同一套规则,不发会被拒的请求) */ export function normalize(input: Partial): ExploreState { const src: Src = input.src === "douban" || input.src === "bangumi" ? input.src : "tmdb"; const s: ExploreState = { src, list: input.list || DEFAULT_LIST[src], type: input.type ?? "" }; const q = input.q?.trim(); if (q) { s.list = "search"; s.q = q; } else if (s.list === "search") s.list = DEFAULT_LIST[src]; if (src === "tmdb") { s.type = ["all", "movie", "tv"].includes(s.type) ? s.type : "all"; const valid = ["search", ...tmdbListOptions("all").map((o) => o.value)]; if (!valid.includes(s.list)) s.list = DEFAULT_LIST.tmdb; const only = TMDB_LIST_TYPES[s.list as TmdbDiscoverList]; if (only) s.type = only; else if (s.type === "all" && !["trending", "search"].includes(s.list)) s.type = "movie"; if (s.list === "trending") s.window = input.window === "day" ? "day" : "week"; if (s.list === "discover") { const t = s.type as "movie" | "tv"; if (input.sort && input.sort in TMDB_SORTS) s.sort = input.sort; if (input.genre && input.genre in tmdbGenres(t)) s.genre = input.genre; if (input.language && input.language in TMDB_LANGUAGES) s.language = input.language; if (input.region && input.region in TMDB_REGIONS) s.region = input.region; if (input.year && DISCOVER_YEAR.test(input.year)) s.year = input.year; if (input.minRating && /^([0-9]|10)$/.test(input.minRating)) s.minRating = input.minRating; } } else if (src === "douban") { s.type = s.type === "tv" ? "tv" : "movie"; const coll = DOUBAN_COLLECTIONS[s.list]; if (!coll && !["tag", "explore", "search"].includes(s.list)) s.list = DEFAULT_LIST.douban; if (s.list === "tag") s.tag = input.tag && DOUBAN_TAGS[s.type as "movie"].includes(input.tag) ? input.tag : "热门"; if (s.list === "explore") { if (input.sort && input.sort in DOUBAN_SORTS) s.sort = input.sort; if (input.genre && DOUBAN_GENRES.includes(input.genre)) s.genre = input.genre; if (input.region && DOUBAN_REGIONS.includes(input.region)) s.region = input.region; if (input.year && DOUBAN_YEAR.test(input.year)) s.year = input.year; } } else { s.type = ""; if (!["calendar", "trends", "rank", "latest", "search"].includes(s.list)) s.list = DEFAULT_LIST.bangumi; if (s.list === "calendar") s.weekday = /^[1-7]$/.test(input.weekday ?? "") ? input.weekday : todayWeekday(); if ((s.list === "rank" || s.list === "latest") && input.year && /^\d{4}$/.test(input.year)) s.year = input.year; } return s; } export function fromSearchParams(sp: URLSearchParams): ExploreState { const raw: Partial = {}; for (const k of KEYS) { const v = sp.get(k); if (v) (raw as Record)[k] = v; } return normalize(raw); } export function toSearchParams(s: ExploreState, view: View = "explore"): URLSearchParams { const sp = new URLSearchParams({ view }); for (const k of KEYS) { const v = s[k]; if (v) sp.set(k, v); } return sp; } /** * 筛选器联动:改来源回到该来源的默认列表;改类型时去掉不配的风格 / 列表;离开「筛选」清掉筛选条件; * 值等于当前值时视为取消(芯片再点一次清除)。 */ export function patch(s: ExploreState, p: Partial): ExploreState { if (p.src && p.src !== s.src) return normalize({ src: p.src }); const next: Partial = { ...s }; for (const [k, v] of Object.entries(p) as [keyof ExploreState, string | undefined][]) { const toggleable = (FILTER_KEYS as readonly string[]).includes(k) || k === "year"; next[k] = (toggleable && v === s[k] ? undefined : v) as never; } if (p.list && p.list !== s.list) { for (const k of FILTER_KEYS) delete next[k]; delete next.tag; if (p.list !== "search") delete next.q; } return normalize(next); } /** 接口与参数;bgm 每日放送走日历接口 */ export type ApiQuery = | { kind: "list"; path: string; params: Record } | { kind: "calendar"; weekday: string }; export function apiQuery(s: ExploreState): ApiQuery { if (s.src === "bangumi" && s.list === "calendar") return { kind: "calendar", weekday: s.weekday ?? todayWeekday() }; const params: Record = { list: s.list }; const pick = (...ks: (keyof ExploreState)[]) => { for (const k of ks) if (s[k]) params[k] = s[k]!; }; if (s.src === "tmdb") pick("type", "window", ...FILTER_KEYS, "q"); else if (s.src === "douban") pick("type", "tag", "sort", "genre", "region", "year", "q"); else pick("year", "q"); if (s.src === "tmdb" && s.list !== "trending") delete params.window; return { kind: "list", path: `/discover/${s.src}`, params }; } // ── 卡片 ── const TMDB_DETAIL = "/console/media/tmdb"; /** 直接能去的作品页:本地作品 → 媒体库详情;有 TMDB 身份 → TMDB 作品详情;豆瓣 / bgm 没匹配过 → null(先匹配) */ export function itemHref(it: Pick): string | null { if (it.local) return `/console/library/${it.local.entityId}`; if (it.tmdb) return `${TMDB_DETAIL}/${it.tmdb.type}/${it.tmdb.id}`; return null; } /** 本地状态徽章:只列非默认状态(不在媒体库不显示) */ export function localBadges(it: Pick): { label: string; tone: StatusTone }[] { const l = it.local; if (!l) return []; const out: { label: string; tone: StatusTone }[] = []; if (l.versionCount > 0) out.push({ label: "媒体库已有", tone: "success" }); else if (l.downloadingCount > 0) out.push({ label: "下载中", tone: "warning" }); if (l.subscribed) out.push({ label: "已订阅", tone: "neutral" }); if (!out.length) out.push({ label: "在媒体库", tone: "neutral" }); return out; } /** 卡片副标题:年份 · 电影 / 剧集 · 前两个风格 */ export function itemMeta(it: Pick): string { const parts: string[] = []; const y = it.year ?? (it.date ? Number(it.date.slice(0, 4)) : null); if (y) parts.push(String(y)); if (it.kind) parts.push(it.kind === "movie" ? "电影" : "剧集"); parts.push(...it.genres.slice(0, 2)); return parts.join(" · "); } // ── 推荐分区(对照 MP recommendSources.ts) ── export type SectionCategory = "movie" | "tv" | "anime" | "chart"; export const CATEGORIES: Option[] = [ { value: "", label: "全部" }, { value: "movie", label: "电影" }, { value: "tv", label: "剧集" }, { value: "anime", label: "动画" }, { value: "chart", label: "榜单" }, ]; export interface RecommendSection { key: string; title: string; category: SectionCategory; explore: Partial; } export const RECOMMEND_SECTIONS: RecommendSection[] = [ { key: "tmdb-trending", title: "TMDB 流行趋势", category: "chart", explore: { src: "tmdb", list: "trending" } }, { key: "tmdb-now-playing", title: "正在热映", category: "movie", explore: { src: "tmdb", list: "now_playing", type: "movie" }, }, { key: "tmdb-popular-movie", title: "TMDB 热门电影", category: "movie", explore: { src: "tmdb", list: "popular", type: "movie" }, }, { key: "tmdb-popular-tv", title: "TMDB 热门剧集", category: "tv", explore: { src: "tmdb", list: "popular", type: "tv" }, }, { key: "douban-movie-hot", title: "豆瓣热门电影", category: "movie", explore: { src: "douban", list: "movie_real_time_hotest" }, }, { key: "douban-tv-hot", title: "豆瓣热门剧集", category: "tv", explore: { src: "douban", list: "tv_real_time_hotest", type: "tv" }, }, { key: "bangumi-today", title: "Bangumi 今日放送", category: "anime", explore: { src: "bangumi", list: "calendar" } }, { key: "bangumi-trends", title: "Bangumi 热门趋势", category: "anime", explore: { src: "bangumi", list: "trends" } }, { key: "tmdb-upcoming", title: "即将上映", category: "movie", explore: { src: "tmdb", list: "upcoming", type: "movie" }, }, { key: "tmdb-on-the-air", title: "正在播出", category: "tv", explore: { src: "tmdb", list: "on_the_air", type: "tv" }, }, { key: "douban-animation", title: "豆瓣热门动画", category: "anime", explore: { src: "douban", list: "tv_animation", type: "tv" }, }, { key: "douban-show", title: "豆瓣热门综艺", category: "tv", explore: { src: "douban", list: "show_hot", type: "tv" }, }, { key: "douban-top250", title: "豆瓣电影 Top250", category: "chart", explore: { src: "douban", list: "movie_top250" }, }, { key: "tmdb-top-rated", title: "TMDB 高分电影", category: "chart", explore: { src: "tmdb", list: "top_rated", type: "movie" }, }, { key: "douban-chinese-weekly", title: "华语口碑剧集榜", category: "chart", explore: { src: "douban", list: "tv_chinese_best_weekly", type: "tv" }, }, { key: "douban-global-weekly", title: "全球口碑剧集榜", category: "chart", explore: { src: "douban", list: "tv_global_best_weekly", type: "tv" }, }, ]; export const sectionsOf = (category: string) => category ? RECOMMEND_SECTIONS.filter((s) => s.category === category) : RECOMMEND_SECTIONS;