diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 01327edc..d44809dd 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -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, 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 = 做种数降序,同分发布时间新→旧 */ diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 096dd74f..e521b544 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -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, diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index 0169ffbf..cad7e3ef 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -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): FacetInput { + const p = (r.parsed ?? {}) as Record; + 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 { 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 => { + let query = rt.ctx.db + .selectFrom("source_release as r") + .select([ + "r.id", + sql`r.parsed`.as("parsed"), + sql`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, diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index 872a139a..b1b3f22c 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -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")]; diff --git a/apps/web/app/api/releases.ts b/apps/web/app/api/releases.ts index 7ec8ddc6..3352abfc 100644 --- a/apps/web/app/api/releases.ts +++ b/apps/web/app/api/releases.ts @@ -14,6 +14,8 @@ export type ReleaseFilters = { category?: string; promotion?: string; resolution?: ResolutionStatus; + /** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */ + qf?: string; }; export const releaseKeys = { diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts index efbdc17a..f45669d6 100644 --- a/apps/web/app/api/search.ts +++ b/apps/web/app/api/search.ts @@ -50,6 +50,8 @@ export type ReleaseSearchFilters = { siteFilter?: string; resolution?: string; source?: string; + /** 快速筛选(SEARCH-3):`维度:值,值;维度:值` */ + qf?: string; /** 字节 */ minSize?: number; maxSize?: number; diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx new file mode 100644 index 00000000..7303b69d --- /dev/null +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -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(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 ( +
+
+ {groups.map((g) => { + const dims = g.dims.map((d) => d.dim); + const n = selectedCount(value, dims); + const chip = ( + + ); + return mobile ? ( + chip + ) : ( + + {chip} + + + + + ); + })} +
+ + {tags.length ? ( +
+ {tags.map((t) => ( + + ))} + +
+ ) : null} + + {mobile ? ( + !o && setSheet(null)}> + + + {sheet?.label} + 可多选,数字是选中后的条数 + + {sheet ? ( +
+ +
+ ) : null} + + + + +
+
+ ) : null} +
+ ); +} + +/** 一个入口的多选列表:每项带计数;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 ( +
+ {bare ? null : ( +
+ {group.label} + +
+ )} +
+ {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 ( +
+ {group.dims.length > 1 ? ( + {d.label} + ) : ( + {d.label} + )} + {all.map((o) => { + const on = chosen.includes(o.value); + const dead = o.count === 0 && !on; + return ( + + ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts new file mode 100644 index 00000000..0672d873 --- /dev/null +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -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")); + }); +}); diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts new file mode 100644 index 00000000..5b773bf1 --- /dev/null +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -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>; + +/** 筛选条上的一个入口;「季/集」一个入口里分两节 */ +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)[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({}); + 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]; +} diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index 6e77d665..81c112b8 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -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(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 (
@@ -155,9 +167,8 @@ export function ReleaseResults({ {first ? : null} - {first ? ( - - ) : null} + {first ? : null} + {first ? : null}
{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 (
- {facets - .filter((f) => f.items.length > 0) - .map((f) => ( -
- {f.label} -
- setFilter(f.key, v.join(","))} - /> -
-
- ))}
大小 diff --git a/apps/web/app/components/search/search2.test.ts b/apps/web/app/components/search/search2.test.ts index 0b257212..11629ed2 100644 --- a/apps/web/app/components/search/search2.test.ts +++ b/apps/web/app/components/search/search2.test.ts @@ -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(); }); }); diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 31dd52e9..4ea224a0 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -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({ hr: "", seeding: "", size: "", age: "" }); - const [live, setLive] = useState(false); + // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 + const [quick, setQuick] = useQuickFilters("site"); + const qf = formatQuickFilter(quick); + // 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点 + const [liveAt, setLiveAt] = useState(null); + const live = liveAt !== null && liveAt === (qf ?? ""); + const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null); const [openId, setOpenId] = useState(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 void sites.refetch()} />; if (sites.data && !s) @@ -327,7 +330,6 @@ export default function SiteWorkbenchPage() { } > - + + {coverage || refreshFailed ? ( ) : null} @@ -373,25 +377,36 @@ export default function SiteWorkbenchPage() { ) : rows.length === 0 ? ( ) : ( <>
- 已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""} + {quickTotal !== undefined && selectedCount(quick) + ? `共 ${quickTotal} 条,已加载 ${all.length} 条` + : `已加载 ${all.length} 条`} + {localActive ? `,本地筛选后 ${rows.length} 条` : ""} {freeCount ? 其中免费 {freeCount} : null} {reqStats.data ? ( diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index c7515a08..55aa2865 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -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`「快速筛选」。 diff --git a/docs/runbook/search.md b/docs/runbook/search.md index 8a88fbb5..a7f3154e 100644 --- a/docs/runbook/search.md +++ b/docs/runbook/search.md @@ -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;站点、制作组、季集随结果变化,不记。 ## 从搜索结果下载 diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts new file mode 100644 index 00000000..349e6d67 --- /dev/null +++ b/packages/contracts/src/release-facets.ts @@ -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; + +/** + * 快速筛选参数 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; diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 4c5c71d5..97f44147 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -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; -export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt }); -export type FacetCount = z.infer; - 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), diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 639b4d92..b34817d7 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -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; diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 24657bd2..aa8ae949 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -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"; diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts new file mode 100644 index 00000000..4b479d5a --- /dev/null +++ b/packages/domain/src/release-facets/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>; + +export interface FacetOption { + value: string; + label: string; + count: number; +} +export type FacetResult = Record; + +/** 计算档位需要的字段(搜索结果与资源表行都能取到) */ +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> = { + 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(); + 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 { + 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> { + const m = new Map>(); + 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, sel: Map>, 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 { + /** 通过全部已选条件的条目(保持输入顺序) */ + items: T[]; + facets: FacetResult; +} + +/** + * 按已选条件筛选并统计各维度计数。 + * 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。 + */ +export function applyFacets( + items: readonly T[], + pick: (t: T) => FacetInput, + selection: FacetSelection, + opts: { now: number; groupTop?: number } = { now: Date.now() }, +): FacetOutcome { + 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(); + 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(); + 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(); + 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, + 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(";"); +} diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts new file mode 100644 index 00000000..fe8e97f8 --- /dev/null +++ b/packages/domain/test/release-facets.test.ts @@ -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 & { 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[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"] }); + }); +});