diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index edf4be3f..8ff26f65 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -295,6 +295,12 @@ function toBase(site: Pick, 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 多选筛选并统计分面 */ diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index f138a282..cce01668 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -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): FacetInput { @@ -143,6 +152,16 @@ export function lightFacetInput(r: LightRow, site: Pick) 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`r.promotion`.as("promotion"), "r.promotion_expires_at", sql`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) { diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index 8fe4c90e..b75f2c72 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -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")]; diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts index 7a018afe..a88d7810 100644 --- a/packages/contracts/src/release-facets.ts +++ b/packages/contracts/src/release-facets.ts @@ -12,7 +12,10 @@ 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)、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; diff --git a/packages/contracts/src/search.ts b/packages/contracts/src/search.ts index 7de5ebae..4d13884c 100644 --- a/packages/contracts/src/search.ts +++ b/packages/contracts/src/search.ts @@ -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; diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts index 4a8c169f..606cdf87 100644 --- a/packages/domain/src/release-facets/index.ts +++ b/packages/domain/src/release-facets/index.ts @@ -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 = { + 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(); + 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(); + 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): 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 (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); } diff --git a/packages/domain/src/release-parser/index.ts b/packages/domain/src/release-parser/index.ts index ad3f3067..9257e722 100644 --- a/packages/domain/src/release-parser/index.ts +++ b/packages/domain/src/release-parser/index.ts @@ -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"; diff --git a/packages/domain/src/release-parser/parse.ts b/packages/domain/src/release-parser/parse.ts index e554f379..5b581531 100644 --- a/packages/domain/src/release-parser/parse.ts +++ b/packages/domain/src/release-parser/parse.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 { diff --git a/packages/domain/src/release-parser/types.ts b/packages/domain/src/release-parser/types.ts index a4f8f398..c6b5a66e 100644 --- a/packages/domain/src/release-parser/types.ts +++ b/packages/domain/src/release-parser/types.ts @@ -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; diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts index d9a72bb5..ff809cf6 100644 --- a/packages/domain/test/release-facets.test.ts +++ b/packages/domain/test/release-facets.test.ts @@ -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[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"]); + }); });