feat(search): multi-select quick filters for search results and site releases (SEARCH-3)
- domain release-facets: bucket resolution / codec / release type / site / promotion / HDR /
release group / season / episode; OR within a dimension, AND across; disjunctive counts
- GET /search/releases and GET /sites/{id}/releases accept qf and return facets; site list
facets come from local source_release rows only (no site requests), paging stays consistent
- web QuickFilterBar shared by search results and site resources tab: per-dimension popover
(bottom sheet on phones) with counts, removable tags, clear all, localStorage memory per page
- runbook: quick filter params and bucket definitions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d282212a6b
commit
970296218c
20 files changed
+1268
-76
No files matched your search
@@ -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 = 做种数降序,同分发布时间新→旧 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ReleaseFilters = {
|
||||
category?: string;
|
||||
promotion?: string;
|
||||
resolution?: ResolutionStatus;
|
||||
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */
|
||||
qf?: string;
|
||||
};
|
||||
|
||||
export const releaseKeys = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,13 +377,21 @@ 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、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
: quickHidden
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
|
||||
: q
|
||||
@@ -391,7 +403,10 @@ export default function SiteWorkbenchPage() {
|
||||
<>
|
||||
<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 ? (
|
||||
|
||||
@@ -174,4 +174,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **VER-3 真机核对(2026-09-29)**:通过——登录态注入、搜索两阶段、待处理 6、Agent 抽屉、快照字段/时区/手机折叠;问题——严重:RSS 标题「[电视剧]」前缀被解析为中文名导致 15 条误映射到《东北有个周东北》(ID-2 修:分类前缀、别名守卫、快照更新重解析、错映射撤销脚本);中:RSS 条目未规范化(分类/大小/标题)、详情页只抓到 MediaInfo/豆瓣/标签(HH-2 修);低:请求计数不持久、「12 Memories」误判 E12、会话页乱码字符、原站视图默认显示「Agent」。
|
||||
- **数据治理后续(FIX-6 之后)**:全量回刷覆盖季 0(特别篇);TMDB 已确认的季集单元由临时转正式(沙丘:预言 仍为临时);同作品同季同集重复临时单元合并(征途 E09/E10 各 3 条);清理脚本修正后对用户库 dry-run 为 0 删除,未执行 apply。
|
||||
- **去 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`「快速筛选」。
|
||||
+12
-2
@@ -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;站点、制作组、季集随结果变化,不记。
|
||||
|
||||
## 从搜索结果下载
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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(";");
|
||||
}
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user