- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门) - 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份) - 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网 - 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品 Co-Authored-By: Claude Opus 5.5 <[email protected]>
216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
import { type DiscoverItem, type DiscoverMatchResult, tmdbImageUrl } from "@umt/contracts";
|
||
import { ExternalLink, LoaderCircle, Search, Star } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { useBangumiCalendar, useDiscoverMatch, useDoubanHot } from "~/api/discover.ts";
|
||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { consolePath } from "~/components/console/nav.ts";
|
||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
|
||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
|
||
// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品,
|
||
// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。
|
||
|
||
export const meta = () => [{ title: "发现 · UHD 媒体自动化控制台" }];
|
||
|
||
type View = "anime" | "movie" | "tv";
|
||
const VIEWS: { value: View; label: string }[] = [
|
||
{ value: "anime", label: "番剧放送" },
|
||
{ value: "movie", label: "热门电影" },
|
||
{ value: "tv", label: "热门剧集" },
|
||
];
|
||
|
||
/** bgm 周一 = 1 … 周日 = 7 */
|
||
const todayWeekday = () => ((new Date().getDay() + 6) % 7) + 1;
|
||
|
||
export default function DiscoverPage() {
|
||
const [sp, setSp] = useSearchParams();
|
||
const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View;
|
||
const [day, setDay] = useState(todayWeekday);
|
||
const cal = useBangumiCalendar(view === "anime");
|
||
const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime");
|
||
const match = useDiscoverMatch();
|
||
const navigate = useNavigate();
|
||
const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
||
const open = async (it: DiscoverItem) => {
|
||
setBusyId(`${it.source}:${it.id}`);
|
||
try {
|
||
const r = await match.mutateAsync(it);
|
||
if (r.match) navigate(consolePath("media", "tmdb", r.match.type, r.match.id));
|
||
else setPicking({ item: it, result: r });
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "匹配 TMDB 失败"));
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
const q = view === "anime" ? cal : hot;
|
||
const items =
|
||
view === "anime" ? (cal.data?.days.find((d) => d.weekday === day)?.items ?? []) : (hot.data?.items ?? []);
|
||
|
||
return (
|
||
<>
|
||
<PageHeader title="发现" description="bgm.tv 每日放送与豆瓣热门;点开匹配到 TMDB 作品后可订阅或搜资源" />
|
||
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams({ view: v }), { replace: true })}>
|
||
<TabsList className="mb-4">
|
||
{VIEWS.map((v) => (
|
||
<TabsTrigger key={v.value} value={v.value}>
|
||
{v.label}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
{view === "anime" && cal.data ? (
|
||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||
{cal.data.days.map((d) => (
|
||
<Button
|
||
key={d.weekday}
|
||
size="sm"
|
||
variant={d.weekday === day ? "default" : "outline"}
|
||
onClick={() => setDay(d.weekday)}
|
||
>
|
||
{d.label}
|
||
<span className="text-micro opacity-70">{d.items.length}</span>
|
||
</Button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{q.isLoading ? (
|
||
<LoadingState rows={4} />
|
||
) : q.error ? (
|
||
<ErrorState error={q.error} onRetry={() => void q.refetch()} />
|
||
) : items.length === 0 ? (
|
||
<EmptyState title="没有条目" />
|
||
) : (
|
||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||
{items.map((it) => (
|
||
<li key={`${it.source}:${it.id}`}>
|
||
<ItemCard item={it} busy={busyId === `${it.source}:${it.id}`} onOpen={() => void open(it)} />
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{picking ? <PickDialog {...picking} onClose={() => setPicking(null)} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) {
|
||
const [failed, setFailed] = useState(false);
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onOpen}
|
||
disabled={busy}
|
||
className="group flex w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border/70 text-left transition-colors hover:border-foreground/40 disabled:opacity-70"
|
||
>
|
||
<div className="relative aspect-[2/3] w-full bg-muted">
|
||
{item.cover && !failed ? (
|
||
<img
|
||
src={item.cover}
|
||
alt=""
|
||
loading="lazy"
|
||
referrerPolicy="no-referrer"
|
||
onError={() => setFailed(true)}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
{busy ? (
|
||
<span className="absolute inset-0 grid place-items-center bg-background/60">
|
||
<LoaderCircle className="h-5 w-5 animate-spin" />
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="min-w-0 px-2 py-1.5">
|
||
<p className="truncate text-caption font-medium text-foreground" title={item.title}>
|
||
{item.title}
|
||
</p>
|
||
<p className="flex items-center gap-2 text-meta text-muted-foreground">
|
||
<span>{item.date ?? item.year ?? "—"}</span>
|
||
{item.rating ? (
|
||
<span className="inline-flex items-center gap-0.5">
|
||
<Star className="h-3 w-3" />
|
||
{item.rating.toFixed(1)}
|
||
</span>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PickDialog({
|
||
item,
|
||
result,
|
||
onClose,
|
||
}: {
|
||
item: DiscoverItem;
|
||
result: DiscoverMatchResult;
|
||
onClose: () => void;
|
||
}) {
|
||
return (
|
||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>选择 TMDB 作品</DialogTitle>
|
||
<DialogDescription>
|
||
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{result.candidates.length ? (
|
||
<ul className="grid gap-2">
|
||
{result.candidates.map((c) => (
|
||
<li key={`${c.type}:${c.id}`}>
|
||
<Link
|
||
to={consolePath("media", "tmdb", c.type, c.id)}
|
||
onClick={onClose}
|
||
className="flex items-center gap-3 rounded-lg border border-border/70 p-2 hover:border-foreground/40 hover:bg-accent"
|
||
>
|
||
<span className="h-14 w-10 shrink-0 overflow-hidden rounded bg-muted">
|
||
{c.posterPath ? (
|
||
<img
|
||
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
|
||
alt=""
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : null}
|
||
</span>
|
||
<span className="min-w-0">
|
||
<span className="block truncate text-caption font-medium">{c.title}</span>
|
||
<span className="text-meta text-muted-foreground">
|
||
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
|
||
</span>
|
||
</span>
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
|
||
)}
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button asChild variant="outline" size="sm">
|
||
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
|
||
<Search />
|
||
按名称搜索
|
||
</Link>
|
||
</Button>
|
||
<Button asChild variant="ghost" size="sm">
|
||
<a href={item.url} target="_blank" rel="noreferrer">
|
||
<ExternalLink />
|
||
{item.source === "bangumi" ? "bgm.tv" : "豆瓣"}
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|