diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index efadc6af..d8371e38 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -437,14 +437,16 @@ describe.skipIf(!up)("全局搜索", () => { let d = await q(""); expect(d.total).toBe(3); - expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 }); - expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 }); - expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 }); + // 只列结果里有的档位 + expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1 }); + expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 }); + expect(counts(d.facets.releaseType)).toEqual({ remux: 1, webdl: 2 }); expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 }); expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众"); expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 }); expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2); expect(d.facets.season).toEqual([]); + expect(d.facets.seasonEpisode).toEqual([]); expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3); // 旧分面仍在 expect(d.facets.resolutions.length).toBe(2); @@ -500,10 +502,8 @@ describe.skipIf(!up)("全局搜索", () => { d = await list("resolution:1080p"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 }); - expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([ - ["FraMeSToR", 0], - ["GRP", 1], - ]); + // 在 1080p 下没有资源的制作组不列 + expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([["GRP", 1]]); // 分页 const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data); expect(p1.items.length).toBe(1); diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx index d1dc7de0..d5c6f197 100644 --- a/apps/web/app/components/search/quick-filter-bar.tsx +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -20,7 +20,8 @@ import { } from "./quick-filter-state.ts"; // 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选; -// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。 +// 选项只列结果里有的值(服务端不给 0 计数的选项),带计数(按其它维度的已选条件);已选但当前为 0 的置灰; +// 已选条件以可移除标签列出,只有一个「清除」。季集在最前(整季在前、单集在后)。 // 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。 // POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。 // SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R) @@ -62,7 +63,7 @@ export function QuickFilterBar({ const dims = g.dims.filter((d) => !hide.includes(d.dim)); if (!dims.length) return false; const opts = dims.flatMap((d) => facets?.[d.dim] ?? []); - // 站点只有一个时不用筛;季/集只在有剧集资源时出现 + // 站点只有一个时不用筛;季集只在有剧集资源时出现 if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false; return ( opts.length > 0 || diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts index 44b51dcc..0d297ec7 100644 --- a/apps/web/app/components/search/quick-filter-state.test.ts +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -16,6 +16,7 @@ import { // SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位 const empty = { + seasonEpisode: [], resolution: [], videoCodec: [], releaseType: [], @@ -41,9 +42,11 @@ describe("快速筛选状态", () => { expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe( "hdr:DV;resolution:2160p;releaseGroup:A%2CB", ); - expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe( - "site:s;audioCodec:TrueHD;seeders:0", + expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"], seasonEpisode: ["S01"] })).toBe( + "seasonEpisode:S01;site:s;audioCodec:TrueHD;seeders:0", ); + // 旧的季 / 集维度排在最后,仍会带上 + expect(formatQuickFilter({ season: ["2"], resolution: ["2160p"] })).toBe("resolution:2160p;season:2"); expect(formatQuickFilter({ resolution: [] })).toBeUndefined(); }); @@ -57,11 +60,16 @@ describe("快速筛选状态", () => { expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1); }); - it("已选标签取分面里的选项名,取不到用原值", () => { - const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] }; - expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([ - { dim: "season", value: "2", dimLabel: "季", label: "2" }, + it("已选标签取分面里的选项名,取不到用原值;季集在最前,旧的季 / 集在最后", () => { + const facets = { + ...empty, + releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }], + seasonEpisode: [{ value: "S02 E14", label: "S02 E14", count: 1 }], + }; + expect(selectedTags({ releaseType: ["encode"], season: ["2"], seasonEpisode: ["S02 E14"] }, facets)).toEqual([ + { dim: "seasonEpisode", value: "S02 E14", dimLabel: "季集", label: "S02 E14" }, { dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" }, + { dim: "season", value: "2", dimLabel: "季", label: "2" }, ]); }); @@ -86,7 +94,7 @@ describe("快速筛选状态", () => { }); }); - it("「更多筛选」收的是 MP 筛选条之外的维度", () => { + it("「更多筛选」收的是 MP 筛选条之外的维度;季集放最前", () => { expect(MORE_DIMS).toEqual([ "audioCodec", "subtitle", @@ -99,8 +107,8 @@ describe("快速筛选状态", () => { "hr", ]); expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([ + "季集", "站点", - "季/集", "促销", "编码", "质量", diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts index 38db6280..fc589261 100644 --- a/apps/web/app/components/search/quick-filter-state.ts +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -8,9 +8,10 @@ export type QuickDim = keyof ReleaseQuickFacets; export type QuickSelection = Partial>; /** - * 筛选条上的一个入口;「季/集」一个入口里分两节。 + * 筛选条上的一个入口。 * more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面 - * (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。 + * (季集放最前,再是站点、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。 + * 季集是一个维度(MP 的 season_episode):整季在前、单集在后,服务端排好序。 */ export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean }; @@ -22,15 +23,8 @@ const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({ }); export const QUICK_GROUPS: QuickGroup[] = [ + one("seasonEpisode", "季集"), one("site", "站点"), - { - key: "seasonEpisode", - label: "季/集", - dims: [ - { dim: "season", label: "季" }, - { dim: "episode", label: "集" }, - ], - }, one("promotion", "促销"), one("videoCodec", "编码"), one("releaseType", "质量"), @@ -51,7 +45,16 @@ export const QUICK_GROUPS: QuickGroup[] = [ export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more); export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); -const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); +/** 旧的季 / 集两个维度:筛选条上不再出现,旧链接带着时仍生效,并以可删除的标签列出 */ +const LEGACY_DIMS: { dim: QuickDim; label: string }[] = [ + { dim: "season", label: "季" }, + { dim: "episode", label: "集" }, +]; + +const DIM_ORDER: QuickDim[] = [ + ...QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)), + ...LEGACY_DIMS.map((d) => d.dim), +]; /** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */ const PERSISTED: QuickDim[] = [ @@ -97,7 +100,9 @@ export function selectedTags( sel: QuickSelection, facets: ReleaseQuickFacets | undefined, ): { dim: QuickDim; value: string; dimLabel: string; label: string }[] { - const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const))); + const dimLabel = new Map( + [...QUICK_GROUPS.flatMap((g) => g.dims), ...LEGACY_DIMS].map((d) => [d.dim, d.label] as const), + ); return DIM_ORDER.flatMap((dim) => (sel[dim] ?? []).map((value) => ({ dim, diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts index a88d7810..4441c8a5 100644 --- a/packages/contracts/src/release-facets.ts +++ b/packages/contracts/src/release-facets.ts @@ -3,14 +3,14 @@ import { NonNegInt } from "./common.ts"; // 资源列表快速筛选(SEARCH-3):GET /search/releases 与 GET /sites/{id}/releases 共用。 // 口径实现在 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND; -// 计数按其它维度的已选条件统计(0 = 在其它条件下没有结果)。 +// 计数按其它维度的已选条件统计;选项只列计数不为 0 的值(已选的值总会列出)。 export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt }); export type FacetCount = z.infer; /** * 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。 - * 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、 + * 维度:seasonEpisode(季集,MP 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`)、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 整季); * SEARCH-4 起:audioCodec(Atmos/TrueHD/DTS:X/DTS-HD/DTS/DDP/DD/AAC/FLAC/LPCM/other/unknown)、 @@ -20,6 +20,8 @@ export type FacetCount = z.infer; export const QuickFilterParam = z.string().max(4000).optional(); export const ReleaseQuickFacets = z.object({ + /** 季集:整季在前、单集在后,季号新的在前(对照 MP);没有剧集资源时为空 */ + seasonEpisode: z.array(FacetCount), resolution: z.array(FacetCount), videoCodec: z.array(FacetCount), releaseType: z.array(FacetCount), diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts index 606cdf87..8b97b2bf 100644 --- a/packages/domain/src/release-facets/index.ts +++ b/packages/domain/src/release-facets/index.ts @@ -3,9 +3,12 @@ // // 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。 // 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数, -// 所以已选维度里的其它选项仍有计数,0 计数表示在其它条件下没有结果。 +// 所以已选维度里的其它选项仍有计数。 +// 选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP useTorrentFilter.initOptions);已选的值总会列出。 export const FACET_DIMS = [ + // 季集合并成一个维度(对照 MP season_episode):`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48` + "seasonEpisode", "resolution", "videoCodec", "releaseType", @@ -75,19 +78,19 @@ export interface FacetInput { seeders?: number | null; } -/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */ -const FIXED: Partial> = { +/** 固定档位(顺序即展示顺序与选项名);只列出有资源的档位 */ +const FIXED: Partial> = { resolution: [ { value: "2160p", label: "2160p" }, { value: "1080p", label: "1080p" }, { value: "720p", label: "720p" }, - { value: "other", label: "其他", hideEmpty: true }, + { value: "other", label: "其他" }, ], videoCodec: [ { value: "H.265", label: "H.265" }, { value: "H.264", label: "H.264" }, { value: "AV1", label: "AV1" }, - { value: "other", label: "其他", hideEmpty: true }, + { value: "other", label: "其他" }, ], releaseType: [ { value: "disc", label: "原盘" }, @@ -95,60 +98,60 @@ const FIXED: Partial String(n).padStart(2, "0"); + +/** 季集(MP 的 season_episode 写法):`S01`、`S01-S03`、`S01 E05`、`S01 E01-E12`;没有季集信息为空 */ +export function seasonEpisodeValue(r: Pick): string | null { + if (r.season === null || r.season === undefined) return null; + const s = r.seasonEnd && r.seasonEnd > r.season ? `S${pad2(r.season)}-S${pad2(r.seasonEnd)}` : `S${pad2(r.season)}`; + if (!r.episodes) return s; + const { start, end } = r.episodes; + return end > start ? `${s} E${pad2(start)}-E${pad2(end)}` : `${s} E${pad2(start)}`; +} + +const SEASON_EPISODE = /^S(\d+)(?:-S(\d+))?(?:\s+E(\d+)(?:-E(\d+))?)?$/; + +/** + * 季集选项排序(照 MP sortSeasonOptions):整季在前、单集在后;整季按季号新→旧, + * 单集按季号新→旧、再按最大集号大→小、起始集号大→小;认不出的写法排在最后。 + */ +export function compareSeasonEpisode(a: string, b: string): number { + const key = (v: string) => { + const m = SEASON_EPISODE.exec(v); + if (!m) return { ok: false, whole: false, season: 0, ep: 0, maxEp: 0 }; + const ep = m[3] ? Number(m[3]) : 0; + return { ok: true, whole: !m[3], season: Number(m[1]), ep, maxEp: m[4] ? Number(m[4]) : ep }; + }; + const x = key(a); + const y = key(b); + if (x.ok !== y.ok) return x.ok ? -1 : 1; + if (x.whole !== y.whole) return x.whole ? -1 : 1; + return y.season - x.season || y.maxEp - x.maxEp || y.ep - x.ep; +} + function episodeValues(r: FacetInput): string[] { if (r.episodes) { const { start, end } = r.episodes; @@ -318,7 +352,9 @@ function episodeValues(r: FacetInput): string[] { /** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */ export function facetValues(r: FacetInput, now: number): Record { + const se = seasonEpisodeValue(r); return { + seasonEpisode: se ? [se] : [], resolution: [resolutionBucket(r.resolution)], videoCodec: [codecBucket(r.videoCodec)], releaseType: [releaseTypeBucket(r.source)], @@ -367,7 +403,7 @@ export interface FacetOutcome { /** * 按已选条件筛选并统计各维度计数。 - * 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。 + * 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。 */ export function applyFacets( items: readonly T[], @@ -400,15 +436,17 @@ export function applyFacets( let options: FacetOption[]; if (fixed) { options = fixed - .filter((f) => !f.hideEmpty || universe.has(f.value) || chosen.has(f.value)) + .filter((f) => (counts.get(f.value) ?? 0) > 0 || 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 = [...universe.entries()] + .filter(([value]) => (counts.get(value) ?? 0) > 0 || chosen.has(value)) + .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 })[], @@ -435,6 +473,7 @@ function sortDynamic( label: o.label, count: o.count, }); + if (dim === "seasonEpisode") return options.sort((a, b) => compareSeasonEpisode(a.value, b.value)).map(strip); 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); diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts index ff809cf6..a5e06191 100644 --- a/packages/domain/test/release-facets.test.ts +++ b/packages/domain/test/release-facets.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { applyFacets, audioBuckets, + compareSeasonEpisode, type FacetInput, formatFacetParam, parseFacetParam, parseRelease, promotionBuckets, releaseTypeBucket, + seasonEpisodeValue, seedersBucket, sizeBucket, subtitleBuckets, @@ -76,8 +78,9 @@ describe("release-facets", () => { 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 }); + // 结果里没有的档位不列出(HDR10+、50%) + expect(counts(r.facets.hdr)).toEqual({ DV: 1, HDR10: 2, SDR: 3 }); + expect(counts(r.facets.promotion)).toEqual({ free: 2, "2x": 1, none: 3 }); expect(r.facets.site).toEqual([ { value: "a", label: "站 A", count: 3 }, { value: "b", label: "站 B", count: 2 }, @@ -88,24 +91,25 @@ describe("release-facets", () => { { value: "FraMeSToR", label: "FraMeSToR", count: 1 }, ]); expect(r.facets.season).toEqual([]); + expect(r.facets.seasonEpisode).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 }); + // 分辨率的计数只受编码条件影响:1080p 在 H.265 下没有结果,但已选,仍列出(计数 0);720p / 其他不列 + expect(counts(r.facets.resolution)).toEqual({ "2160p": 2, "1080p": 0 }); + // 编码计数只受分辨率条件影响:在 2160p / 1080p 下没有 AV1 / 其他,不列 + expect(counts(r.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 }); + // 其它维度按全部条件:计数为 0 的档位不列 + expect(counts(r.facets.releaseType)).toEqual({ remux: 1, webdl: 1 }); 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("H&R 三态分面:缺省按未知;未知档只在有资源时列出", () => { + it("H&R 三态分面:缺省按未知;只列有资源的档", () => { const hrItems = [mk("y", { hr: true }), mk("n", { hr: false }), mk("n2", { hr: false }), mk("u")]; const r = applyFacets(hrItems, (x) => x, {}, { now: NOW }); expect(r.facets.hr).toEqual([ @@ -115,7 +119,7 @@ describe("release-facets", () => { ]); expect(ids(applyFacets(hrItems, (x) => x, { hr: ["no", "unknown"] }, { now: NOW }))).toEqual(["n", "n2", "u"]); const known = applyFacets([mk("y", { hr: true })], (x) => x, {}, { now: NOW }); - expect(known.facets.hr.map((o) => o.value)).toEqual(["yes", "no"]); + expect(known.facets.hr.map((o) => o.value)).toEqual(["yes"]); }); it("已选但没有结果的值仍列出(计数 0)", () => { @@ -150,14 +154,56 @@ describe("release-facets", () => { { 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 }, - ]); + // 在 S02 下单集没有结果,不列 + expect(r.facets.episode).toEqual([{ value: "pack", label: "整季", count: 1 }]); expect(ids(applyFacets(tv, (x) => x, { season: ["1"], episode: ["3"] }, { now: NOW }))).toEqual(["s1e2"]); }); + it("季集(MP season_episode):一个维度,整季在前、单集在后,季号新的在前", () => { + const tv = [ + mk("s1", { season: 1, episodes: null }), + mk("s1b", { season: 1, episodes: null }), + mk("s2", { season: 2, episodes: null }), + mk("s1-3", { season: 1, seasonEnd: 3, episodes: null }), + mk("s1e25", { season: 1, episodes: { start: 25, end: 48 } }), + mk("s2e14", { season: 2, episodes: { start: 14, end: 14 } }), + mk("s2e1", { season: 2, episodes: { start: 1, end: 1 } }), + mk("s2e14-15", { season: 2, episodes: { start: 14, end: 15 } }), + mk("movie"), + ]; + const r = applyFacets(tv, (x) => x, {}, { now: NOW }); + expect(r.facets.seasonEpisode.map((o) => [o.value, o.count])).toEqual([ + ["S02", 1], + ["S01", 2], + ["S01-S03", 1], + ["S02 E14-E15", 1], + ["S02 E14", 1], + ["S02 E01", 1], + ["S01 E25-E48", 1], + ]); + expect(r.facets.seasonEpisode[0]?.label).toBe("S02"); + expect(ids(applyFacets(tv, (x) => x, { seasonEpisode: ["S01", "S02 E14"] }, { now: NOW }))).toEqual([ + "s1", + "s1b", + "s2e14", + ]); + // 计数按其它维度已选条件;在其它条件下为 0 的不列 + const free = applyFacets( + tv.map((t) => (t.id === "s2" ? { ...t, promotion: { ...none, type: "free", downloadFactor: 0 } } : t)), + (x) => x, + { promotion: ["free"] }, + { now: NOW }, + ); + expect(free.facets.seasonEpisode).toEqual([{ value: "S02", label: "S02", count: 1 }]); + expect(seasonEpisodeValue({ season: null, episodes: { start: 1, end: 2 } })).toBeNull(); + expect(["S01 E02", "bogus", "S10", "S02 E10-E12"].sort(compareSeasonEpisode)).toEqual([ + "S10", + "S02 E10-E12", + "S01 E02", + "bogus", + ]); + }); + it("促销与版本类型档位", () => { expect(promotionBuckets({ type: "2x50%", downloadFactor: 0.5, uploadFactor: 2, expiresAt: null }, NOW)).toEqual([ "2x", @@ -227,10 +273,7 @@ describe("release-facets", () => { 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+"); @@ -246,16 +289,8 @@ describe("release-facets", () => { ["电影", 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 }); + expect(counts(r.facets.size)).toEqual({ "1-5": 1, "5-10": 1, "50+": 1, unknown: 1 }); + expect(counts(r.facets.seeders)).toEqual({ "0": 1, "10-49": 1, "50+": 1, unknown: 1 }); }); it("新维度组合:同维度 OR、跨维度 AND,计数按其它维度已选条件", () => {