docs(plan): on-demand site detail page parsing (HHClub details.php)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 15:13:35 +08:00
1 parent 94fd48bff2
commit ac478673bc
39 files changed
+2446 -339

No files matched your search

@@ -1,5 +1,5 @@
import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useSearch } from "~/api/search.ts";
@@ -7,8 +7,7 @@ import { settingsPath } from "~/components/console/nav.ts";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
import { useSubscribeTmdb } from "./search-sections.tsx";
import { actionIcon, mediaKindLabel } from "./search-labels.ts";
import {
isSearchShortcut,
openSearchPalette,
@@ -16,9 +15,10 @@ import {
setSearchPaletteOpen,
useSearchPalette,
} from "./search-store.ts";
import { inventoryBadge, workHref } from "./work-card.tsx";
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。
// 资源组最慢要等 15s,面板里不查,回车进结果页。
// 命令面板(Ctrl/⌘+K):输入即查作品(本地 + TMDB 合并,带库内状态)/ 页面与操作;选中作品打开作品详情页。
// 面板里不订阅、不查站点;最后一项进搜索结果页。
function useDebounced<T>(v: T, ms: number): T {
const [d, setD] = useState(v);
@@ -68,7 +68,7 @@ export function CommandPalette() {
)}
>
<DialogTitle className="sr-only">搜索</DialogTitle>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
<DialogDescription className="sr-only">搜索作品和页面;选中作品打开详情</DialogDescription>
{open ? <PaletteBody initial={initial} /> : null}
</DialogContent>
</Dialog>
@@ -82,7 +82,6 @@ function PaletteBody({ initial }: { initial: string }) {
const term = useDebounced(q.trim(), 150);
const media = useSearch(term, "media", 6);
const actions = useSearch(term, "actions", 6);
const tmdbSub = useSubscribeTmdb();
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
@@ -92,36 +91,32 @@ function PaletteBody({ initial }: { initial: string }) {
navigate(to);
};
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
const stale = q.trim() !== term;
const local = term ? (media.data?.media?.local.items ?? []) : [];
const works = term ? (media.data?.media?.works ?? []) : [];
const tmdb = term ? media.data?.media?.tmdb : undefined;
const acts = term ? (actions.data?.actions?.items ?? []) : [];
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建)
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go 每次渲染新建)
const items = useMemo<Item[]>(() => {
const out: Item[] = [];
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`)));
for (const c of tmdb?.items ?? [])
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
for (const w of works) out.push(workItem(w, () => go(workHref(w))));
for (const a of acts) out.push(actionItem(a, () => go(a.href)));
if (term)
out.push({
key: "releases",
group: "资源",
group: "搜索页",
icon: Search,
title: (
<span>
在资源中搜索 <span className="font-medium">“{term}”</span>
查看「<span className="font-medium">{term}</span>」的全部结果
</span>
),
meta: "查询全部已启用站点,最慢约 15 秒",
meta: "作品卡上可以选站点搜资源",
hint: "打开",
run: () => go(searchPagePath(term)),
});
return out;
}, [local, tmdb, acts, term]);
}, [works, acts, term]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
useEffect(() => setActive(0), [items.length, term]);
@@ -160,7 +155,7 @@ function PaletteBody({ initial }: { initial: string }) {
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索作品、TMDB、页面"
placeholder="搜索作品、页面"
role="combobox"
aria-expanded={items.length > 0}
aria-controls={listId}
@@ -190,7 +185,7 @@ function PaletteBody({ initial }: { initial: string }) {
>
{!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。回车在资源中搜索。
输入作品名、别名或页面名称。选中作品打开详情页。
</p>
) : (
<>
@@ -261,30 +256,36 @@ function PaletteBody({ initial }: { initial: string }) {
<span>↵ 打开</span>
<span className="ml-auto inline-flex items-center gap-1">
<ArrowRight className="h-3 w-3" />
回车在资源中搜索
搜资源在作品详情页
</span>
</div>
</div>
);
}
function localItem(h: LocalMediaHit, run: () => void): Item {
const e = h.entity;
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean);
function workItem(w: SearchWork, run: () => void): Item {
const inv = inventoryBadge(w.inventory);
const parts = [
mediaKindLabel(w.kind),
w.year ? String(w.year) : null,
inv.label,
w.inventory.subscribed ? "已订阅" : null,
];
return {
key: `local-${e.id}`,
group: "本地作品",
icon: Film,
key: `work-${w.key}`,
group: "作品",
icon: w.localEntityId ? Film : Globe,
posterPath: w.posterPath,
title: (
<span>
{e.title}
{h.matchedAlias && h.matchedAlias !== e.title ? (
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span>
{w.title}
{w.originalTitle && w.originalTitle !== w.title ? (
<span className="ml-1.5 text-muted-foreground">· {w.originalTitle}</span>
) : null}
</span>
),
meta: parts.join(" · "),
hint: "打开",
meta: parts.filter(Boolean).join(" · "),
hint: "详情",
run,
};
}
@@ -306,27 +307,6 @@ function ItemIcon({ it }: { it: Item }) {
return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
const local = !!c.localEntityId;
return {
key: `tmdb-${c.entityType}-${c.externalId}`,
group: "TMDB",
icon: local ? Globe : Plus,
posterPath: c.posterPath,
title: (
<span>
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</span>
),
meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview]
.filter(Boolean)
.join(" · "),
hint: local ? "查看" : "订阅",
run: local ? view : () => subscribe(c),
};
}
function actionItem(a: SearchAction, run: () => void): Item {
return {
key: `action-${a.id}`,
@@ -29,7 +29,8 @@ import { cn, formatBytes } from "~/lib/utils.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -55,12 +56,25 @@ export const RELEASE_FILTER_KEYS = [
"order",
] as const satisfies readonly (keyof ReleaseFilterState)[];
export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters {
/** 搜什么:关键词,或按作品(服务端生成查询词) */
export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string };
/** URL 参数 work → 搜索目标:local:<id> / tmdb:<type>:<id> / q:<关键词> */
export function parseWorkRef(ref: string): ReleaseTarget | null {
if (ref.startsWith("local:")) return { entityId: ref.slice(6) };
const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref);
if (m) return { tmdb: `${m[1]}:${m[2]}` };
if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() };
return null;
}
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
q,
sites: f.sites || undefined,
...target,
sites: siteIds || undefined,
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
minSize: gbToBytes(f.minGb),
@@ -75,17 +89,22 @@ export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFil
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
q,
target,
siteIds,
filters,
setFilter,
resetFilters,
onChangeSites,
}: {
q: string;
target: ReleaseTarget;
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
siteIds: string;
onChangeSites?: () => void;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
@@ -109,20 +128,29 @@ export function ReleaseResults({
) : null}
</h2>
{first ? (
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
{relTime(first.fetchedAt)} · {first.tookMs} ms
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
<div className="flex flex-wrap gap-1">
{onChangeSites ? (
<Button variant="ghost" size="sm" onClick={onChangeSites}>
换站点
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
</div>
</div>
{first ? <CoverageNotice page={first} /> : null}
@@ -143,8 +171,8 @@ export function ReleaseResults({
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"}
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
className="py-12"
/>
) : (
@@ -1,18 +1,17 @@
import type { TmdbCandidate } from "@umt/contracts";
import { ArrowRight, Film, Plus } from "lucide-react";
import type { SearchWork } from "@umt/contracts";
import { ArrowRight, Film, PackageSearch } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useEntityFromCandidate } from "~/api/files.ts";
import { mediaServers } from "~/api/instances.ts";
import { useSearch } from "~/api/search.ts";
import { useCreateSubscription } from "~/api/subscriptions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
import { actionIcon } from "./search-labels.ts";
import { SitePickerDialog } from "./site-picker-dialog.tsx";
import { releaseSearchPath, WorkCard, workRef } from "./work-card.tsx";
// 结果页的"作品"与"操作"两段(资源段见 release-results.tsx)
// 结果页第一阶段:作品(本地 + TMDB 合并,带库存对比)与操作;资源要点「搜索资源」选站点后才查(release-results.tsx)
function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) {
return (
@@ -23,131 +22,76 @@ function SectionTitle({ id, title, extra }: { id: string; title: string; extra?:
);
}
/** 从 TMDB 候选建订阅:先按 TMDB id 建档(季/集与总集数随之就位)再订阅作品;建档失败退回按标题追踪 */
export function useSubscribeTmdb() {
const create = useCreateSubscription();
const entity = useEntityFromCandidate();
/** 「搜索资源」:先选站点,再跳到第二阶段(同一搜索页带 work / in 参数) */
export function useReleaseSearchLauncher() {
const navigate = useNavigate();
const [target, setTarget] = useState<{ title: string; ref: string } | null>(null);
return {
pending: create.isPending || entity.isPending,
subscribe: async (c: TmdbCandidate, onDone?: () => void) => {
const kind = c.entityType === "movie" ? "movie" : "series";
const mediaEntityId =
c.localEntityId ??
(await entity
.mutateAsync({ provider: "tmdb", externalId: c.externalId, kind: c.entityType === "movie" ? "movie" : "tv" })
.then((r) => r.mediaEntityId)
.catch(() => null));
create.mutate(
{
...(mediaEntityId ? { mediaEntityId } : { titleQuery: c.year ? `${c.title} ${c.year}` : c.title }),
mediaKind: kind,
mode: kind === "movie" ? "once" : "continuous",
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
priority: "new_work",
origin: "user",
},
{
onSuccess: () => {
toast.success(`已订阅「${c.title}」`, {
action: { label: "查看", onClick: () => navigate("/console/subscriptions") },
});
onDone?.();
},
onError: (e) => toast.error(errorMessage(e, "订阅失败")),
},
);
},
open: (t: { title: string; ref: string }) => setTarget(t),
dialog: (
<SitePickerDialog
open={!!target}
onOpenChange={(o) => !o && setTarget(null)}
title={target?.title ?? ""}
onConfirm={(ids) => target && navigate(releaseSearchPath(target, ids))}
/>
),
};
}
export function MediaSection({ q }: { q: string }) {
export function WorksSection({ q }: { q: string }) {
const res = useSearch(q, "media", 20);
const sub = useSubscribeTmdb();
const local = res.data?.media?.local.items ?? [];
const ms = mediaServers.useList();
const emby = (ms.data?.items ?? []).some((i) => (i as { enabled?: boolean }).enabled !== false);
const launcher = useReleaseSearchLauncher();
const works = res.data?.media?.works ?? [];
const tmdb = res.data?.media?.tmdb;
const searchReleases = (w: SearchWork) => {
const ref = workRef(w);
if (ref) launcher.open({ title: w.title, ref });
};
return (
<section aria-labelledby="search-media" className="min-w-0">
<SectionTitle
id="search-media"
title="作品"
extra={res.data ? `${local.length + (tmdb?.items.length ?? 0)} 条` : undefined}
/>
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<LoadingState rows={2} className="p-4" />
) : res.isError ? (
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<SectionTitle id="search-media" title="作品" extra={res.data ? `${works.length} 部` : undefined} />
<Button variant="ghost" size="sm" onClick={() => launcher.open({ title: q, ref: `q:${q}` })}>
<PackageSearch />
按关键词搜资源
</Button>
</div>
{res.isPending ? (
<LoadingState rows={3} className="pg-slab rounded-xl p-4" />
) : res.isError ? (
<div className="pg-slab rounded-xl">
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : local.length === 0 && !tmdb?.items.length ? (
</div>
) : works.length === 0 ? (
<div className="pg-slab rounded-xl">
<EmptyState
plain
icon={Film}
title="没有匹配的作品"
description="换个译名、原名或别名再搜。"
description="换个译名、原名或别名再搜;资源标题可以点「按关键词搜资源」。"
className="py-8"
/>
) : (
<ul className="divide-y divide-border/60">
{local.map((h) => (
<li key={h.entity.id}>
<Link
to={`/console/library/${h.entity.id}`}
className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
>
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{h.entity.title}
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null}
</p>
<p className="truncate text-meta text-muted-foreground">
{mediaKindLabel(h.entity.kind)} · {slotSummary(h)}
{h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""}
</p>
</div>
<StatusBadge>本地</StatusBadge>
</Link>
</li>
))}
{(tmdb?.items ?? []).map((c) => (
<li key={`${c.entityType}-${c.externalId}`} className="flex min-w-0 items-center gap-3 px-4 py-3">
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</p>
<p className="line-clamp-1 text-meta text-muted-foreground">
TMDB · {c.entityType === "movie" ? "电影" : "剧集"}
{c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""}
{c.overview ? ` · ${c.overview}` : ""}
</p>
</div>
{c.localEntityId ? (
<Button asChild variant="outline" size="sm">
<Link to={`/console/library/${c.localEntityId}`}>查看</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled={sub.pending} onClick={() => sub.subscribe(c)}>
订阅
</Button>
)}
</li>
))}
</ul>
)}
{tmdb && !tmdb.configured ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{tmdb?.error ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
</div>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{works.map((w) => (
<WorkCard key={w.key} w={w} emby={emby} onSearchReleases={searchReleases} />
))}
</div>
)}
{tmdb && !tmdb.configured ? (
<p className="mt-2 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{tmdb?.error ? <p className="mt-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p> : null}
{launcher.dialog}
</section>
);
}
@@ -22,7 +22,7 @@ export function SearchTrigger({ className }: { className?: string }) {
)}
>
<Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">搜索作品、资源、页面</span>
<span className="flex-1 truncate text-left">搜索作品、页面</span>
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
{shortcut}
</kbd>
@@ -0,0 +1,124 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { MediaServerInventory, WorkInventory } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { parseWorkRef, type ReleaseFilterState, toApiFilters } from "./release-results.tsx";
import { resolvePick } from "./site-picker-dialog.tsx";
import { inventoryBadge, mediaServerSummary, releaseSearchPath, slotLine, workHref, workRef } from "./work-card.tsx";
// SEARCH-2:作品 → 详情 / 搜资源的地址,站点选择解析,资源查询参数,订阅入口只在作品详情页。
const inv = (p: Partial<WorkInventory>): WorkInventory => ({
state: "not_collected",
subscribed: false,
versionCount: 0,
downloadingCount: 0,
slotTotal: 0,
slotSatisfied: 0,
missing: [],
...p,
});
const ID = "11111111-1111-4111-8111-111111111111";
describe("作品地址", () => {
it("本地作品进媒体库详情;仅 TMDB 的进 TMDB 详情", () => {
expect(workHref({ localEntityId: ID, tmdb: { entityType: "movie", externalId: "1" } })).toBe(
`/console/library/${ID}`,
);
expect(workHref({ localEntityId: null, tmdb: { entityType: "tv", externalId: "1399" } })).toBe(
"/console/media/tmdb/tv/1399",
);
});
it("搜资源引用与第二阶段地址可以往返", () => {
const ref = workRef({ localEntityId: null, tmdb: { entityType: "movie", externalId: "157336" } });
expect(ref).toBe("tmdb:movie:157336");
expect(parseWorkRef(ref!)).toEqual({ tmdb: "movie:157336" });
expect(parseWorkRef(`local:${ID}`)).toEqual({ entityId: ID });
expect(parseWorkRef("q:Dune 2024")).toEqual({ q: "Dune 2024" });
expect(parseWorkRef("nope")).toBeNull();
const url = new URL(releaseSearchPath({ title: "星际穿越", ref: ref! }, ["a", "b"]), "http://x");
expect(url.pathname).toBe("/console/search");
expect(Object.fromEntries(url.searchParams)).toEqual({ q: "星际穿越", work: ref, t: "星际穿越", in: "a,b" });
});
});
describe("站点选择", () => {
const sites = [
{ id: "a", enabled: true, searchDefault: true },
{ id: "b", enabled: true, searchDefault: false },
{ id: "c", enabled: false, searchDefault: true },
];
it("默认 = 启用且默认参与;全部 = 全部启用;指定 = 选中里仍启用的", () => {
expect(resolvePick({ mode: "default", siteIds: [] }, sites)).toEqual(["a"]);
expect(resolvePick({ mode: "all", siteIds: [] }, sites)).toEqual(["a", "b"]);
expect(resolvePick({ mode: "custom", siteIds: ["b", "c"] }, sites)).toEqual(["b"]);
});
it("资源查询:选中的站点进 sites,站点分面只进 siteFilter;按作品搜不带 q", () => {
const f: ReleaseFilterState = {
sites: "b",
resolution: "",
source: "",
minGb: "",
maxGb: "",
free: "",
hr: "",
sort: "",
order: "",
};
expect(toApiFilters({ tmdb: "movie:1" }, f, "a,b")).toMatchObject({
tmdb: "movie:1",
sites: "a,b",
siteFilter: "b",
});
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
});
});
describe("库内状态文案", () => {
it("未收录 / 已建档 / 已收录 / 正在下载;版本目标满足与缺失", () => {
expect(inventoryBadge(inv({})).label).toBe("未收录");
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("已建档 · 未入库");
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已收录 · 2 个版本");
expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个");
expect(
slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })),
).toBe("版本目标满足 1/2 · 缺「2160p」");
expect(slotLine(inv({}))).toBeNull();
});
it("Emby:任一实例有 = 已有;全部没有 = 没有;有失败 = 未知(不等于缺失)", () => {
const ms = (...st: ("present" | "not_found" | "unknown")[]): MediaServerInventory => ({
mediaEntityId: null,
partial: false,
instances: st.map((status, i) => ({
instanceId: `${ID.slice(0, -1)}${i}`,
name: `E${i}`,
status,
reason: null,
items: [],
observedAt: "2026-09-29T00:00:00.000Z",
})),
});
expect(mediaServerSummary(ms())).toBeNull();
expect(mediaServerSummary(ms("present"))?.label).toBe("Emby 已有");
expect(mediaServerSummary(ms("not_found", "not_found"))?.label).toBe("Emby 没有");
expect(mediaServerSummary(ms("not_found", "unknown"))?.label).toBe("Emby 未知");
});
});
describe("订阅入口位置", () => {
const src = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
it("搜索结果与 ⌘K 面板不能直接订阅", () => {
for (const f of ["./command-palette.tsx", "./search-sections.tsx", "./work-card.tsx"]) {
const s = src(f);
expect(s, f).not.toMatch(/useCreateSubscription|SubscriptionFormDialog|useSubscribeTmdb/);
}
});
it("作品详情页(本地 / 仅 TMDB)有订阅入口", () => {
for (const f of ["../../routes/console/library.$id.tsx", "../../routes/console/media.tmdb.$type.$id.tsx"]) {
expect(src(f), f).toMatch(/<SubscriptionFormDialog[\s\S]*initialTarget=/);
}
});
});
@@ -0,0 +1,171 @@
import type { SearchSiteMode, Site } from "@umt/contracts";
import { PackageSearch } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { useSiteOptions } from "~/api/instances.ts";
import { useSettings } from "~/api/settings.ts";
import { consolePath } from "~/components/console/nav.ts";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { cn } from "~/lib/utils.ts";
// 搜资源前选站点:默认(站点页勾了「默认参与搜索」的)/ 全部启用站点 / 指定站点。
// 初始值 = 本机上次的选择,没有就用 settings.search.defaultSiteMode。
export type SitePick = { mode: SearchSiteMode; siteIds: string[] };
const STORE_KEY = "umt.search.sitePick";
export function loadSitePick(): SitePick | null {
try {
const raw = globalThis.localStorage?.getItem(STORE_KEY);
if (!raw) return null;
const v = JSON.parse(raw) as Partial<SitePick>;
if (v.mode !== "default" && v.mode !== "all" && v.mode !== "custom") return null;
return { mode: v.mode, siteIds: Array.isArray(v.siteIds) ? v.siteIds.filter((x) => typeof x === "string") : [] };
} catch {
return null;
}
}
function saveSitePick(p: SitePick) {
try {
globalThis.localStorage?.setItem(STORE_KEY, JSON.stringify(p));
} catch {
// 隐私模式等存不了:只是下次不记得
}
}
/** 按模式解析出站点 ID(只含启用站点) */
export function resolvePick(pick: SitePick, sites: Pick<Site, "id" | "enabled" | "searchDefault">[]): string[] {
const on = sites.filter((s) => s.enabled);
if (pick.mode === "all") return on.map((s) => s.id);
if (pick.mode === "custom") {
const set = new Set(pick.siteIds);
return on.filter((s) => set.has(s.id)).map((s) => s.id);
}
return on.filter((s) => s.searchDefault !== false).map((s) => s.id);
}
const MODES: { value: SearchSiteMode; label: string }[] = [
{ value: "default", label: "默认站点" },
{ value: "all", label: "全部站点" },
{ value: "custom", label: "指定站点" },
];
export function SitePickerDialog({
open,
onOpenChange,
title,
onConfirm,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
/** 作品名,显示在说明里 */
title: string;
onConfirm: (siteIds: string[]) => void;
}) {
const sitesQ = useSiteOptions();
const settings = useSettings();
const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]);
const [pick, setPick] = useState<SitePick | null>(null);
// 打开时取初始值:本机上次 > 全局设置 > 默认
useEffect(() => {
if (!open) return;
const cfg = settings.data?.search;
setPick(
loadSitePick() ?? (cfg ? { mode: cfg.defaultSiteMode, siteIds: cfg.siteIds } : { mode: "default", siteIds: [] }),
);
}, [open, settings.data]);
const cur = pick ?? { mode: "default" as const, siteIds: [] };
const ids = resolvePick(cur, sites);
const count = (m: SearchSiteMode) => resolvePick({ mode: m, siteIds: cur.siteIds }, sites).length;
const confirm = () => {
saveSitePick(cur);
onConfirm(ids);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>搜索资源</DialogTitle>
<DialogDescription>在所选站点搜「{title}」。每个站点按作品原名和译名各查一次。</DialogDescription>
</DialogHeader>
<fieldset aria-label="站点范围" className="m-0 grid grid-cols-3 gap-1 rounded-lg border border-border/70 p-1">
{MODES.map((m) => (
<button
key={m.value}
type="button"
aria-pressed={cur.mode === m.value}
onClick={() =>
setPick({
mode: m.value,
// 切到「指定」时从当前集合起步
siteIds: m.value === "custom" && cur.mode !== "custom" ? ids : cur.siteIds,
})
}
className={cn(
"cursor-pointer rounded-md px-2 py-1.5 text-caption transition-colors duration-micro",
cur.mode === m.value
? "bg-foreground/[0.08] font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{m.label}
{m.value !== "custom" && sitesQ.data ? (
<span className="ml-1 text-meta text-muted-foreground tabular-nums">{count(m.value)}</span>
) : null}
</button>
))}
</fieldset>
{sitesQ.isPending ? (
<p className="text-caption text-muted-foreground">加载站点…</p>
) : sites.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有启用的站点。
<Link to={consolePath("sites")} className="ml-1 underline underline-offset-4">
去站点页
</Link>
</p>
) : cur.mode === "custom" ? (
<ChipToggleGroup
label="站点"
options={sites.map((s) => ({ value: s.id, label: s.name }))}
value={cur.siteIds.filter((id) => sites.some((s) => s.id === id))}
onChange={(v) => setPick({ mode: "custom", siteIds: v })}
/>
) : (
<p className="text-caption break-words text-muted-foreground">
{ids.length
? sites
.filter((s) => ids.includes(s.id))
.map((s) => s.name)
.join("、")
: "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={confirm} disabled={ids.length === 0}>
<PackageSearch />
搜索 {ids.length} 个站点
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,140 @@
import type { MediaServerInventory, SearchWork, WorkInventory } from "@umt/contracts";
import { ArrowRight, PackageSearch } from "lucide-react";
import { Link } from "react-router";
import { type MediaServerRef, useMediaServerInventory } from "~/api/search.ts";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { mediaKindLabel } from "./search-labels.ts";
// 作品卡(SEARCH-2 第一阶段):TMDB 海报 / 年份 / 类型 / 简介摘要 + 库内状态;「详情」进作品详情页,「搜索资源」先选站点。
/** 作品详情页:本地作品走媒体库详情,仅 TMDB 的走 TMDB 详情 */
export function workHref(w: Pick<SearchWork, "localEntityId" | "tmdb">): string {
if (w.localEntityId) return consolePath("library", w.localEntityId);
if (w.tmdb) return `/console/media/tmdb/${w.tmdb.entityType}/${w.tmdb.externalId}`;
return consolePath("library");
}
/** 搜资源用的作品引用(URL 参数 work):local:<id> / tmdb:<type>:<id> */
export function workRef(w: Pick<SearchWork, "localEntityId" | "tmdb">): string | null {
if (w.localEntityId) return `local:${w.localEntityId}`;
if (w.tmdb) return `tmdb:${w.tmdb.entityType}:${w.tmdb.externalId}`;
return null;
}
/** 第二阶段地址:搜索页带作品引用 + 选中的站点 */
export function releaseSearchPath(w: { title: string; ref: string }, siteIds: string[]): string {
const p = new URLSearchParams({ q: w.title, work: w.ref, t: w.title, in: siteIds.join(",") });
return `/console/search?${p.toString()}`;
}
export function inventoryBadge(inv: WorkInventory): { label: string; tone: StatusTone } {
switch (inv.state) {
case "not_collected":
return { label: "未收录", tone: "neutral" };
case "downloading":
return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" };
case "owned":
return { label: `已收录 · ${inv.versionCount} 个版本`, tone: "success" };
default:
return { label: "已建档 · 未入库", tone: "neutral" };
}
}
/** 版本目标满足情况一行:满足 a/b · 缺「x」「y」 */
export function slotLine(inv: WorkInventory): string | null {
if (inv.state === "not_collected" || inv.slotTotal === 0) return null;
const parts = [`版本目标满足 ${inv.slotSatisfied}/${inv.slotTotal}`];
if (inv.missing.length) parts.push(`缺${inv.missing.map((m) => `「${m.name}」`).join("")}`);
return parts.join(" · ");
}
export function InventoryBadges({ inv }: { inv: WorkInventory }) {
const b = inventoryBadge(inv);
return (
<>
<StatusBadge tone={b.tone}>{b.label}</StatusBadge>
{inv.subscribed ? <StatusBadge>已订阅</StatusBadge> : null}
</>
);
}
/** Emby 对比摘要:任一实例有 = 已有;全部没有 = 没有;有实例失败 / 超时 = 未知(不等于缺失) */
export function mediaServerSummary(ms: MediaServerInventory): { label: string; tone: StatusTone } | null {
if (ms.instances.length === 0) return null;
const present = ms.instances.filter((i) => i.status === "present");
if (present.length)
return {
label: `Emby 已有${ms.instances.length > 1 ? ` ${present.length}/${ms.instances.length}` : ""}`,
tone: "success",
};
if (ms.instances.every((i) => i.status === "not_found")) return { label: "Emby 没有", tone: "neutral" };
return { label: "Emby 未知", tone: "warning" };
}
export function EmbyBadge({ refOf, enabled = true }: { refOf: MediaServerRef | null; enabled?: boolean }) {
const q = useMediaServerInventory(refOf, enabled);
if (!q.data) return null;
const s = mediaServerSummary(q.data);
return s ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : null;
}
export function msRefOf(w: Pick<SearchWork, "localEntityId" | "tmdb">): MediaServerRef | null {
if (w.localEntityId) return { mediaEntityId: w.localEntityId };
if (w.tmdb) return { tmdbType: w.tmdb.entityType, tmdbId: w.tmdb.externalId };
return null;
}
export function WorkCard({
w,
emby,
onSearchReleases,
}: {
w: SearchWork;
/** 有媒体服务器实例时才查 Emby */
emby: boolean;
onSearchReleases: (w: SearchWork) => void;
}) {
const line = slotLine(w.inventory);
const href = workHref(w);
return (
<article className="pg-slab flex min-w-0 gap-3 rounded-xl p-3">
<Link to={href} className="shrink-0" aria-label={`${w.title} 详情`}>
<CoverPlaceholder title={w.title} kind={w.kind} posterPath={w.posterPath} className="h-32 w-[5.5rem]" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link to={href} className="min-w-0 hover:underline">
<h3 className="truncate text-sm font-semibold text-foreground">
{w.title}
{w.year ? <span className="font-normal text-muted-foreground"> ({w.year})</span> : null}
</h3>
</Link>
<p className="truncate text-meta text-muted-foreground">
{mediaKindLabel(w.kind)}
{w.originalTitle && w.originalTitle !== w.title ? ` · ${w.originalTitle}` : ""}
{w.source === "tmdb" ? " · TMDB" : ""}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
<InventoryBadges inv={w.inventory} />
{emby ? <EmbyBadge refOf={msRefOf(w)} /> : null}
</div>
{line ? <p className="mt-1 line-clamp-1 text-meta text-muted-foreground">{line}</p> : null}
{w.overview ? <p className="mt-1 line-clamp-2 text-meta text-muted-foreground">{w.overview}</p> : null}
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2">
<Button asChild variant="ghost" size="sm">
<Link to={href}>
详情
<ArrowRight />
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => onSearchReleases(w)} disabled={!workRef(w)}>
<PackageSearch />
搜索资源
</Button>
</div>
</div>
</article>
);
}