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
+1273
-81
No files matched your search
@@ -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