feat(search): align quick-filter facets with MP (SEARCH-4)

Add audioCodec, subtitle, dub, feature, year, category, size and seeders
facets with disjunctive counts; parser records dub languages (audioLangs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 22:40:55 +08:00
1 parent c3b25be676
commit d9cf8f1c3a
10 files changed
+409 -5

No files matched your search

@@ -295,6 +295,12 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
videoCodec: p.videoCodec ?? null,
hdr: [...p.hdr],
releaseGroup: p.releaseGroup?.name ?? null,
audio: [...p.audio],
subtitles: p.subtitles ? [...p.subtitles] : null,
audioLangs: p.audioLangs ? [...p.audioLangs] : null,
bitDepth: p.bitDepth ?? null,
fps: p.fps ?? null,
cut: p.cut ?? null,
},
};
}
@@ -358,6 +364,16 @@ export const searchFacetInput = (r: BaseRelease): FacetInput => ({
expiresAt: r.promotion.expiresAt,
},
hr: r.hr,
audio: r.parsed.audio ?? [],
subtitles: r.parsed.subtitles ?? null,
audioLangs: r.parsed.audioLangs ?? null,
bitDepth: r.parsed.bitDepth ?? null,
fps: r.parsed.fps ?? null,
cut: r.parsed.cut ?? null,
year: r.parsed.year,
category: r.category.name,
sizeBytes: r.sizeBytes,
seeders: r.seeders,
});
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
@@ -6,7 +6,7 @@ import type {
WorkbenchReleaseQuery,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, scanAudioLangs } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts";
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
@@ -111,10 +111,19 @@ interface LightRow {
promotion_expires_at: Date | null;
/** raw._site.hr(列表快照解析的 H&R);没有为 null */
hr: unknown;
/** 站点分类名(列表快照 → RSS → 分类 ID) */
category: string | null;
size_bytes: number | string | null;
seeders: number | null;
/** 旧解析结果没有 audioLangs 时从标题 / 副标题补 */
title: string;
subtitle: string | 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);
const strs = (v: unknown): string[] | null =>
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.length > 0) : null;
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
@@ -143,6 +152,16 @@ export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">)
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
},
hr: r.hr === true || r.hr === false ? r.hr : "unknown",
audio: strs(p.audio) ?? [],
subtitles: strs(p.subtitles),
audioLangs: strs(p.audioLangs) ?? scanAudioLangs(`${r.title ?? ""} ${r.subtitle ?? ""}`),
bitDepth: num(p.bitDepth),
fps: num(p.fps),
cut: str(p.cut),
year: num(p.year),
category: r.category,
sizeBytes: r.size_bytes === null || r.size_bytes === undefined ? null : Number(r.size_bytes),
seeders: r.seeders,
};
}
@@ -184,6 +203,15 @@ export async function listSiteReleases(
sql<unknown>`r.promotion`.as("promotion"),
"r.promotion_expires_at",
sql<unknown>`r.raw->'_site'->'hr'`.as("hr"),
sql<
string | null
>`coalesce(nullif(r.raw->'_site'->>'categoryName', ''), nullif(r.raw->'rss'->>'categoryName', ''), r.category_raw)`.as(
"category",
),
"r.size_bytes",
"r.seeders",
"r.title",
"r.subtitle",
])
.where("r.site_id", "=", site.id);
if (q.q) {
+42
View File
@@ -483,6 +483,48 @@ describe.skipIf(!up)("全局搜索", () => {
expect([nexusSearches(), mteamSearches()]).toEqual(before);
});
it("SEARCH-4 新维度:音频 / 年份 / 分类 / 做种 / 大小,搜索结果与站点资源列表同一口径", async () => {
const q = (qf: string) => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
const list = (qf: string) =>
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
const pick = (f: Loose) =>
Object.fromEntries(
["audioCodec", "subtitle", "dub", "feature", "year", "category", "size", "seeders"].map((k) => [
k,
Object.fromEntries(f[k].map((o: Loose) => [o.value, o.count])),
]),
);
const d = await q("");
expect(pick(d.facets)).toMatchObject({
audioCodec: { Atmos: 1, TrueHD: 1, DDP: 1, AAC: 1 },
subtitle: { zh: 1, unknown: 2 },
dub: { cmn: 1, en: 1, unknown: 2 },
feature: {},
year: { "2024": 3 },
category: { 电影: 3 },
size: { "5-10": 1, "20-50": 1, "50+": 1 },
seeders: { "10-49": 1, "50+": 2 },
});
// 组合:年份 × 做种 × 音频(同维度 OR)
const hit = await q("year:2024;seeders:50+;audioCodec:AAC,DDP");
expect(hit.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
expect(hit.facets.audioCodec.find((o: Loose) => o.value === "DDP").count).toBe(0);
expect(hit.facets.seeders.find((o: Loose) => o.value === "10-49").count).toBe(1);
// 站点资源列表:从资源表的解析结果 / 分类 / 大小 / 做种算,不请求站点
const before = nexusSearches();
const l = await list("");
expect(pick(l.facets)).toMatchObject({
audioCodec: { Atmos: 1, TrueHD: 1, AAC: 1 },
dub: { cmn: 1, unknown: 1 },
category: { 电影: 2 },
size: { "5-10": 1, "50+": 1 },
seeders: { "50+": 2 },
});
expect((await list("dub:cmn;category:电影")).total).toBe(1);
expect((await list("size:5-10;audioCodec:AAC")).items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
expect(nexusSearches()).toBe(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")];
+18 -1
View File
@@ -12,7 +12,10 @@ 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)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季);
* SEARCH-4 起:audioCodec(Atmos/TrueHD/DTS:X/DTS-HD/DTS/DDP/DD/AAC/FLAC/LPCM/other/unknown)、
* subtitle(zh/zh-Hans/zh-Hant/en/ja/ko/unknown)、dub(cmn/yue/en/unknown)、feature(10bit/hfr/cut)、
* year(年份)、category(站点分类名)、size(lt1/1-5/5-10/10-20/20-50/50+/unknown,GiB)、seeders(0/1-9/10-49/50+/unknown)。
*/
export const QuickFilterParam = z.string().max(4000).optional();
@@ -31,5 +34,19 @@ export const ReleaseQuickFacets = z.object({
/** 没有剧集资源时为空 */
season: z.array(FacetCount),
episode: z.array(FacetCount),
/** 音频编码(一条可属于多个;没写的归「未标注」) */
audioCodec: z.array(FacetCount),
/** 字幕:任何中文字幕都算 zh,简 / 繁另列 */
subtitle: z.array(FacetCount),
/** 配音:国语 / 粤语(MP 的 CNVOI / HKVOI) */
dub: z.array(FacetCount),
/** 特性:10bit / 50 帧以上 / 特别剪辑版;没有的不计入任何档 */
feature: z.array(FacetCount),
/** 年份,新的在前 */
year: z.array(FacetCount),
/** 站点分类名,计数最多的前 20 个 + 已选的 */
category: z.array(FacetCount),
size: z.array(FacetCount),
seeders: z.array(FacetCount),
});
export type ReleaseQuickFacets = z.infer<typeof ReleaseQuickFacets>;
+10
View File
@@ -79,6 +79,16 @@ export const SearchReleaseParsed = z.object({
videoCodec: z.string().nullable(),
hdr: z.array(z.string()),
releaseGroup: z.string().nullable(),
/** 音轨(`TrueHD`、`Atmos 7.1`…,SEARCH-4 快速筛选用);空 = 标题没写 */
audio: z.array(z.string()).optional(),
/** 字幕语言 zh / zh-Hans / zh-Hant / en / ja / ko;null = 标题没写 */
subtitles: z.array(z.string()).nullable().optional(),
/** 配音语言 cmn 国语 / yue 粤语 / en 英语;null = 标题没写 */
audioLangs: z.array(z.string()).nullable().optional(),
bitDepth: z.number().int().nullable().optional(),
fps: z.number().nullable().optional(),
/** 剪辑版(加长版、导演剪辑版…) */
cut: z.string().nullable().optional(),
});
export type SearchReleaseParsed = z.infer<typeof SearchReleaseParsed>;
+153 -2
View File
@@ -16,6 +16,15 @@ export const FACET_DIMS = [
"releaseGroup",
"season",
"episode",
// SEARCH-4:对齐 MP 过滤规则里的音频 / 中字 / 国语粤语配音 / 60 帧,再加年份、分类、大小、做种
"audioCodec",
"subtitle",
"dub",
"feature",
"year",
"category",
"size",
"seeders",
] as const;
export type FacetDim = (typeof FACET_DIMS)[number];
@@ -49,6 +58,21 @@ export interface FacetInput {
};
/** H&R 三态;缺省 / null 按未知 */
hr?: boolean | "unknown" | null;
/** 解析出的音轨(`TrueHD`、`Atmos 7.1`、`DDP 5.1`…);空 = 标题没写 */
audio?: readonly string[];
/** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);缺省 / 空 = 标题没写 */
subtitles?: readonly string[] | null;
/** 配音语言(cmn / yue / en);缺省 / 空 = 标题没写 */
audioLangs?: readonly string[] | null;
bitDepth?: number | null;
fps?: number | null;
/** 剪辑版(加长版、导演剪辑版、IMAX…) */
cut?: string | null;
year?: number | null;
/** 站点分类名 */
category?: string | null;
sizeBytes?: number | null;
seeders?: number | null;
}
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
@@ -92,10 +116,62 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
{ value: "no", label: "无" },
{ value: "unknown", label: "未知", hideEmpty: true },
],
audioCodec: [
{ value: "Atmos", label: "Atmos" },
{ value: "TrueHD", label: "TrueHD" },
{ value: "DTS:X", label: "DTS:X", hideEmpty: true },
{ value: "DTS-HD", label: "DTS-HD MA" },
{ value: "DTS", label: "DTS" },
{ value: "DDP", label: "DD+" },
{ value: "DD", label: "DD" },
{ value: "AAC", label: "AAC" },
{ value: "FLAC", label: "FLAC", hideEmpty: true },
{ value: "LPCM", label: "LPCM", hideEmpty: true },
{ value: "other", label: "其他", hideEmpty: true },
{ value: "unknown", label: "未标注", hideEmpty: true },
],
subtitle: [
{ value: "zh", label: "中文字幕" },
{ value: "zh-Hans", label: "简体" },
{ value: "zh-Hant", label: "繁体" },
{ value: "en", label: "英文" },
{ value: "ja", label: "日文", hideEmpty: true },
{ value: "ko", label: "韩文", hideEmpty: true },
{ value: "unknown", label: "未标注", hideEmpty: true },
],
dub: [
{ value: "cmn", label: "国语" },
{ value: "yue", label: "粤语" },
{ value: "en", label: "英语", hideEmpty: true },
{ value: "unknown", label: "未标注", hideEmpty: true },
],
feature: [
{ value: "10bit", label: "10bit", hideEmpty: true },
{ value: "hfr", label: "50 帧以上", hideEmpty: true },
{ value: "cut", label: "特别剪辑版", hideEmpty: true },
],
size: [
{ value: "lt1", label: "1 GB 以下" },
{ value: "1-5", label: "1–5 GB" },
{ value: "5-10", label: "5–10 GB" },
{ value: "10-20", label: "10–20 GB" },
{ value: "20-50", label: "20–50 GB" },
{ value: "50+", label: "50 GB 以上" },
{ value: "unknown", label: "未知", hideEmpty: true },
],
seeders: [
{ value: "0", label: "无人做种" },
{ value: "1-9", label: "1–9" },
{ value: "10-49", label: "10–49" },
{ value: "50+", label: "50 以上" },
{ value: "unknown", label: "未知", hideEmpty: true },
],
};
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
export const RELEASE_GROUP_TOP = 20;
/** 分类只列计数最多的前 N 个(已选的总会列出) */
export const CATEGORY_TOP = 20;
/** 一条资源最多展开多少集(防止异常集号范围撑爆计数) */
const MAX_EPISODES_PER_RELEASE = 200;
const MAX_SEASONS_PER_RELEASE = 50;
@@ -155,6 +231,71 @@ export function hrBucket(hr: FacetInput["hr"]): string {
return hr === true ? "yes" : hr === false ? "no" : "unknown";
}
const AUDIO_BUCKET: Record<string, string> = {
Atmos: "Atmos",
TrueHD: "TrueHD",
"DTS:X": "DTS:X",
"DTS-HD MA": "DTS-HD",
"DTS-HD HR": "DTS-HD",
"DTS-HD": "DTS-HD",
DTS: "DTS",
DDP: "DDP",
"E-AC3": "DDP",
DD: "DD",
AC3: "DD",
AAC: "AAC",
FLAC: "FLAC",
LPCM: "LPCM",
PCM: "LPCM",
};
/** 音频编码档位(一条可有多条音轨);声道(`5.1`)、音轨数(`2Audios`)不算编码 */
export function audioBuckets(audio: readonly string[] | undefined): string[] {
const out = new Set<string>();
for (const a of audio ?? []) {
const codec = a.replace(/\s*\d\.\d$/, "").trim();
if (!codec || /^\d+Audios?$/i.test(codec)) continue;
out.add(AUDIO_BUCKET[codec] ?? "other");
}
return out.size ? [...out] : ["unknown"];
}
/** 字幕档位:任何中文字幕都算「中文字幕」,简 / 繁另列 */
export function subtitleBuckets(subs: readonly string[] | null | undefined): string[] {
if (!subs?.length) return ["unknown"];
const out = new Set<string>();
for (const s of subs) {
if (s.startsWith("zh")) out.add("zh");
if (s === "zh-Hans" || s === "zh-Hant" || s === "en" || s === "ja" || s === "ko") out.add(s);
}
return out.size ? [...out] : ["unknown"];
}
export function dubBuckets(langs: readonly string[] | null | undefined): string[] {
const out = (langs ?? []).filter((l) => l === "cmn" || l === "yue" || l === "en");
return out.length ? [...new Set(out)] : ["unknown"];
}
export function featureBuckets(r: Pick<FacetInput, "bitDepth" | "fps" | "cut">): string[] {
const out: string[] = [];
if (r.bitDepth && r.bitDepth >= 10) out.push("10bit");
if (r.fps && r.fps >= 50) out.push("hfr");
if (r.cut) out.push("cut");
return out;
}
const GIB = 1024 ** 3;
export function sizeBucket(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return "unknown";
const g = bytes / GIB;
return g < 1 ? "lt1" : g < 5 ? "1-5" : g < 10 ? "5-10" : g < 20 ? "10-20" : g < 50 ? "20-50" : "50+";
}
export function seedersBucket(n: number | null | undefined): string {
if (n === null || n === undefined || !Number.isFinite(n) || n < 0) return "unknown";
return n === 0 ? "0" : n < 10 ? "1-9" : n < 50 ? "10-49" : "50+";
}
function seasonValues(r: FacetInput): string[] {
if (r.season === null || r.season === undefined) return [];
const end =
@@ -188,6 +329,14 @@ export function facetValues(r: FacetInput, now: number): Record<FacetDim, string
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
season: seasonValues(r),
episode: episodeValues(r),
audioCodec: audioBuckets(r.audio),
subtitle: subtitleBuckets(r.subtitles),
dub: dubBuckets(r.audioLangs),
feature: featureBuckets(r),
year: r.year ? [String(r.year)] : [],
category: r.category?.trim() ? [r.category.trim()] : [],
size: [sizeBucket(r.sizeBytes)],
seeders: [seedersBucket(r.seeders)],
};
}
@@ -290,9 +439,11 @@ function sortDynamic(
const n = (v: string) => (v === "pack" ? -1 : Number(v));
return options.sort((a, b) => n(a.value) - n(b.value)).map(strip);
}
// 年份新的在前
if (dim === "year") return options.sort((a, b) => Number(b.value) - Number(a.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);
if (dim !== "releaseGroup" && dim !== "category") return sorted.map(strip);
const head = sorted.slice(0, dim === "category" ? CATEGORY_TOP : top);
const extra = sorted.slice(top).filter((o) => chosen.has(norm(dim, o.value)));
return [...head, ...extra].map(strip);
}
+1 -1
View File
@@ -1,6 +1,6 @@
export { cnToInt } from "./cn-number.ts";
export { evalEpisodeOffset, normalizeOffsetExpression, OffsetExpressionError } from "./offset.ts";
export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanCnMarkers } from "./parse.ts";
export { type CnMarkers, confidenceOf, fileKindOf, parseRelease, scanAudioLangs, scanCnMarkers } from "./parse.ts";
export { mergeParsed, type PathSegments, parseReleasePath, splitReleasePath } from "./path.ts";
export { buildGroupRegex, RELEASE_GROUP_SEED } from "./release-groups.ts";
export * from "./types.ts";
@@ -188,6 +188,7 @@ export interface CnMarkers {
contentType?: ContentType;
variantTags: string[];
subtitles?: string[];
audioLangs?: string[];
evidence: string[];
}
@@ -204,6 +205,18 @@ const SUB_KEYWORDS =
/字幕|中字|双字|雙字|内封|內封|内嵌|內嵌|外挂|外掛|简繁|簡繁|简中|繁中|简体|简日|繁日|繁体|繁體|簡體|简英|繁英|中英|双语|雙語/u;
const AUDIO_LANG_RE = /国粤双语|國粵雙語|国英双语|國英雙語|国语配音|國語配音|国配|國配|国语|國語|粤语|粵語/gu;
/** 配音语言标记(国语 / 粤语 / 国英双语…)→ cmn / yue / en */
export function scanAudioLangs(text: string): string[] {
const out: string[] = [];
for (const m of text.matchAll(AUDIO_LANG_RE)) {
const t = m[0];
if (/[国國]/u.test(t) && !out.includes("cmn")) out.push("cmn");
if (/[粤粵]/u.test(t) && !out.includes("yue")) out.push("yue");
if (/英/u.test(t) && !out.includes("en")) out.push("en");
}
return out;
}
function scanSubtitleLangs(run: string): string[] {
const langs: string[] = [];
const add = (l: string) => {
@@ -261,6 +274,11 @@ export function scanCnMarkers(input: string): { s: string } & CnMarkers {
out.totalEpisodes = cnToInt((m[1] ?? m[2])!);
out.evidence.push("total:cn");
});
const dubs = scanAudioLangs(s);
if (dubs.length) {
out.audioLangs = dubs;
out.evidence.push("audio_lang:cn");
}
s = s.replace(AUDIO_LANG_RE, " ");
for (const [re, ct, ev] of CONTENT_RULES) {
take(re, () => {
@@ -1097,6 +1115,10 @@ function applyCnMarkers(d: Draft, cm: CnMarkers, prefix: string): void {
d.subtitles = [...new Set([...(d.subtitles ?? []), ...cm.subtitles])];
note("subtitle");
}
if (cm.audioLangs) {
d.audioLangs = [...new Set([...(d.audioLangs ?? []), ...cm.audioLangs])];
note("audio_lang");
}
}
function finalize(d: Draft, opts: ParseOptions): ParsedRelease {
@@ -109,6 +109,8 @@ export interface ParsedRelease {
part?: string;
/** 字幕语言(zh / zh-Hans / zh-Hant / en / ja / ko);undefined = 标题无信息(未知),不是"无字幕" */
subtitles?: string[];
/** 配音语言(cmn 国语 / yue 粤语 / en 英语,来自「国语」「国粤双语」等标记);undefined = 标题无信息 */
audioLangs?: string[];
sizeBytes?: number;
contentType: ContentType;
segment?: Segment;
+116
View File
@@ -1,11 +1,16 @@
import { describe, expect, it } from "vitest";
import {
applyFacets,
audioBuckets,
type FacetInput,
formatFacetParam,
parseFacetParam,
parseRelease,
promotionBuckets,
releaseTypeBucket,
seedersBucket,
sizeBucket,
subtitleBuckets,
} from "../src/index.ts";
// SEARCH-3 快速筛选:档位、计数(按其它维度已选条件)、同维度 OR / 跨维度 AND、qf 编解码
@@ -186,4 +191,115 @@ describe("release-facets", () => {
expect(parseFacetParam(s)).toEqual(sel);
expect(parseFacetParam("bogus:1;hdr:;hdr:DV,DV")).toEqual({ hdr: ["DV"] });
});
// ── SEARCH-4:对齐 MP 后新增的维度 ──
const GB = 1024 ** 3;
const rich = [
mk("a", {
audio: ["TrueHD", "Atmos 7.1"],
subtitles: ["zh-Hans", "en"],
audioLangs: ["cmn"],
bitDepth: 10,
year: 2024,
category: "电影",
sizeBytes: 60 * GB,
seeders: 80,
}),
mk("b", {
audio: ["DDP 5.1"],
subtitles: ["zh-Hant"],
audioLangs: ["cmn", "yue"],
fps: 60,
year: 2023,
category: "电影",
sizeBytes: 8 * GB,
seeders: 0,
}),
mk("c", { audio: ["DTS-HD MA 5.1", "2Audios"], year: 2024, category: "剧集", sizeBytes: 3 * GB, seeders: 12 }),
mk("d", { audio: [], cut: "Extended", sizeBytes: null, seeders: null }),
];
const run2 = (sel: Parameters<typeof applyFacets>[2]) => applyFacets(rich, (x) => x, sel, { now: NOW });
it("新维度档位与计数:音频 / 字幕 / 配音 / 特性 / 年份 / 分类 / 大小 / 做种", () => {
const r = run2({});
expect(counts(r.facets.audioCodec)).toEqual({
Atmos: 1,
TrueHD: 1,
"DTS-HD": 1,
DTS: 0,
DDP: 1,
DD: 0,
AAC: 0,
unknown: 1,
});
expect(r.facets.audioCodec.find((o) => o.value === "DDP")?.label).toBe("DD+");
expect(counts(r.facets.subtitle)).toEqual({ zh: 2, "zh-Hans": 1, "zh-Hant": 1, en: 1, unknown: 2 });
expect(counts(r.facets.dub)).toEqual({ cmn: 2, yue: 1, unknown: 2 });
expect(counts(r.facets.feature)).toEqual({ "10bit": 1, hfr: 1, cut: 1 });
// 年份新的在前;分类按条数
expect(r.facets.year.map((o) => [o.value, o.count])).toEqual([
["2024", 2],
["2023", 1],
]);
expect(r.facets.category.map((o) => [o.label, o.count])).toEqual([
["电影", 2],
["剧集", 1],
]);
expect(counts(r.facets.size)).toEqual({
lt1: 0,
"1-5": 1,
"5-10": 1,
"10-20": 0,
"20-50": 0,
"50+": 1,
unknown: 1,
});
expect(counts(r.facets.seeders)).toEqual({ "0": 1, "1-9": 0, "10-49": 1, "50+": 1, unknown: 1 });
});
it("新维度组合:同维度 OR、跨维度 AND,计数按其它维度已选条件", () => {
// 中文字幕 AND 国语 → a、b;再加 Atmos → 只剩 a
expect(ids(run2({ subtitle: ["zh"], dub: ["cmn"] }))).toEqual(["a", "b"]);
const r = run2({ subtitle: ["zh"], dub: ["cmn"], audioCodec: ["Atmos"] });
expect(ids(r)).toEqual(["a"]);
// 音频维度自己的计数不受已选 Atmos 影响:DD+ 仍可多出 b
expect(counts(r.facets.audioCodec).DDP).toBe(1);
// 同维度 OR:Atmos 或 DTS-HD
expect(ids(run2({ audioCodec: ["Atmos", "DTS-HD"] }))).toEqual(["a", "c"]);
// 年份 × 分类 × 做种
expect(ids(run2({ year: ["2024"], category: ["电影", "剧集"], seeders: ["10-49", "50+"] }))).toEqual(["a", "c"]);
expect(ids(run2({ size: ["unknown"], feature: ["cut"] }))).toEqual(["d"]);
// 已选但当前没有资源的年份列出、计数 0
expect(run2({ year: ["1999"] }).facets.year.find((o) => o.value === "1999")).toEqual({
value: "1999",
label: "1999",
count: 0,
});
});
it("新维度的归档函数", () => {
expect(audioBuckets(["E-AC3", "AC3 2.0", "PCM", "Opus", "5.1"])).toEqual(["DDP", "DD", "LPCM", "other"]);
expect(audioBuckets(["2Audios"])).toEqual(["unknown"]);
expect(subtitleBuckets(["zh"])).toEqual(["zh"]);
expect(subtitleBuckets(null)).toEqual(["unknown"]);
expect([0.5 * GB, GB, 49.9 * GB, 50 * GB, null, -1].map(sizeBucket)).toEqual([
"lt1",
"1-5",
"20-50",
"50+",
"unknown",
"unknown",
]);
expect([0, 9, 10, 50, null].map(seedersBucket)).toEqual(["0", "1-9", "10-49", "50+", "unknown"]);
});
it("解析器补齐配音语言(MP 的 CNVOI / HKVOI)", () => {
expect(parseRelease("Movie 2020 1080p 国语中字 WEB-DL").audioLangs).toEqual(["cmn"]);
expect(parseRelease("某剧 第01集 国粤双语 1080p").audioLangs).toEqual(["cmn", "yue"]);
expect(parseRelease("Movie.2020.1080p.BluRay.x264-GRP").audioLangs).toBeUndefined();
expect(
parseRelease("Movie.2020.2160p.BluRay.x265-GRP", { subtitle: "电影 | 国英双语 简繁字幕" }).audioLangs,
).toEqual(["cmn", "en"]);
});
});