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:
rootandClaude Opus 5.5 committed 2026-09-29 20:52:29 +08:00
1 parent d282212a6b
commit 970296218c
20 files changed
+1273 -81

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ export * from "./identity/index.ts";
export * from "./naming/index.ts";
export * from "./organize/index.ts";
export * from "./path/index.ts";
export * from "./release-facets/index.ts";
export * from "./release-parser/index.ts";
export * from "./rules/index.ts";
export * from "./slot/index.ts";
+329
View File
@@ -0,0 +1,329 @@
// 资源列表快速筛选(SEARCH-3,对照 MP 搜索结果筛选条):把解析字段归到固定档位,按档位统计计数并筛选。
// 搜索结果页(GET /search/releases)与站点资源列表(GET /sites/{id}/releases)共用同一套口径。
//
// 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。
// 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数,
// 所以已选维度里的其它选项仍有计数,0 计数表示在其它条件下没有结果。
export const FACET_DIMS = [
"resolution",
"videoCodec",
"releaseType",
"site",
"promotion",
"hdr",
"releaseGroup",
"season",
"episode",
] as const;
export type FacetDim = (typeof FACET_DIMS)[number];
/** 各维度已选的值;空数组 / 缺省 = 该维度不筛 */
export type FacetSelection = Partial<Record<FacetDim, string[]>>;
export interface FacetOption {
value: string;
label: string;
count: number;
}
export type FacetResult = Record<FacetDim, FacetOption[]>;
/** 计算档位需要的字段(搜索结果与资源表行都能取到) */
export interface FacetInput {
siteId: string;
siteName: string;
resolution: string | null;
source: string | null;
videoCodec: string | null;
hdr: readonly string[];
releaseGroup: string | null;
season: number | null;
seasonEnd?: number | null;
episodes: { start: number; end: number } | null;
promotion: {
type: string | null;
downloadFactor: number | null;
uploadFactor: number | null;
expiresAt: string | null;
};
}
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; hideEmpty?: boolean }[]>> = {
resolution: [
{ value: "2160p", label: "2160p" },
{ value: "1080p", label: "1080p" },
{ value: "720p", label: "720p" },
{ value: "other", label: "其他", hideEmpty: true },
],
videoCodec: [
{ value: "H.265", label: "H.265" },
{ value: "H.264", label: "H.264" },
{ value: "AV1", label: "AV1" },
{ value: "other", label: "其他", hideEmpty: true },
],
releaseType: [
{ value: "disc", label: "原盘" },
{ value: "remux", label: "Remux" },
{ value: "encode", label: "蓝光压制" },
{ value: "webdl", label: "WEB-DL" },
{ value: "hdtv", label: "HDTV" },
{ value: "other", label: "其他", hideEmpty: true },
],
promotion: [
{ value: "free", label: "免费" },
{ value: "2x", label: "2x" },
{ value: "50%", label: "50%" },
{ value: "other", label: "其他", hideEmpty: true },
{ value: "none", label: "无" },
],
hdr: [
{ value: "DV", label: "DV" },
{ value: "HDR10", label: "HDR10" },
{ value: "HDR10+", label: "HDR10+" },
{ value: "other", label: "其他 HDR", hideEmpty: true },
{ value: "SDR", label: "SDR" },
],
};
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
export const RELEASE_GROUP_TOP = 20;
/** 一条资源最多展开多少集(防止异常集号范围撑爆计数) */
const MAX_EPISODES_PER_RELEASE = 200;
const MAX_SEASONS_PER_RELEASE = 50;
export function resolutionBucket(res: string | null): string {
if (res === "2160p") return "2160p";
if (res === "1080p" || res === "1080i") return "1080p";
if (res === "720p") return "720p";
return "other";
}
export function codecBucket(codec: string | null): string {
return codec === "H.265" || codec === "H.264" || codec === "AV1" ? codec : "other";
}
/** 版本类型:按解析出的来源(BluRay = 蓝光压制,BluRay-Disc = 原盘) */
export function releaseTypeBucket(source: string | null): string {
switch (source) {
case "BluRay-Disc":
return "disc";
case "REMUX":
return "remux";
case "BluRay":
return "encode";
case "WEB-DL":
return "webdl";
case "HDTV":
return "hdtv";
default:
return "other";
}
}
/** 促销档位(一条资源可同时是免费和 2x);已过期的促销按"无" */
export function promotionBuckets(p: FacetInput["promotion"], now: number): string[] {
const type = p.type ?? "none";
if (p.expiresAt && Date.parse(p.expiresAt) <= now) return ["none"];
const out: string[] = [];
const d = p.downloadFactor;
const u = p.uploadFactor;
if (d === 0 || type === "free" || type === "2xfree") out.push("free");
if ((u !== null && u >= 2) || type === "2x" || type === "2xfree" || type === "2x50%") out.push("2x");
if (d === 0.5 || type === "50%" || type === "2x50%") out.push("50%");
if (out.length) return out;
if (type === "none" || type === "unknown" || !type) return d !== null && d > 0 && d < 1 ? ["other"] : ["none"];
return ["other"];
}
export function hdrBuckets(hdr: readonly string[]): string[] {
if (hdr.length === 0) return ["SDR"];
const out = new Set<string>();
for (const h of hdr) out.add(h === "DV" || h === "HDR10" || h === "HDR10+" ? h : "other");
return [...out];
}
function seasonValues(r: FacetInput): string[] {
if (r.season === null || r.season === undefined) return [];
const end =
r.seasonEnd && r.seasonEnd > r.season ? Math.min(r.seasonEnd, r.season + MAX_SEASONS_PER_RELEASE) : r.season;
const out: string[] = [];
for (let s = r.season; s <= end; s++) out.push(String(s));
return out;
}
function episodeValues(r: FacetInput): string[] {
if (r.episodes) {
const { start, end } = r.episodes;
const last = Math.min(Math.max(start, end), start + MAX_EPISODES_PER_RELEASE);
const out: string[] = [];
for (let e = start; e <= last; e++) out.push(String(e));
return out;
}
return r.season !== null && r.season !== undefined ? ["pack"] : [];
}
/** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */
export function facetValues(r: FacetInput, now: number): Record<FacetDim, string[]> {
return {
resolution: [resolutionBucket(r.resolution)],
videoCodec: [codecBucket(r.videoCodec)],
releaseType: [releaseTypeBucket(r.source)],
site: [r.siteId],
promotion: promotionBuckets(r.promotion, now),
hdr: hdrBuckets(r.hdr),
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
season: seasonValues(r),
episode: episodeValues(r),
};
}
const norm = (dim: FacetDim, v: string) => (dim === "releaseGroup" ? v.toLowerCase() : v);
function normalizeSelection(sel: FacetSelection): Map<FacetDim, Set<string>> {
const m = new Map<FacetDim, Set<string>>();
for (const dim of FACET_DIMS) {
const vals = sel[dim]?.filter(Boolean);
if (vals?.length) m.set(dim, new Set(vals.map((v) => norm(dim, v))));
}
return m;
}
function matches(values: Record<FacetDim, string[]>, sel: Map<FacetDim, Set<string>>, except?: FacetDim): boolean {
for (const [dim, want] of sel) {
if (dim === except) continue;
if (!values[dim].some((v) => want.has(norm(dim, v)))) return false;
}
return true;
}
export interface FacetOutcome<T> {
/** 通过全部已选条件的条目(保持输入顺序) */
items: T[];
facets: FacetResult;
}
/**
* 按已选条件筛选并统计各维度计数。
* 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。
*/
export function applyFacets<T>(
items: readonly T[],
pick: (t: T) => FacetInput,
selection: FacetSelection,
opts: { now: number; groupTop?: number } = { now: Date.now() },
): FacetOutcome<T> {
const sel = normalizeSelection(selection);
const rows = items.map((t) => {
const input = pick(t);
return { t, input, values: facetValues(input, opts.now) };
});
const out = {} as FacetResult;
for (const dim of FACET_DIMS) {
const universe = new Map<string, { label: string; total: number }>();
for (const r of rows)
for (const v of r.values[dim]) {
const key = norm(dim, v);
const u = universe.get(key);
if (u) u.total++;
else universe.set(key, { label: labelOf(dim, v, r.input), total: 1 });
}
const counts = new Map<string, number>();
for (const r of rows) {
if (!matches(r.values, sel, dim)) continue;
for (const v of new Set(r.values[dim].map((x) => norm(dim, x)))) counts.set(v, (counts.get(v) ?? 0) + 1);
}
const chosen = sel.get(dim) ?? new Set<string>();
const fixed = FIXED[dim];
let options: FacetOption[];
if (fixed) {
options = fixed
.filter((f) => !f.hideEmpty || universe.has(f.value) || chosen.has(f.value))
.map((f) => ({ value: f.value, label: f.label, count: counts.get(f.value) ?? 0 }));
} else {
options = [...universe.entries()].map(([value, u]) => ({
value: dim === "releaseGroup" ? u.label : value,
label: u.label,
count: counts.get(value) ?? 0,
total: u.total,
}));
options = sortDynamic(
dim,
options as (FacetOption & { total: number })[],
chosen,
opts.groupTop ?? RELEASE_GROUP_TOP,
);
for (const v of new Set(selection[dim] ?? []))
if (v && !options.some((o) => norm(dim, o.value) === norm(dim, v)))
options.push({ value: v, label: labelOf(dim, v), count: 0 });
}
out[dim] = options;
}
return { items: rows.filter((r) => matches(r.values, sel)).map((r) => r.t), facets: out };
}
function sortDynamic(
dim: FacetDim,
options: (FacetOption & { total: number })[],
chosen: Set<string>,
top: number,
): FacetOption[] {
const strip = (o: FacetOption & { total: number }): FacetOption => ({
value: o.value,
label: o.label,
count: o.count,
});
if (dim === "season" || dim === "episode") {
const n = (v: string) => (v === "pack" ? -1 : Number(v));
return options.sort((a, b) => n(a.value) - n(b.value)).map(strip);
}
const sorted = options.sort((a, b) => b.total - a.total || a.label.localeCompare(b.label));
if (dim !== "releaseGroup") return sorted.map(strip);
const head = sorted.slice(0, top);
const extra = sorted.slice(top).filter((o) => chosen.has(norm(dim, o.value)));
return [...head, ...extra].map(strip);
}
function labelOf(dim: FacetDim, v: string, input?: FacetInput): string {
switch (dim) {
case "site":
return input?.siteName ?? v;
case "season":
return `S${v.padStart(2, "0")}`;
case "episode":
return v === "pack" ? "整季" : `E${v.padStart(2, "0")}`;
default:
return FIXED[dim]?.find((f) => f.value === v)?.label ?? v;
}
}
/** 查询参数 qf 的格式:`维度:值,值;维度:值`,值经 encodeURIComponent(制作组名可能含 `,` `;` `:`) */
export function parseFacetParam(qf: string | undefined): FacetSelection {
const out: FacetSelection = {};
if (!qf) return out;
for (const part of qf.split(";")) {
const i = part.indexOf(":");
if (i <= 0) continue;
const dim = part.slice(0, i).trim() as FacetDim;
if (!(FACET_DIMS as readonly string[]).includes(dim)) continue;
const vals = part
.slice(i + 1)
.split(",")
.map((v) => {
try {
return decodeURIComponent(v).trim();
} catch {
return v.trim();
}
})
.filter(Boolean);
if (vals.length) out[dim] = [...new Set([...(out[dim] ?? []), ...vals])];
}
return out;
}
export function formatFacetParam(sel: FacetSelection): string {
return FACET_DIMS.filter((d) => sel[d]?.length)
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
.join(";");
}