feat(search): 筛选选项只列结果里有的值,季集合并成一个维度放最前

对照 MP useTorrentFilter:分面选项按实际结果生成,计数为 0 的不再列出(已选的值仍列出,可删除);
新增季集维度 seasonEpisode(MP season_episode 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`),
整季在前、单集在后、季号新的在前,在筛选条最前。旧的季 / 集维度保留(旧链接仍生效,以标签列出)。
搜索结果页与站点资源 Tab 共用这套口径。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:39:32 +08:00
1 parent 18ee466664
commit f6d976a963
7 files changed
+181 -91

No files matched your search

+4 -2
View File
@@ -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<typeof FacetCount>;
/**
* 快速筛选参数 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<typeof FacetCount>;
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),
+71 -32
View File
@@ -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<Record<FacetDim, readonly { value: string; label: string; hideEmpty?: boolean }[]>> = {
/** 固定档位(顺序即展示顺序与选项名);只列出有资源的档位 */
const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string }[]>> = {
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<Record<FacetDim, readonly { value: string; label: string; h
{ value: "encode", label: "蓝光压制" },
{ value: "webdl", label: "WEB-DL" },
{ value: "hdtv", label: "HDTV" },
{ value: "other", label: "其他", hideEmpty: true },
{ value: "other", label: "其他" },
],
promotion: [
{ value: "free", label: "免费" },
{ value: "2x", label: "2x" },
{ value: "50%", label: "50%" },
{ value: "other", label: "其他", hideEmpty: true },
{ value: "other", label: "其他" },
{ value: "none", label: "无" },
],
hdr: [
{ value: "DV", label: "DV" },
{ value: "HDR10", label: "HDR10" },
{ value: "HDR10+", label: "HDR10+" },
{ value: "other", label: "其他 HDR", hideEmpty: true },
{ value: "other", label: "其他 HDR" },
{ value: "SDR", label: "SDR" },
],
hr: [
{ value: "yes", label: "有" },
{ value: "no", label: "无" },
{ value: "unknown", label: "未知", hideEmpty: true },
{ value: "unknown", label: "未知" },
],
audioCodec: [
{ value: "Atmos", label: "Atmos" },
{ value: "TrueHD", label: "TrueHD" },
{ value: "DTS:X", label: "DTS:X", hideEmpty: true },
{ value: "DTS:X", label: "DTS:X" },
{ 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 },
{ value: "FLAC", label: "FLAC" },
{ value: "LPCM", label: "LPCM" },
{ value: "other", label: "其他" },
{ value: "unknown", label: "未标注" },
],
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 },
{ value: "ja", label: "日文" },
{ value: "ko", label: "韩文" },
{ value: "unknown", label: "未标注" },
],
dub: [
{ value: "cmn", label: "国语" },
{ value: "yue", label: "粤语" },
{ value: "en", label: "英语", hideEmpty: true },
{ value: "unknown", label: "未标注", hideEmpty: true },
{ value: "en", label: "英语" },
{ value: "unknown", label: "未标注" },
],
feature: [
{ value: "10bit", label: "10bit", hideEmpty: true },
{ value: "hfr", label: "50 帧以上", hideEmpty: true },
{ value: "cut", label: "特别剪辑版", hideEmpty: true },
{ value: "10bit", label: "10bit" },
{ value: "hfr", label: "50 帧以上" },
{ value: "cut", label: "特别剪辑版" },
],
size: [
{ value: "lt1", label: "1 GB 以下" },
@@ -157,14 +160,14 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
{ value: "10-20", label: "10–20 GB" },
{ value: "20-50", label: "20–50 GB" },
{ value: "50+", label: "50 GB 以上" },
{ value: "unknown", label: "未知", hideEmpty: true },
{ value: "unknown", label: "未知" },
],
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 },
{ value: "unknown", label: "未知" },
],
};
@@ -305,6 +308,37 @@ function seasonValues(r: FacetInput): string[] {
return out;
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 季集(MP 的 season_episode 写法):`S01`、`S01-S03`、`S01 E05`、`S01 E01-E12`;没有季集信息为空 */
export function seasonEpisodeValue(r: Pick<FacetInput, "season" | "seasonEnd" | "episodes">): 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<FacetDim, string[]> {
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<T> {
/**
* 按已选条件筛选并统计各维度计数。
* 选项来自全部输入条目(固定档位总会列出,"其他"类只在有资源时列出);已选但当前没有资源的值也会列出(计数 0)。
* 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。
*/
export function applyFacets<T>(
items: readonly T[],
@@ -400,15 +436,17 @@ export function applyFacets<T>(
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);
+63 -28
View File
@@ -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,计数按其它维度已选条件", () => {