feat(identity): 豆瓣 / bgm.tv 辅助信息源与发现页

- adapters/meta:bgm.tv 官方 API(条目 / 搜索 / 每日放送)与豆瓣移动版接口(条目 / 实时热门)
- 识别升级链:豆瓣条目缓存没有时在线查;AniBT 的 bgm ID、公开 BT 站标题搜 bgm → 名称 + 年份匹配 TMDB(季条目去季名与年份)
- 条目缓存 30 天,查不到 6 小时负缓存;测试进程缺省不出外网
- 发现页:bgm 每日放送、豆瓣热门电影 / 剧集,点条目匹配 TMDB 作品

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 21:30:16 +08:00
1 parent b8876542b8
commit 709414778e
23 files changed
+958 -31

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
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>
);
}