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

+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;