Merge branch 'search3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-29 20:52:51 +08:00
commit 4c7375b52f
20 files changed
+1273 -81

No files matched your search

+27 -1
View File
@@ -11,7 +11,7 @@ import type {
SearchSiteCoverage,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle, parseRelease } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { raceAbort } from "./media.ts";
@@ -286,6 +286,7 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
titleEn: p.titleEn ?? null,
year: p.year ?? null,
season: p.season ?? null,
seasonEnd: p.seasonEnd ?? null,
episodes: p.episodes ? { start: p.episodes.start, end: p.episodes.end } : null,
resolution: p.resolution ?? null,
source: p.source ?? null,
@@ -336,6 +337,31 @@ export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): Bas
});
}
/** 搜索结果 → 快速筛选口径的输入 */
export const searchFacetInput = (r: BaseRelease): FacetInput => ({
siteId: r.siteId,
siteName: r.siteName,
resolution: r.parsed.resolution,
source: r.parsed.source,
videoCodec: r.parsed.videoCodec,
hdr: r.parsed.hdr,
releaseGroup: r.parsed.releaseGroup,
season: r.parsed.season,
seasonEnd: r.parsed.seasonEnd ?? null,
episodes: r.parsed.episodes,
promotion: {
type: r.promotion.type,
downloadFactor: r.promotion.downloadFactor,
uploadFactor: r.promotion.uploadFactor,
expiresAt: r.promotion.expiresAt,
},
});
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
export function quickFilter(items: BaseRelease[], q: ReleaseSearchQuery, now: number) {
return applyFacets(filterReleases(items, q), searchFacetInput, parseFacetParam(q.qf), { now });
}
const time = (s: string | null) => (s ? Date.parse(s) : null);
/** 空值恒排最后;default = 做种数降序,同分发布时间新→旧 */
+4 -3
View File
@@ -34,7 +34,7 @@ import type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts";
import { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
import {
buildWorks,
detailSeasons,
@@ -278,7 +278,8 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
const terms = await releaseTerms(q);
const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" });
const all = hit.fanout.items;
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
const quick = quickFilter(all, q, d.now());
const filtered = sortReleases(quick.items, q.sort, q.order);
const offset = decodeCursor(q.cursor);
const pageItems = filtered.slice(offset, offset + q.limit);
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
@@ -290,7 +291,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
nextCursor: next,
total: filtered.length,
unfilteredTotal: all.length,
facets: facets(all),
facets: { ...facets(all), ...quick.facets },
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
@@ -1,12 +1,12 @@
import type {
ReleaseOrigin,
WorkbenchCoverage,
WorkbenchRelease,
WorkbenchReleaseDetail,
WorkbenchReleasePage,
WorkbenchReleaseQuery,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts";
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
@@ -103,6 +103,46 @@ function toWorkbench(row: ReleaseJoinRow): WorkbenchRelease {
};
}
/** 快速筛选用的轻量行:只取解析结果与促销 */
interface LightRow {
id: string;
parsed: unknown;
promotion: unknown;
promotion_expires_at: Date | null;
}
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
const p = (r.parsed ?? {}) as Record<string, unknown>;
const promo = (r.promotion ?? {}) as { kind?: unknown; expiresAt?: unknown; raw?: unknown };
const raw = (promo.raw ?? {}) as { downloadFactor?: unknown; uploadFactor?: unknown };
const eps = p.episodes as { start?: unknown; end?: unknown } | undefined;
const start = num(eps?.start);
const group = p.releaseGroup as { name?: unknown } | undefined;
const expires = r.promotion_expires_at ?? (typeof promo.expiresAt === "string" ? new Date(promo.expiresAt) : null);
return {
siteId: site.id,
siteName: site.name,
resolution: str(p.resolution),
source: str(p.source),
videoCodec: str(p.videoCodec),
hdr: Array.isArray(p.hdr) ? p.hdr.filter((h): h is string => typeof h === "string") : [],
releaseGroup: str(group?.name),
season: num(p.season),
seasonEnd: num(p.seasonEnd),
episodes: start !== null ? { start, end: num(eps?.end) ?? start } : null,
promotion: {
type: str(promo.kind),
downloadFactor: num(raw.downloadFactor),
uploadFactor: num(raw.uploadFactor),
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
},
};
}
function releaseSelect(rt: SourcesRuntime) {
return rt.ctx.db
.selectFrom("source_release as r")
@@ -122,7 +162,7 @@ export async function listSiteReleases(
rt: SourcesRuntime,
siteId: string,
q: WorkbenchReleaseQuery,
): Promise<{ items: WorkbenchRelease[]; nextCursor: string | null; coverage: WorkbenchCoverage }> {
): Promise<WorkbenchReleasePage> {
const site = await rt.ctx.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const snapAt = await lastSnapshotAt(rt, site.id);
@@ -130,8 +170,18 @@ export async function listSiteReleases(
const stale = !snapAt || rt.now().getTime() - snapAt.getTime() > periodMs * 2;
const offset = decodeCursor(q.cursor);
const run = async () => {
let query = releaseSelect(rt).where("r.site_id", "=", site.id);
// 快速筛选(SEARCH-3):先按 q / 分类 / 促销 / 识别状态取出全部候选的轻量字段,在内存里算分面并筛选,
// 再只为当前页取完整行。只读本地资源表,不触发站点请求。
const run = async (): Promise<LightRow[]> => {
let query = rt.ctx.db
.selectFrom("source_release as r")
.select([
"r.id",
sql<unknown>`r.parsed`.as("parsed"),
sql<unknown>`r.promotion`.as("promotion"),
"r.promotion_expires_at",
])
.where("r.site_id", "=", site.id);
if (q.q) {
const like = `%${q.q.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
query = query.where((eb) =>
@@ -149,9 +199,7 @@ export async function listSiteReleases(
return (await query
.orderBy(sql`coalesce(r.source_published_at, r.first_seen_at)`, "desc")
.orderBy("r.id")
.offset(offset)
.limit(q.limit + 1)
.execute()) as unknown as ReleaseJoinRow[];
.execute()) as unknown as LightRow[];
};
let rows = await run();
@@ -169,10 +217,23 @@ export async function listSiteReleases(
if (live) rows = await run();
}
}
const pg = page(rows, offset, q.limit);
const quick = applyFacets(rows, (r) => lightFacetInput(r, site), parseFacetParam(q.qf), {
now: rt.now().getTime(),
});
const slice = quick.items.slice(offset, offset + q.limit + 1);
const ids = slice.map((r) => r.id);
const full = ids.length
? ((await releaseSelect(rt).where("r.id", "in", ids).execute()) as unknown as ReleaseJoinRow[])
: [];
const byId = new Map(full.map((r) => [r.id as string, r]));
const ordered = ids.map((id) => byId.get(id)).filter((r): r is ReleaseJoinRow => !!r);
const pg = page(ordered, offset, q.limit);
return {
items: pg.items.map(toWorkbench),
nextCursor: pg.nextCursor,
total: quick.items.length,
unfilteredTotal: rows.length,
facets: quick.facets,
coverage: {
siteEnabled: site.enabled,
lastSnapshotAt: snapAt?.toISOString() ?? null,
+84
View File
@@ -396,6 +396,90 @@ describe.skipIf(!up)("全局搜索", () => {
expect(p2.nextCursor).toBeNull();
});
it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => {
const q = (qf: string, extra = "") =>
req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();
let d = await q("");
expect(d.total).toBe(3);
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 });
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 });
expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 });
expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 });
expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众");
expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 });
expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2);
expect(d.facets.season).toEqual([]);
// 旧分面仍在
expect(d.facets.resolutions.length).toBe(2);
d = await q("resolution:2160p");
expect(ids(d)).toEqual(["812345", "990001"]);
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
// 本维度其它选项仍按其它条件计数
expect(counts(d.facets.resolution)).toMatchObject({ "2160p": 2, "1080p": 1 });
expect(counts(d.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1 });
d = await q("resolution:2160p;releaseType:webdl");
expect(ids(d)).toEqual(["990001"]);
d = await q(`resolution:2160p,1080p;site:${sites.nexus}`);
expect(ids(d)).toEqual(["812345", "900001"]);
d = await q("releaseGroup:flux,grp");
expect(ids(d)).toEqual(["900001", "990001"]);
d = await q("hdr:DV");
expect(ids(d)).toEqual(["812345"]);
d = await q("videoCodec:AV1");
expect(d.items).toEqual([]);
expect(d.total).toBe(0);
// 与旧参数叠加(AND)
d = await q("resolution:2160p", "&source=REMUX");
expect(ids(d)).toEqual(["812345"]);
// 分页:同一筛选下逐页取完 = 一次取完,且排序一致
const whole = await q("videoCodec:H.265,H.264", "&sort=size&limit=50");
const seen: string[] = [];
let cursor: string | null = null;
do {
const p: Loose = await q("videoCodec:H.265,H.264", `&sort=size&limit=1${cursor ? `&cursor=${cursor}` : ""}`);
expect(p.total).toBe(3);
expect(p.facets.videoCodec).toEqual(whole.facets.videoCodec);
seen.push(...p.items.map((i: Loose) => i.siteReleaseId));
cursor = p.nextCursor;
} while (cursor);
expect(seen).toEqual(whole.items.map((i: Loose) => i.siteReleaseId));
});
it("站点资源列表的分面基于本地数据,不请求站点", async () => {
const before = [nexusSearches(), mteamSearches()];
const list = (qf: string) =>
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
let d = await list("");
expect(d.total).toBe(2);
expect(d.unfilteredTotal).toBe(2);
expect(d.facets.site).toEqual([{ value: sites.nexus, label: "观众", count: 2 }]);
expect(d.facets.resolution.find((f: Loose) => f.value === "2160p").count).toBe(1);
expect(d.facets.releaseType.find((f: Loose) => f.value === "remux").count).toBe(1);
d = await list("resolution:1080p");
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([
["FraMeSToR", 0],
["GRP", 1],
]);
// 分页
const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data);
expect(p1.items.length).toBe(1);
const p2 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1&cursor=${p1.nextCursor}`).then(
(r) => r.body.data,
);
expect(p2.items.length).toBe(1);
expect(p2.items[0].id).not.toBe(p1.items[0].id);
expect(p2.nextCursor).toBeNull();
expect([nexusSearches(), mteamSearches()]).toEqual(before);
});
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
const items = [base("a", true), base("b", false), base("c", "unknown")];
+2
View File
@@ -14,6 +14,8 @@ export type ReleaseFilters = {
category?: string;
promotion?: string;
resolution?: ResolutionStatus;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */
qf?: string;
};
export const releaseKeys = {
+2
View File
@@ -50,6 +50,8 @@ export type ReleaseSearchFilters = {
siteFilter?: string;
resolution?: string;
source?: string;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值` */
qf?: string;
/** 字节 */
minSize?: number;
maxSize?: number;
@@ -0,0 +1,251 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { Check, ChevronDown, X } from "lucide-react";
import { useState } from "react";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils.ts";
import {
clearDims,
QUICK_GROUPS,
type QuickDim,
type QuickGroup,
type QuickSelection,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
export function QuickFilterBar({
facets,
value,
onChange,
hide = [],
className,
}: {
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 不显示的维度(站点资源 Tab 不显示站点) */
hide?: QuickDim[];
className?: string;
}) {
const mobile = useIsMobile();
const [sheet, setSheet] = useState<QuickGroup | null>(null);
const groups = QUICK_GROUPS.filter((g) => {
const dims = g.dims.filter((d) => !hide.includes(d.dim));
if (!dims.length) return false;
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
return (
opts.length > 0 ||
selectedCount(
value,
dims.map((d) => d.dim),
) > 0
);
});
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
if (!groups.length && !tags.length) return null;
return (
<div className={cn("mb-3 min-w-0 space-y-2", className)}>
<div
role="toolbar"
aria-label="快速筛选"
className="-mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
>
{groups.map((g) => {
const dims = g.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
const chip = (
<button
key={g.key}
type="button"
aria-haspopup="dialog"
onClick={mobile ? () => setSheet(g) : undefined}
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
n
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{g.label}
{n ? (
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
{n}
</span>
) : null}
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</button>
);
return mobile ? (
chip
) : (
<Popover key={g.key}>
<PopoverTrigger asChild>{chip}</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<GroupPanel group={g} facets={facets} value={value} onChange={onChange} />
</PopoverContent>
</Popover>
);
})}
</div>
{tags.length ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{tags.map((t) => (
<button
key={`${t.dim}:${t.value}`}
type="button"
onClick={() => onChange(toggleValue(value, t.dim, t.value))}
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
>
<span className="text-muted-foreground">{t.dimLabel}</span>
<span className="max-w-40 truncate">{t.label}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
))}
<Button variant="ghost" size="sm" className="h-7" onClick={() => onChange({})}>
清除全部
</Button>
</div>
) : null}
{mobile ? (
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
<SheetHeader className="pb-2">
<SheetTitle>{sheet?.label}</SheetTitle>
<SheetDescription className="text-meta">可多选,数字是选中后的条数</SheetDescription>
</SheetHeader>
{sheet ? (
<div className="min-h-0 overflow-y-auto px-2 pb-2">
<GroupPanel group={sheet} facets={facets} value={value} onChange={onChange} bare />
</div>
) : null}
<SheetFooter className="flex-row gap-2 border-t border-border/60">
<Button
variant="outline"
className="flex-1"
disabled={
!sheet ||
!selectedCount(
value,
sheet.dims.map((d) => d.dim),
)
}
onClick={() =>
sheet &&
onChange(
clearDims(
value,
sheet.dims.map((d) => d.dim),
),
)
}
>
清除
</Button>
<Button className="flex-1" onClick={() => setSheet(null)}>
完成
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) : null}
</div>
);
}
/** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */
function GroupPanel({
group,
facets,
value,
onChange,
bare = false,
}: {
group: QuickGroup;
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 抽屉里不显示标题栏(抽屉有自己的标题和清除) */
bare?: boolean;
}) {
const dims = group.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
return (
<div className="flex max-h-[min(60dvh,24rem)] flex-col">
{bare ? null : (
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-caption font-medium text-foreground">{group.label}</span>
<button
type="button"
disabled={!n}
onClick={() => onChange(clearDims(value, dims))}
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-50"
>
清除
</button>
</div>
)}
<div className={cn("min-h-0 overflow-y-auto", bare ? "" : "p-1")}>
{group.dims.map((d) => {
const opts: FacetCount[] = facets?.[d.dim] ?? [];
const chosen = value[d.dim] ?? [];
const extra = chosen.filter((v) => !opts.some((o) => o.value === v));
const all = [...opts, ...extra.map((v) => ({ value: v, label: v, count: 0 }))];
if (!all.length) return null;
return (
<fieldset key={d.dim} className="m-0 border-0 p-0">
{group.dims.length > 1 ? (
<legend className="px-2 pt-2 pb-1 text-meta text-muted-foreground">{d.label}</legend>
) : (
<legend className="sr-only">{d.label}</legend>
)}
{all.map((o) => {
const on = chosen.includes(o.value);
const dead = o.count === 0 && !on;
return (
<button
key={o.value}
type="button"
aria-pressed={on}
disabled={dead}
onClick={() => onChange(toggleValue(value, d.dim, o.value))}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors duration-micro md:py-1.5",
on ? "text-foreground" : "text-foreground/90",
dead ? "cursor-default opacity-40" : "hover:bg-foreground/[0.06]",
)}
>
<span
aria-hidden
className={cn(
"grid h-4 w-4 shrink-0 place-items-center rounded-sm border",
on ? "border-primary bg-primary text-primary-foreground" : "border-border",
)}
>
{on ? <Check className="h-3 w-3" /> : null}
</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
<span className="text-meta text-muted-foreground tabular-nums">{o.count}</span>
</button>
);
})}
</fieldset>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
clearDims,
formatQuickFilter,
parseStored,
persistable,
quickFilterStorageKey,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
const empty = {
resolution: [],
videoCodec: [],
releaseType: [],
site: [],
promotion: [],
hdr: [],
releaseGroup: [],
season: [],
episode: [],
};
describe("快速筛选状态", () => {
it("qf 按维度顺序拼接,值编码;空选择不带参数", () => {
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
"resolution:2160p;hdr:DV;releaseGroup:A%2CB",
);
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
});
it("切换 / 清除 / 计数", () => {
let s = toggleValue({}, "resolution", "2160p");
s = toggleValue(s, "resolution", "1080p");
s = toggleValue(s, "season", "1");
expect(selectedCount(s)).toBe(3);
s = toggleValue(s, "resolution", "2160p");
expect(s.resolution).toEqual(["1080p"]);
expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1);
});
it("已选标签取分面里的选项名,取不到用原值", () => {
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
{ dim: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" },
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
]);
});
it("本机记忆只保留分辨率/编码/版本/促销/HDR,坏数据忽略", () => {
const sel = { resolution: ["2160p"], site: ["x"], releaseGroup: ["FLUX"], hdr: ["DV"] };
expect(persistable(sel)).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored(JSON.stringify(sel))).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored("{bad")).toEqual({});
expect(parseStored(JSON.stringify({ resolution: "2160p", videoCodec: [1, "H.265"] }))).toEqual({
videoCodec: ["H.265"],
});
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
});
});
@@ -0,0 +1,131 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { useCallback, useEffect, useState } from "react";
// 资源快速筛选(SEARCH-3)的状态与参数:搜索结果页和站点资源 Tab 共用。
// 服务端口径见 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND。
export type QuickDim = keyof ReleaseQuickFacets;
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
/** 筛选条上的一个入口;「季/集」一个入口里分两节 */
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[] };
export const QUICK_GROUPS: QuickGroup[] = [
{ key: "resolution", label: "分辨率", dims: [{ dim: "resolution", label: "分辨率" }] },
{ key: "videoCodec", label: "编码", dims: [{ dim: "videoCodec", label: "编码" }] },
{ key: "releaseType", label: "版本", dims: [{ dim: "releaseType", label: "版本" }] },
{ key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] },
{ key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] },
{ key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] },
{ key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
{
key: "seasonEpisode",
label: "季/集",
dims: [
{ dim: "season", label: "季" },
{ dim: "episode", label: "集" },
],
},
];
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"];
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
export function formatQuickFilter(sel: QuickSelection): string | undefined {
const s = DIM_ORDER.filter((d) => sel[d]?.length)
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
.join(";");
return s || undefined;
}
export const selectedCount = (sel: QuickSelection, dims: readonly QuickDim[] = DIM_ORDER) =>
dims.reduce((n, d) => n + (sel[d]?.length ?? 0), 0);
export function toggleValue(sel: QuickSelection, dim: QuickDim, value: string): QuickSelection {
const cur = sel[dim] ?? [];
const next = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value];
return { ...sel, [dim]: next };
}
export function clearDims(sel: QuickSelection, dims: readonly QuickDim[]): QuickSelection {
const next = { ...sel };
for (const d of dims) delete next[d];
return next;
}
/** 已选条件的标签(按维度顺序),文字取分面里的选项名 */
export function selectedTags(
sel: QuickSelection,
facets: ReleaseQuickFacets | undefined,
): { dim: QuickDim; value: string; dimLabel: string; label: string }[] {
const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const)));
return DIM_ORDER.flatMap((dim) =>
(sel[dim] ?? []).map((value) => ({
dim,
value,
dimLabel: dimLabel.get(dim) ?? dim,
label: facets?.[dim]?.find((o: FacetCount) => o.value === value)?.label ?? value,
})),
);
}
/** 本机记住的选择只保留固定档位的维度 */
export function persistable(sel: QuickSelection): QuickSelection {
const out: QuickSelection = {};
for (const d of PERSISTED) if (sel[d]?.length) out[d] = [...sel[d]!];
return out;
}
export function parseStored(raw: string | null): QuickSelection {
if (!raw) return {};
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object") return {};
const out: QuickSelection = {};
for (const d of PERSISTED) {
const list = (v as Record<string, unknown>)[d];
if (Array.isArray(list)) {
const vals = list.filter((x): x is string => typeof x === "string" && x.length > 0);
if (vals.length) out[d] = vals;
}
}
return out;
} catch {
return {};
}
}
/** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */
export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`;
/**
* 快速筛选状态;挂载后从 localStorage 读上次的选择(服务端渲染时没有 localStorage)。
* 读写失败(隐私模式等)只是不记忆,不影响筛选。
*/
export function useQuickFilters(page: "search" | "site"): [QuickSelection, (s: QuickSelection) => void] {
const key = quickFilterStorageKey(page);
const [sel, setSel] = useState<QuickSelection>({});
useEffect(() => {
try {
const stored = parseStored(globalThis.localStorage?.getItem(key) ?? null);
if (selectedCount(stored)) setSel(stored);
} catch {
// 读不到就不恢复
}
}, [key]);
const set = useCallback(
(next: QuickSelection) => {
setSel(next);
try {
globalThis.localStorage?.setItem(key, JSON.stringify(persistable(next)));
} catch {
// 写不了就不记忆
}
},
[key],
);
return [sel, set];
}
@@ -1,4 +1,4 @@
import type { FacetCount, ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
@@ -19,17 +19,19 @@ import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch }
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx";
import { formatQuickFilter, type QuickSelection, selectedCount, useQuickFilters } from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
@@ -68,7 +70,12 @@ export function parseWorkRef(ref: string): ReleaseTarget | null {
return null;
}
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
export function toApiFilters(
target: ReleaseTarget,
f: ReleaseFilterState,
siteIds = "",
quick: QuickSelection = {},
): 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 {
@@ -77,6 +84,7 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
qf: formatQuickFilter(quick),
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
@@ -86,8 +94,6 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
};
}
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
target,
siteIds,
@@ -104,14 +110,20 @@ export function ReleaseResults({
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
const [quick, setQuick] = useQuickFilters("search");
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const resetAll = () => {
setQuick({});
resetFilters();
};
return (
<section aria-labelledby="search-releases" className="min-w-0">
@@ -155,9 +167,8 @@ export function ReleaseResults({
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : undefined} />
) : null}
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
@@ -247,16 +258,11 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
);
}
const facetOpts = (items: FacetCount[]) =>
items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` }));
function Filters({
page,
filters,
setFilter,
onReset,
}: {
page: ReleaseSearchPage;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => void;
@@ -265,30 +271,10 @@ function Filters({
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [
{ key: "sites", label: "站点", items: page.facets.sites },
{ key: "resolution", label: "分辨率", items: page.facets.resolutions },
{ key: "source", label: "来源", items: page.facets.sources },
];
const desc = filters.order !== "asc";
return (
<div className="mb-3 space-y-2.5">
{facets
.filter((f) => f.items.length > 0)
.map((f) => (
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
<div className="min-w-0 flex-1">
<ChipToggleGroup
label={f.label}
options={facetOpts(f.items)}
value={csvList(filters[f.key])}
onChange={(v) => setFilter(f.key, v.join(","))}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
@@ -73,6 +73,11 @@ describe("站点选择", () => {
siteFilter: "b",
});
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
// 快速筛选进 qf(同维度逗号分隔,值编码)
expect(toApiFilters({ q: "x" }, f, "", { resolution: ["2160p", "1080p"], promotion: ["50%"], hdr: [] }).qf).toBe(
"resolution:2160p,1080p;promotion:50%25",
);
expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined();
});
});
+37 -22
View File
@@ -26,6 +26,8 @@ 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 { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
@@ -40,13 +42,6 @@ import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
{ value: "free", label: "免费" },
{ value: "2xfree", label: "2x 免费" },
{ value: "50%", label: "50%" },
{ value: "30%", label: "30%" },
{ value: "2x", label: "2x 上传" },
];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "待识别" },
@@ -120,26 +115,34 @@ export default function SiteWorkbenchPage() {
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [promotion, setPromotion] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
const [live, setLive] = useState(false);
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
qf,
};
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
useEffect(() => setLiveAt(null), [q, category, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const quickTotal = pages[0]?.total;
/** 本地有资源,但快速筛选后一条都没有 */
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const localActive = Object.values(local).some(Boolean);
@@ -162,11 +165,11 @@ export default function SiteWorkbenchPage() {
const reset = () => {
setQInput("");
setCategory("");
setPromotion("");
setQuick({});
setResolution("");
setLocal({ hr: "", seeding: "", size: "", age: "" });
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
@@ -327,7 +330,6 @@ export default function SiteWorkbenchPage() {
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="H&R"
@@ -361,6 +363,8 @@ export default function SiteWorkbenchPage() {
/>
</FilterBar>
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} />
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
@@ -373,25 +377,36 @@ export default function SiteWorkbenchPage() {
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length ? SearchX : Globe}
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地快照里没有匹配的资源"
: "还没有快照数据"
}
description={
all.length
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
+1 -1
View File
@@ -175,7 +175,7 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
- **数据治理后续(FIX-6 之后)**:全量回刷覆盖季 0(特别篇);TMDB 已确认的季集单元由临时转正式(沙丘:预言 仍为临时);同作品同季同集重复临时单元合并(征途 E09/E10 各 3 条);清理脚本修正后对用户库 dry-run 为 0 删除,未执行 apply。
- DH-1 已做(分支 dh1):`metadata.includeSpecials`(缺省开)full 刷新 / 回刷含季 0;刷新时对上 TMDB 季集结构的临时单元转正式(`content_unit.source`,迁移 0053);重复单元合并(refresh 一步 + `identity-cleanup.ts --merge-duplicates`),懒建加咨询锁防竞态。用户库只读 dry-run:征途 S01E09/E10 各 4 条 → 各保留 1 条,共删 6 条,未 apply。见 runbook metadata.md / identity.md。
- **去 AI 味改造(DEAI-2,用户 2026-09-29 拍板)**:"版本目标"全站改名「收录版本」;ID/hash/token/耗时/trace/原始字段默认折叠进「技术信息」(设置可改默认展开);"Agent 上游"改名「模型服务」;首页只回答"在忙什么/要我处理什么/最近入库了什么",去掉运行状态与计划两块;计划页维护类任务收成一行「数据清理」可展开;"未经核查"只在最终汇报保留一处;错误码不直接显示给用户;"无法确定作品"事项主按钮为「指定作品」;其余按 `docs/design/de-ai-review.md` P0/P1/P2。
- **搜索快速筛选(SEARCH-3,用户 2026-09-29,对照 MP)**:搜索结果与站点资源列表顶部多选筛选条:分辨率、编码、版本类型(原盘/Remux/蓝光压制/WEB-DL/HDTV)、站点、促销、HDR/DV、制作组、季集;选项从当前结果聚合并显示计数,可组合、可清除、记住上次选择。
- **搜索快速筛选(SEARCH-3,用户 2026-09-29,对照 MP)**:搜索结果与站点资源列表顶部多选筛选条:分辨率、编码、版本类型(原盘/Remux/蓝光压制/WEB-DL/HDTV)、站点、促销、HDR/DV、制作组、季集;选项从当前结果聚合并显示计数,可组合、可清除、记住上次选择。**已实现(分支 search3)**:`qf` 参数与分面口径见 `docs/runbook/search.md`「快速筛选」。
- **计划页与智能体(用户 2026-09-29 追加)**:计划页「Agent 巡检」不应沉底,移到上方;自动任务按分类分组、每组可折叠(并入 DEAI-2)。用户可见的"Agent / Agent 会话"统一改称「智能体」(并入 DEAI-2)。智能体对话整体偏繁琐、无效信息多 → AGUI-3 先做只读易用性审查出方案,DEAI-2 合并后再落地。
- **入库版本可见(LIBV-1,用户 2026-09-29)**:以《人生切割术》为例,作品/季/集只显示"已入库"不够,要显示入库的是哪个版本(分辨率、来源、编码、HDR、制作组、大小、文件位置),同一集多个文件并列;为之后的"多版本收录目标"打地基(数据模型一集可挂多个版本,与「收录版本」目标可比对:已满足/缺哪个版本)。
- **智能体对话重做(AGUI-3,审查见 `docs/design/agent-chat-review.md`)**:按报告 P0/P1 落地,DEAI-2 合并后从新 main 开。协调者裁定:"选定方案即批准"只在方案绑定同一 planId+payloadHash 且卡片写明动作要点(对象、大小/数量)时成立;删除、覆盖类动作仍需单独确认;"先看方案再执行"改为单次指令 `/先给方案`。
+12 -2
View File
@@ -13,7 +13,7 @@
| 端点 | scope | 说明 |
|---|---|---|
| `GET /search?q=&scope=all\|media\|releases\|actions&limit=` | read | 聚合搜索。`limit` 是每组条数(1–50,默认 10)。不查的组返回 `null`。`all` = 作品 + 操作,不查站点;资源只在 `scope=releases` 时查(站点按默认设置) |
| `GET /search/releases?q=\|entityId=\|tmdb=&sites=&siteFilter=&resolution=&source=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。`q` / `entityId` / `tmdb`(`movie:157336`)至少给一个,都没有 422。与 `/search` 共用缓存 |
| `GET /search/releases?q=\|entityId=\|tmdb=&sites=&siteFilter=&resolution=&source=&qf=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。`q` / `entityId` / `tmdb`(`movie:157336`)至少给一个,都没有 422。与 `/search` 共用缓存 |
| `GET /media/tmdb/{type}/{id}?include=mediaServers` | read | 仅 TMDB 作品详情:TMDB 信息与季列表、本地对比、是否已建档(`localEntityId`)、查询词;`include=mediaServers` 同时转调 Emby(最慢约 8s)。没配 TMDB 412,TMDB 没有该条目 404 |
| `GET /inventory/media-servers?mediaEntityId=\|tmdbType=&tmdbId=` | read | Emby 对比。按 TMDB 条目查时本地已建档就按作品查,否则只用这一个 TMDB ID,`mediaEntityId = null` |
| `POST /search/releases/{siteId}/{siteReleaseId}/plan {slotId?, entityId?, note?}` | organize.execute | 从搜索结果下载,走 `POST /acquisition-plans` 同一流程 |
@@ -54,7 +54,17 @@
- `free=true` 只保留下载计量为 0 的资源,`free=false` 排除它们。
- `hr=true` 只保留明确标了 H&R 的资源;`hr=false` 只排除明确 H&R 的,`unknown` 保留。目前观众等站点的定义没有 H&R 选择器,结果一律是 `unknown`。
- `sort=default|seeders|size|uploadedAt|site`,配合 `order=asc|desc`。`default` 按做种数降序,做种数相同时按发布时间从新到旧;空值始终排在最后。
- `facets` 统计的是未筛选的全部结果,包括站点、分辨率、来源三项。`total` 是筛选后的条数,`unfilteredTotal` 是筛选前的条数。游标是不透明的偏移量。
- `facets.sites` / `resolutions` / `sources` 统计的是未筛选的全部结果(旧字段)。`total` 是筛选后的条数,`unfilteredTotal` 是筛选前的条数。游标是不透明的偏移量。
### 快速筛选(SEARCH-3,对照 MP)
`GET /search/releases` 与 `GET /sites/{id}/releases` 共用,口径在 `packages/domain/src/release-facets/`。
- 参数 `qf=维度:值,值;维度:值`,值经 `encodeURIComponent`。同一维度多值为 OR,不同维度为 AND;与其它筛选参数叠加(AND),排序、游标照旧。
- 维度与档位:`resolution` 2160p / 1080p(含 1080i)/ 720p / other;`videoCodec` H.265 / H.264 / AV1 / other;`releaseType` 按解析的来源:disc 原盘(BluRay-Disc)/ remux / encode 蓝光压制(BluRay)/ webdl(WEB-DL)/ hdtv / other(WEBRip、DVD 等);`site` 站点 ID;`promotion` free / 2x / 50% / other(30% 等)/ none,一条可同时属于 free 和 2x,促销已过期算 none;`hdr` DV / HDR10 / HDR10+ / other(HLG、HDR Vivid)/ SDR(没有 HDR 标记),一条可属于多个;`releaseGroup` 制作组名(不分大小写);`season` 季号(多季合集展开到每一季);`episode` 集号,整季资源为 `pack`。
- 响应 `facets.<维度>` 为 `{value, label, count}`。计数按"其它维度的已选条件"统计:已选维度里的其它选项仍显示选上后能多出的条数,0 表示在其它条件下没有结果。固定档位总会列出,other 只在有资源时列出;制作组只列计数最多的 20 个(已选的补在后面);没有剧集资源时 `season` / `episode` 为空;已选但当前没有资源的值计数 0。
- 站点资源列表:先按 `q` / 分类 / 促销 / 识别状态从资源表取出全部候选的解析字段与促销,在内存里算分面和筛选,再只为当前页取完整行。只读本地资源表,`qf` 不会触发实时补查;响应另有 `total`(快速筛选后)与 `unfilteredTotal`。
- 前端:`components/search/quick-filter-bar.tsx`(桌面弹层、手机底部抽屉),状态与 `qf` 拼接在 `quick-filter-state.ts`。本机记住上次选择(localStorage `umt.quickFilters.search` / `umt.quickFilters.site`),只记分辨率、编码、版本、促销、HDR;站点、制作组、季集随结果变化,不记。
## 从搜索结果下载
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
import { NonNegInt } from "./common.ts";
// 资源列表快速筛选(SEARCH-3):GET /search/releases 与 GET /sites/{id}/releases 共用。
// 口径实现在 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND;
// 计数按其它维度的已选条件统计(0 = 在其它条件下没有结果)。
export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt });
export type FacetCount = z.infer<typeof FacetCount>;
/**
* 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。
* 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
* releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、
* hdr(DV/HDR10/HDR10+/other/SDR)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
*/
export const QuickFilterParam = z.string().max(4000).optional();
export const ReleaseQuickFacets = z.object({
resolution: z.array(FacetCount),
videoCodec: z.array(FacetCount),
releaseType: z.array(FacetCount),
/** 站点列表(单站)里只有一项 */
site: z.array(FacetCount),
promotion: z.array(FacetCount),
hdr: z.array(FacetCount),
/** 计数最多的前 20 个 + 已选的 */
releaseGroup: z.array(FacetCount),
/** 没有剧集资源时为空 */
season: z.array(FacetCount),
episode: z.array(FacetCount),
});
export type ReleaseQuickFacets = z.infer<typeof ReleaseQuickFacets>;
+14 -7
View File
@@ -3,6 +3,9 @@ import { Id, NonNegInt, Timestamp } from "./common.ts";
import { MediaCredit, MediaEntityStatus, MediaKind } from "./content.ts";
import { ManualAcquisitionRequest } from "./execution.ts";
import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts";
import { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
export { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
// 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页):
// GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。
@@ -68,6 +71,8 @@ export const SearchReleaseParsed = z.object({
titleEn: z.string().nullable(),
year: z.number().int().nullable(),
season: z.number().int().nullable(),
/** 多季合集的最后一季(S01-S03 = 3);单季 / 电影为 null */
seasonEnd: z.number().int().nullable().optional(),
episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(),
resolution: z.string().nullable(),
source: z.string().nullable(),
@@ -264,10 +269,12 @@ export const ReleaseSearchQuery = z.object({
sites: csv,
/** 逗号分隔的站点 ID:只筛选结果(仍查 sites 里的全部站点,分面计数不变) */
siteFilter: csv,
/** 逗号分隔:2160p,1080p… */
/** 逗号分隔的解析原值:2160p,1080p…(旧参数;快速筛选用 qf) */
resolution: csv,
/** 逗号分隔:REMUX,WEB-DL,BluRay… */
/** 逗号分隔的解析原值:REMUX,WEB-DL,BluRay…(旧参数;快速筛选用 qf) */
source: csv,
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`,同维度 OR、不同维度 AND,见 ReleaseQuickFacets */
qf: QuickFilterParam,
/** 字节 */
minSize: z.coerce.number().int().nonnegative().optional(),
maxSize: z.coerce.number().int().nonnegative().optional(),
@@ -285,9 +292,6 @@ export const ReleaseSearchQuery = z.object({
});
export type ReleaseSearchQuery = z.infer<typeof ReleaseSearchQuery>;
export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt });
export type FacetCount = z.infer<typeof FacetCount>;
export const ReleaseSearchPage = z
.object({
q: z.string(),
@@ -299,8 +303,11 @@ export const ReleaseSearchPage = z
total: NonNegInt,
/** 未筛选的全部条数 */
unfilteredTotal: NonNegInt,
/** 筛选项计数(基于未筛选的全部结果) */
facets: z.object({
/**
* sites / resolutions / sources:旧的筛选项计数(基于未筛选的全部结果);
* 其余为快速筛选分面(SEARCH-3,在其它筛选条件之后、按其它维度的已选条件计数)
*/
facets: ReleaseQuickFacets.extend({
sites: z.array(FacetCount),
resolutions: z.array(FacetCount),
sources: z.array(FacetCount),
+9
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { CursorQuery, DateOnly, EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts";
import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts";
// 来源域:站点、站点账号、Feed、站点资源、观察(REQ §4)
// 含 passkey 的 Feed URL 只以 urlSecretRef 保存,对外只给脱敏的 urlDisplay。
@@ -594,6 +595,8 @@ export const WorkbenchReleaseQuery = z.object({
resolution: ResolutionStatus.optional(),
/** true = 强制实时补查;false = 只看本地快照;缺省 = 本地无结果或快照过期时补查 */
live: z.enum(["true", "false"]).optional(),
/** 快速筛选(SEARCH-3,格式同 GET /search/releases 的 qf);只筛本地数据,不触发站点请求 */
qf: QuickFilterParam,
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
@@ -633,6 +636,12 @@ export const WorkbenchReleasePage = z.object({
items: z.array(WorkbenchRelease),
nextCursor: z.string().nullable(),
coverage: WorkbenchCoverage,
/** 快速筛选后的条数 */
total: NonNegInt.optional(),
/** 快速筛选前的条数(q / 分类 / 促销 / 识别状态筛选之后) */
unfilteredTotal: NonNegInt.optional(),
/** 快速筛选分面(SEARCH-3,基于本地资源表数据);site 只有本站一项 */
facets: ReleaseQuickFacets.optional(),
});
export type WorkbenchReleasePage = z.infer<typeof WorkbenchReleasePage>;
+1
View File
@@ -7,6 +7,7 @@ export * from "./identity/index.ts";
export * from "./naming/index.ts";
export * from "./organize/index.ts";
export * from "./path/index.ts";
export * from "./release-facets/index.ts";
export * from "./release-parser/index.ts";
export * from "./rules/index.ts";
export * from "./slot/index.ts";
+329
View File
@@ -0,0 +1,329 @@
// 资源列表快速筛选(SEARCH-3,对照 MP 搜索结果筛选条):把解析字段归到固定档位,按档位统计计数并筛选。
// 搜索结果页(GET /search/releases)与站点资源列表(GET /sites/{id}/releases)共用同一套口径。
//
// 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。
// 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数,
// 所以已选维度里的其它选项仍有计数,0 计数表示在其它条件下没有结果。
export const FACET_DIMS = [
"resolution",
"videoCodec",
"releaseType",
"site",
"promotion",
"hdr",
"releaseGroup",
"season",
"episode",
] as const;
export type FacetDim = (typeof FACET_DIMS)[number];
/** 各维度已选的值;空数组 / 缺省 = 该维度不筛 */
export type FacetSelection = Partial<Record<FacetDim, string[]>>;
export interface FacetOption {
value: string;
label: string;
count: number;
}
export type FacetResult = Record<FacetDim, FacetOption[]>;
/** 计算档位需要的字段(搜索结果与资源表行都能取到) */
export interface FacetInput {
siteId: string;
siteName: string;
resolution: string | null;
source: string | null;
videoCodec: string | null;
hdr: readonly string[];
releaseGroup: string | null;
season: number | null;
seasonEnd?: number | null;
episodes: { start: number; end: number } | null;
promotion: {
type: string | null;
downloadFactor: number | null;
uploadFactor: number | null;
expiresAt: string | null;
};
}
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; hideEmpty?: boolean }[]>> = {
resolution: [
{ value: "2160p", label: "2160p" },
{ value: "1080p", label: "1080p" },
{ value: "720p", label: "720p" },
{ value: "other", label: "其他", hideEmpty: true },
],
videoCodec: [
{ value: "H.265", label: "H.265" },
{ value: "H.264", label: "H.264" },
{ value: "AV1", label: "AV1" },
{ value: "other", label: "其他", hideEmpty: true },
],
releaseType: [
{ value: "disc", label: "原盘" },
{ value: "remux", label: "Remux" },
{ value: "encode", label: "蓝光压制" },
{ value: "webdl", label: "WEB-DL" },
{ value: "hdtv", label: "HDTV" },
{ value: "other", label: "其他", hideEmpty: true },
],
promotion: [
{ value: "free", label: "免费" },
{ value: "2x", label: "2x" },
{ value: "50%", label: "50%" },
{ value: "other", label: "其他", hideEmpty: true },
{ value: "none", label: "无" },
],
hdr: [
{ value: "DV", label: "DV" },
{ value: "HDR10", label: "HDR10" },
{ value: "HDR10+", label: "HDR10+" },
{ value: "other", label: "其他 HDR", hideEmpty: true },
{ value: "SDR", label: "SDR" },
],
};
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
export const RELEASE_GROUP_TOP = 20;
/** 一条资源最多展开多少集(防止异常集号范围撑爆计数) */
const MAX_EPISODES_PER_RELEASE = 200;
const MAX_SEASONS_PER_RELEASE = 50;
export function resolutionBucket(res: string | null): string {
if (res === "2160p") return "2160p";
if (res === "1080p" || res === "1080i") return "1080p";
if (res === "720p") return "720p";
return "other";
}
export function codecBucket(codec: string | null): string {
return codec === "H.265" || codec === "H.264" || codec === "AV1" ? codec : "other";
}
/** 版本类型:按解析出的来源(BluRay = 蓝光压制,BluRay-Disc = 原盘) */
export function releaseTypeBucket(source: string | null): string {
switch (source) {
case "BluRay-Disc":
return "disc";
case "REMUX":
return "remux";
case "BluRay":
return "encode";
case "WEB-DL":
return "webdl";
case "HDTV":
return "hdtv";
default:
return "other";
}
}
/** 促销档位(一条资源可同时是免费和 2x);已过期的促销按"无" */
export function promotionBuckets(p: FacetInput["promotion"], now: number): string[] {
const type = p.type ?? "none";
if (p.expiresAt && Date.parse(p.expiresAt) <= now) return ["none"];
const out: string[] = [];
const d = p.downloadFactor;
const u = p.uploadFactor;
if (d === 0 || type === "free" || type === "2xfree") out.push("free");
if ((u !== null && u >= 2) || type === "2x" || type === "2xfree" || type === "2x50%") out.push("2x");
if (d === 0.5 || type === "50%" || type === "2x50%") out.push("50%");
if (out.length) return out;
if (type === "none" || type === "unknown" || !type) return d !== null && d > 0 && d < 1 ? ["other"] : ["none"];
return ["other"];
}
export function hdrBuckets(hdr: readonly string[]): string[] {
if (hdr.length === 0) return ["SDR"];
const out = new Set<string>();
for (const h of hdr) out.add(h === "DV" || h === "HDR10" || h === "HDR10+" ? h : "other");
return [...out];
}
function seasonValues(r: FacetInput): string[] {
if (r.season === null || r.season === undefined) return [];
const end =
r.seasonEnd && r.seasonEnd > r.season ? Math.min(r.seasonEnd, r.season + MAX_SEASONS_PER_RELEASE) : r.season;
const out: string[] = [];
for (let s = r.season; s <= end; s++) out.push(String(s));
return out;
}
function episodeValues(r: FacetInput): string[] {
if (r.episodes) {
const { start, end } = r.episodes;
const last = Math.min(Math.max(start, end), start + MAX_EPISODES_PER_RELEASE);
const out: string[] = [];
for (let e = start; e <= last; e++) out.push(String(e));
return out;
}
return r.season !== null && r.season !== undefined ? ["pack"] : [];
}
/** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */
export function facetValues(r: FacetInput, now: number): Record<FacetDim, string[]> {
return {
resolution: [resolutionBucket(r.resolution)],
videoCodec: [codecBucket(r.videoCodec)],
releaseType: [releaseTypeBucket(r.source)],
site: [r.siteId],
promotion: promotionBuckets(r.promotion, now),
hdr: hdrBuckets(r.hdr),
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
season: seasonValues(r),
episode: episodeValues(r),
};
}
const norm = (dim: FacetDim, v: string) => (dim === "releaseGroup" ? v.toLowerCase() : v);
function normalizeSelection(sel: FacetSelection): Map<FacetDim, Set<string>> {
const m = new Map<FacetDim, Set<string>>();
for (const dim of FACET_DIMS) {
const vals = sel[dim]?.filter(Boolean);
if (vals?.length) m.set(dim, new Set(vals.map((v) => norm(dim, v))));
}
return m;
}
function matches(values: Record<FacetDim, string[]>, sel: Map<FacetDim, Set<string>>, except?: FacetDim): boolean {
for (const [dim, want] of sel) {
if (dim === except) continue;
if (!values[dim].some((v) => want.has(norm(dim, v)))) return false;
}
return true;
}
export interface FacetOutcome<T> {
/** 通过全部已选条件的条目(保持输入顺序) */
items: T[];
facets: FacetResult;
}
/**
* 按已选条件筛选并统计各维度计数。
* 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。
*/
export function applyFacets<T>(
items: readonly T[],
pick: (t: T) => FacetInput,
selection: FacetSelection,
opts: { now: number; groupTop?: number } = { now: Date.now() },
): FacetOutcome<T> {
const sel = normalizeSelection(selection);
const rows = items.map((t) => {
const input = pick(t);
return { t, input, values: facetValues(input, opts.now) };
});
const out = {} as FacetResult;
for (const dim of FACET_DIMS) {
const universe = new Map<string, { label: string; total: number }>();
for (const r of rows)
for (const v of r.values[dim]) {
const key = norm(dim, v);
const u = universe.get(key);
if (u) u.total++;
else universe.set(key, { label: labelOf(dim, v, r.input), total: 1 });
}
const counts = new Map<string, number>();
for (const r of rows) {
if (!matches(r.values, sel, dim)) continue;
for (const v of new Set(r.values[dim].map((x) => norm(dim, x)))) counts.set(v, (counts.get(v) ?? 0) + 1);
}
const chosen = sel.get(dim) ?? new Set<string>();
const fixed = FIXED[dim];
let options: FacetOption[];
if (fixed) {
options = fixed
.filter((f) => !f.hideEmpty || universe.has(f.value) || chosen.has(f.value))
.map((f) => ({ value: f.value, label: f.label, count: counts.get(f.value) ?? 0 }));
} else {
options = [...universe.entries()].map(([value, u]) => ({
value: dim === "releaseGroup" ? u.label : value,
label: u.label,
count: counts.get(value) ?? 0,
total: u.total,
}));
options = sortDynamic(
dim,
options as (FacetOption & { total: number })[],
chosen,
opts.groupTop ?? RELEASE_GROUP_TOP,
);
for (const v of new Set(selection[dim] ?? []))
if (v && !options.some((o) => norm(dim, o.value) === norm(dim, v)))
options.push({ value: v, label: labelOf(dim, v), count: 0 });
}
out[dim] = options;
}
return { items: rows.filter((r) => matches(r.values, sel)).map((r) => r.t), facets: out };
}
function sortDynamic(
dim: FacetDim,
options: (FacetOption & { total: number })[],
chosen: Set<string>,
top: number,
): FacetOption[] {
const strip = (o: FacetOption & { total: number }): FacetOption => ({
value: o.value,
label: o.label,
count: o.count,
});
if (dim === "season" || dim === "episode") {
const n = (v: string) => (v === "pack" ? -1 : Number(v));
return options.sort((a, b) => n(a.value) - n(b.value)).map(strip);
}
const sorted = options.sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
if (dim !== "releaseGroup") return sorted.map(strip);
const head = sorted.slice(0, top);
const extra = sorted.slice(top).filter((o) => chosen.has(norm(dim, o.value)));
return [...head, ...extra].map(strip);
}
function labelOf(dim: FacetDim, v: string, input?: FacetInput): string {
switch (dim) {
case "site":
return input?.siteName ?? v;
case "season":
return `S${v.padStart(2, "0")}`;
case "episode":
return v === "pack" ? "整季" : `E${v.padStart(2, "0")}`;
default:
return FIXED[dim]?.find((f) => f.value === v)?.label ?? v;
}
}
/** 查询参数 qf 的格式:`维度:值,值;维度:值`,值经 encodeURIComponent(制作组名可能含 `,` `;` `:`) */
export function parseFacetParam(qf: string | undefined): FacetSelection {
const out: FacetSelection = {};
if (!qf) return out;
for (const part of qf.split(";")) {
const i = part.indexOf(":");
if (i <= 0) continue;
const dim = part.slice(0, i).trim() as FacetDim;
if (!(FACET_DIMS as readonly string[]).includes(dim)) continue;
const vals = part
.slice(i + 1)
.split(",")
.map((v) => {
try {
return decodeURIComponent(v).trim();
} catch {
return v.trim();
}
})
.filter(Boolean);
if (vals.length) out[dim] = [...new Set([...(out[dim] ?? []), ...vals])];
}
return out;
}
export function formatFacetParam(sel: FacetSelection): string {
return FACET_DIMS.filter((d) => sel[d]?.length)
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
.join(";");
}
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
applyFacets,
type FacetInput,
formatFacetParam,
parseFacetParam,
promotionBuckets,
releaseTypeBucket,
} from "../src/index.ts";
// SEARCH-3 快速筛选:档位、计数(按其它维度已选条件)、同维度 OR / 跨维度 AND、qf 编解码
const NOW = Date.parse("2026-09-29T00:00:00Z");
const none = { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null };
const mk = (id: string, o: Partial<FacetInput> = {}): FacetInput & { id: string } => ({
id,
siteId: "a",
siteName: "站 A",
resolution: "1080p",
source: "WEB-DL",
videoCodec: "H.264",
hdr: [],
releaseGroup: null,
season: null,
episodes: null,
promotion: none,
...o,
});
const items = [
mk("1", {
resolution: "2160p",
source: "REMUX",
videoCodec: "H.265",
hdr: ["DV", "HDR10"],
releaseGroup: "FraMeSToR",
}),
mk("2", {
resolution: "2160p",
source: "WEB-DL",
videoCodec: "H.265",
hdr: ["HDR10"],
releaseGroup: "FLUX",
siteId: "b",
siteName: "站 B",
}),
mk("3", {
resolution: "1080p",
source: "BluRay",
videoCodec: "H.264",
releaseGroup: "flux",
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: null },
}),
mk("4", {
resolution: "720p",
source: "HDTV",
videoCodec: null,
promotion: { type: "2xfree", downloadFactor: 0, uploadFactor: 2, expiresAt: null },
}),
mk("5", { resolution: null, source: "BluRay-Disc", videoCodec: "AV1", siteId: "b", siteName: "站 B" }),
];
const run = (sel: Parameters<typeof applyFacets>[2]) => applyFacets(items, (x) => x, sel, { now: NOW });
const ids = (r: { items: { id: string }[] }) => r.items.map((i) => i.id);
const counts = (opts: { value: string; count: number }[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
describe("release-facets", () => {
it("未筛选:各维度计数等于全部结果里的档位分布", () => {
const r = run({});
expect(ids(r)).toEqual(["1", "2", "3", "4", "5"]);
expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 1, other: 1 });
expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 1, other: 1 });
expect(counts(r.facets.releaseType)).toEqual({ disc: 1, remux: 1, encode: 1, webdl: 1, hdtv: 1 });
expect(r.facets.releaseType.map((o) => o.label)).toEqual(["原盘", "Remux", "蓝光压制", "WEB-DL", "HDTV"]);
expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, "HDR10+": 0, SDR: 3 });
expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, "50%": 0, none: 3 });
expect(r.facets.site).toEqual([
{ value: "a", label: "站 A", count: 3 },
{ value: "b", label: "站 B", count: 2 },
]);
// 制作组不分大小写合并
expect(r.facets.releaseGroup).toEqual([
{ value: "FLUX", label: "FLUX", count: 2 },
{ value: "FraMeSToR", label: "FraMeSToR", count: 1 },
]);
expect(r.facets.season).toEqual([]);
});
it("同维度 OR、不同维度 AND;计数按其它维度的已选条件", () => {
const r = run({ resolution: ["2160p", "1080p"], videoCodec: ["H.265"] });
expect(ids(r)).toEqual(["1", "2"]);
// 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果 → 0
expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0, "720p": 0, other: 0 });
// 编码计数只受分辨率条件影响
expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0, other: 0 });
// 其它维度按全部条件
expect(counts(r.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1, encode: 0 });
expect(ids(run({ site: ["b"], releaseType: ["disc", "webdl"] }))).toEqual(["2", "5"]);
expect(ids(run({ releaseGroup: ["flux"] }))).toEqual(["2", "3"]);
expect(ids(run({ hdr: ["SDR"] }))).toEqual(["3", "4", "5"]);
expect(ids(run({ promotion: ["2x"] }))).toEqual(["4"]);
});
it("已选但没有结果的值仍列出(计数 0)", () => {
const r = run({ releaseGroup: ["NoSuch"], site: ["zzz"] });
expect(r.items).toEqual([]);
expect(r.facets.releaseGroup.find((o) => o.value === "NoSuch")).toEqual({
value: "NoSuch",
label: "NoSuch",
count: 0,
});
expect(r.facets.site.find((o) => o.value === "zzz")?.count).toBe(0);
});
it("制作组只列前 20(已选的补在后面)", () => {
const many = Array.from({ length: 25 }, (_, i) => mk(String(i), { releaseGroup: `G${i}` }));
const r = applyFacets(many, (x) => x, { releaseGroup: ["g9"] }, { now: NOW });
expect(r.facets.releaseGroup.length).toBe(21);
expect(r.facets.releaseGroup.at(-1)?.value).toBe("G9");
});
it("季 / 集:多季合集展开,整季资源记为整季", () => {
const tv = [
mk("s1", { season: 1, episodes: null }),
mk("s1e2", { season: 1, episodes: { start: 2, end: 3 } }),
mk("s13", { season: 1, seasonEnd: 3, episodes: null }),
mk("movie"),
];
const r = applyFacets(tv, (x) => x, { season: ["2"] }, { now: NOW });
expect(ids(r)).toEqual(["s13"]);
expect(r.facets.season).toEqual([
{ value: "1", label: "S01", count: 3 },
{ value: "2", label: "S02", count: 1 },
{ value: "3", label: "S03", count: 1 },
]);
expect(r.facets.episode).toEqual([
{ value: "pack", label: "整季", count: 1 },
{ value: "2", label: "E02", count: 0 },
{ value: "3", label: "E03", count: 0 },
]);
expect(ids(applyFacets(tv, (x) => x, { season: ["1"], episode: ["3"] }, { now: NOW }))).toEqual(["s1e2"]);
});
it("促销与版本类型档位", () => {
expect(promotionBuckets({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2, expiresAt: null }, NOW)).toEqual([
"2x",
"50%",
]);
expect(promotionBuckets({ type: "30%", downloadFactor: 0.3, uploadFactor: 1, expiresAt: null }, NOW)).toEqual([
"other",
]);
expect(
promotionBuckets({ type: "unknown", downloadFactor: null, uploadFactor: null, expiresAt: null }, NOW),
).toEqual(["none"]);
// 促销已过期 → 无
expect(
promotionBuckets({ type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-28T00:00:00Z" }, NOW),
).toEqual(["none"]);
expect(["BluRay-Disc", "REMUX", "BluRay", "WEB-DL", "HDTV", "WEBRip", null].map(releaseTypeBucket)).toEqual([
"disc",
"remux",
"encode",
"webdl",
"hdtv",
"other",
"other",
]);
});
it("qf 参数编解码", () => {
const sel = { resolution: ["2160p", "1080p"], promotion: ["50%"], releaseGroup: ["A;B,C:D"] };
const s = formatFacetParam(sel);
expect(s).toBe("resolution:2160p,1080p;promotion:50%25;releaseGroup:A%3BB%2CC%3AD");
expect(parseFacetParam(s)).toEqual(sel);
expect(parseFacetParam("bogus:1;hdr:;hdr:DV,DV")).toEqual({ hdr: ["DV"] });
});
});