Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
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<string, string>): 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<Src, string> = {
|
|
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>): 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<ExploreState> = {};
|
|
for (const k of KEYS) {
|
|
const v = sp.get(k);
|
|
if (v) (raw as Record<string, string>)[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>): ExploreState {
|
|
if (p.src && p.src !== s.src) return normalize({ src: p.src });
|
|
const next: Partial<ExploreState> = { ...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<string, string> }
|
|
| { 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<string, string> = { 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<DiscoverItem, "local" | "tmdb">): 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<DiscoverItem, "local">): { 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<DiscoverItem, "year" | "date" | "kind" | "genres">): 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<ExploreState>;
|
|
}
|
|
|
|
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;
|