Files
UHD_Media_Tool/apps/web/app/routes/console/library.tsx
T
rootandClaude Opus 5.5 7862573944 feat(cache): TMDB image proxy, posters, cache admin API and settings cards
GET /img/tmdb/{size}/{file} ported from MP ImageHelper / fetch_image
(sanitized cache path under DATA_DIR/cache/images, GLOBAL_IMAGE_CACHE
read switch, md5 ETag / 304, day-based cleanup job). Cache module adds
cache.sweep / cache.images_clean jobs and /cache/tmdb, /cache/images
list and clear endpoints. Library list/detail and the search palette
show posters with the neutral placeholder as fallback; metadata settings
get TMDB cache and image cache cards. Runbook docs/runbook/cache.md and
porting notes docs/porting/moviepilot-tmdb-cache.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 22:26:29 +08:00

233 lines
8.3 KiB
TypeScript

import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
import { Download, Inbox, Library as LibraryIcon, RotateCw } from "lucide-react";
import { useDeferredValue } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useLibraryList } from "~/api/library.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
import { INBOX_IDENTITY } from "~/components/library/identity-pool-sheet.tsx";
import { ExternalTab, PoolTab } from "~/components/library/identity-views.tsx";
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
export const meta = () => [{ title: "媒体概览 · UHD 媒体自动化控制台" }];
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
{ value: "gap", label: "有缺口" },
{ value: "attention", label: "需处理" },
{ value: "downloading", label: "下载中" },
{ value: "satisfied", label: "已满足" },
];
/** 各 Slot 状态计数:只列非零项,文字 + 语气 */
function SlotSummary({ counts }: { counts: LibraryItem["slotCounts"] }) {
const parts = SLOT_STATUS_ORDER.filter((s) => counts[s] > 0);
if (parts.length === 0) return <span className="text-meta text-muted-foreground">未设目标</span>;
return (
<div className="flex flex-wrap gap-1">
{parts.map((s: SlotStatus) => (
<StatusBadge key={s} tone={SLOT_STATUS[s].tone}>
{SLOT_STATUS[s].label} {counts[s]}
</StatusBadge>
))}
</div>
);
}
type View = "works" | "unidentified" | "identities" | "aliases";
function viewOf(sp: URLSearchParams): View {
if (sp.get("filter") === "unidentified") return "unidentified";
const v = sp.get("view");
return v === "identities" || v === "aliases" ? v : "works";
}
const VIEW_QUERY: Record<View, string> = {
works: "",
unidentified: "filter=unidentified",
identities: "view=identities",
aliases: "view=aliases",
};
export default function LibraryPage() {
const [sp, setSp] = useSearchParams();
const view = viewOf(sp);
return (
<>
<PageHeader
title="媒体概览"
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
actions={
view === "unidentified" ? (
<Button asChild variant="outline" size="sm">
<Link to={INBOX_IDENTITY}>
<Inbox />
确认身份
</Link>
</Button>
) : undefined
}
/>
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams(VIEW_QUERY[v as View]), { replace: true })}>
<TabsList className="mb-4">
<TabsTrigger value="works">作品</TabsTrigger>
<TabsTrigger value="unidentified">待识别</TabsTrigger>
<TabsTrigger value="identities">外部身份</TabsTrigger>
<TabsTrigger value="aliases">别名</TabsTrigger>
</TabsList>
</Tabs>
{view === "unidentified" ? (
<PoolTab />
) : view === "identities" ? (
<ExternalTab />
) : view === "aliases" ? (
<AliasesPanel />
) : (
<WorksView />
)}
</>
);
}
function WorksView() {
const [sp, setSp] = useSearchParams();
const navigate = useNavigate();
const kind = (sp.get("kind") ?? "") as MediaKind | "";
const filter = (sp.get("filter") ?? "") as LibraryListQuery["filter"] | "";
const q = sp.get("q") ?? "";
const deferredQ = useDeferredValue(q.trim());
const set = (k: string, v: string) =>
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (v) next.set(k, v);
else next.delete(k);
return next;
},
{ replace: true },
);
const list = useLibraryList({
kind: kind || undefined,
filter: filter || undefined,
q: deferredQ || undefined,
});
const rows = list.data?.pages.flatMap((p) => p.items);
const filtered = !!(kind || filter || q);
const columns: DataColumn<LibraryItem>[] = [
{
key: "title",
header: "作品",
cell: (r) => (
<div className="flex min-w-0 items-center gap-3">
<CoverPlaceholder
title={r.entity.title}
kind={r.entity.kind}
posterPath={r.posterPath}
small
className="h-14 w-10"
/>
<div className="min-w-0">
<Link
to={consolePath("library", r.entity.id)}
onClick={(e) => e.stopPropagation()}
className="block max-w-[14rem] truncate font-medium text-foreground hover:underline sm:max-w-xs"
>
{r.entity.title}
</Link>
<p className="truncate text-meta text-muted-foreground">
{MEDIA_KIND[r.entity.kind]}
{r.entity.year ? ` · ${r.entity.year}` : ""}
{r.entity.originalTitle && r.entity.originalTitle !== r.entity.title
? ` · ${r.entity.originalTitle}`
: ""}
</p>
{r.entity.status !== "active" ? <Tone v={ENTITY_STATUS[r.entity.status]} className="mt-1" /> : null}
</div>
</div>
),
},
{ key: "slots", header: "版本目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{
key: "assets",
header: "资产",
align: "right",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
},
{
key: "dl",
header: "下载中",
align: "right",
hideBelow: "md",
cell: (r) =>
r.activeDownloads ? (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
{r.activeDownloads}
</span>
) : (
<span className="text-muted-foreground">—</span>
),
},
{
key: "org",
header: "最近入库",
hideBelow: "lg",
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
},
];
return (
<>
<FilterBar
actions={
<Button variant="outline" size="sm" onClick={() => list.refetch()} disabled={list.isFetching}>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
search={{ value: q, onChange: (v) => set("q", v), placeholder: "标题 / 原名 / 别名" }}
onReset={filtered ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}
>
<FilterSelect label="类型" value={kind} onChange={(v) => set("kind", v)} options={opts(MEDIA_KIND)} />
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.entity.id}
onRowClick={(r) => navigate(consolePath("library", r.entity.id))}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
empty={
filtered
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
: {
title: "还没有作品",
description: "扫描库根或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>新建订阅</Link>
</Button>
),
}
}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
/>
</>
);
}