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

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