Files
UHD_Media_Tool/apps/web/app/components/discover/discover-state.ts
T

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;