对照 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
533 lines
19 KiB
TypeScript
533 lines
19 KiB
TypeScript
// 资源列表快速筛选(SEARCH-3,对照 MP 搜索结果筛选条):把解析字段归到固定档位,按档位统计计数并筛选。
|
||
// 搜索结果页(GET /search/releases)与站点资源列表(GET /sites/{id}/releases)共用同一套口径。
|
||
//
|
||
// 组合逻辑:同一维度多选为 OR,不同维度之间为 AND。
|
||
// 计数按"其它维度的已选条件"算(disjunctive facet):某个选项的计数 = 加上它之后本维度仍是 OR 时能看到的条数,
|
||
// 所以已选维度里的其它选项仍有计数。
|
||
// 选项只列结果里实际出现、且在其它条件下计数不为 0 的值(对照 MP useTorrentFilter.initOptions);已选的值总会列出。
|
||
|
||
export const FACET_DIMS = [
|
||
// 季集合并成一个维度(对照 MP season_episode):`S01`、`S01-S02`、`S02 E14`、`S01 E25-E48`
|
||
"seasonEpisode",
|
||
"resolution",
|
||
"videoCodec",
|
||
"releaseType",
|
||
"site",
|
||
"promotion",
|
||
"hdr",
|
||
"hr",
|
||
"releaseGroup",
|
||
"season",
|
||
"episode",
|
||
// SEARCH-4:对齐 MP 过滤规则里的音频 / 中字 / 国语粤语配音 / 60 帧,再加年份、分类、大小、做种
|
||
"audioCodec",
|
||
"subtitle",
|
||
"dub",
|
||
"feature",
|
||
"year",
|
||
"category",
|
||
"size",
|
||
"seeders",
|
||
] as const;
|
||
export type FacetDim = (typeof FACET_DIMS)[number];
|
||
|
||
/** 各维度已选的值;空数组 / 缺省 = 该维度不筛 */
|
||
export type FacetSelection = Partial<Record<FacetDim, string[]>>;
|
||
|
||
export interface FacetOption {
|
||
value: string;
|
||
label: string;
|
||
count: number;
|
||
}
|
||
export type FacetResult = Record<FacetDim, FacetOption[]>;
|
||
|
||
/** 计算档位需要的字段(搜索结果与资源表行都能取到) */
|
||
export interface FacetInput {
|
||
siteId: string;
|
||
siteName: string;
|
||
resolution: string | null;
|
||
source: string | null;
|
||
videoCodec: string | null;
|
||
hdr: readonly string[];
|
||
releaseGroup: string | null;
|
||
season: number | null;
|
||
seasonEnd?: number | null;
|
||
episodes: { start: number; end: number } | null;
|
||
promotion: {
|
||
type: string | null;
|
||
downloadFactor: number | null;
|
||
uploadFactor: number | null;
|
||
expiresAt: string | null;
|
||
};
|
||
/** 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;
|
||
}
|
||
|
||
/** 固定档位(顺序即展示顺序与选项名);只列出有资源的档位 */
|
||
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: "其他" },
|
||
],
|
||
videoCodec: [
|
||
{ value: "H.265", label: "H.265" },
|
||
{ value: "H.264", label: "H.264" },
|
||
{ value: "AV1", label: "AV1" },
|
||
{ value: "other", label: "其他" },
|
||
],
|
||
releaseType: [
|
||
{ value: "disc", label: "原盘" },
|
||
{ value: "remux", label: "Remux" },
|
||
{ value: "encode", label: "蓝光压制" },
|
||
{ value: "webdl", label: "WEB-DL" },
|
||
{ value: "hdtv", label: "HDTV" },
|
||
{ value: "other", label: "其他" },
|
||
],
|
||
promotion: [
|
||
{ value: "free", label: "免费" },
|
||
{ value: "2x", label: "2x" },
|
||
{ value: "50%", label: "50%" },
|
||
{ value: "other", label: "其他" },
|
||
{ value: "none", label: "无" },
|
||
],
|
||
hdr: [
|
||
{ value: "DV", label: "DV" },
|
||
{ value: "HDR10", label: "HDR10" },
|
||
{ value: "HDR10+", label: "HDR10+" },
|
||
{ value: "other", label: "其他 HDR" },
|
||
{ value: "SDR", label: "SDR" },
|
||
],
|
||
hr: [
|
||
{ value: "yes", label: "有" },
|
||
{ value: "no", label: "无" },
|
||
{ value: "unknown", label: "未知" },
|
||
],
|
||
audioCodec: [
|
||
{ value: "Atmos", label: "Atmos" },
|
||
{ value: "TrueHD", label: "TrueHD" },
|
||
{ 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" },
|
||
{ 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: "日文" },
|
||
{ value: "ko", label: "韩文" },
|
||
{ value: "unknown", label: "未标注" },
|
||
],
|
||
dub: [
|
||
{ value: "cmn", label: "国语" },
|
||
{ value: "yue", label: "粤语" },
|
||
{ value: "en", label: "英语" },
|
||
{ value: "unknown", label: "未标注" },
|
||
],
|
||
feature: [
|
||
{ value: "10bit", label: "10bit" },
|
||
{ value: "hfr", label: "50 帧以上" },
|
||
{ value: "cut", label: "特别剪辑版" },
|
||
],
|
||
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: "未知" },
|
||
],
|
||
seeders: [
|
||
{ value: "0", label: "无人做种" },
|
||
{ value: "1-9", label: "1–9" },
|
||
{ value: "10-49", label: "10–49" },
|
||
{ value: "50+", label: "50 以上" },
|
||
{ value: "unknown", label: "未知" },
|
||
],
|
||
};
|
||
|
||
/** 制作组只列计数最多的前 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;
|
||
|
||
export function resolutionBucket(res: string | null): string {
|
||
if (res === "2160p") return "2160p";
|
||
if (res === "1080p" || res === "1080i") return "1080p";
|
||
if (res === "720p") return "720p";
|
||
return "other";
|
||
}
|
||
|
||
export function codecBucket(codec: string | null): string {
|
||
return codec === "H.265" || codec === "H.264" || codec === "AV1" ? codec : "other";
|
||
}
|
||
|
||
/** 版本类型:按解析出的来源(BluRay = 蓝光压制,BluRay-Disc = 原盘) */
|
||
export function releaseTypeBucket(source: string | null): string {
|
||
switch (source) {
|
||
case "BluRay-Disc":
|
||
return "disc";
|
||
case "REMUX":
|
||
return "remux";
|
||
case "BluRay":
|
||
return "encode";
|
||
case "WEB-DL":
|
||
return "webdl";
|
||
case "HDTV":
|
||
return "hdtv";
|
||
default:
|
||
return "other";
|
||
}
|
||
}
|
||
|
||
/** 促销档位(一条资源可同时是免费和 2x);已过期的促销按"无" */
|
||
export function promotionBuckets(p: FacetInput["promotion"], now: number): string[] {
|
||
const type = p.type ?? "none";
|
||
if (p.expiresAt && Date.parse(p.expiresAt) <= now) return ["none"];
|
||
const out: string[] = [];
|
||
const d = p.downloadFactor;
|
||
const u = p.uploadFactor;
|
||
if (d === 0 || type === "free" || type === "2xfree") out.push("free");
|
||
if ((u !== null && u >= 2) || type === "2x" || type === "2xfree" || type === "2x50%") out.push("2x");
|
||
if (d === 0.5 || type === "50%" || type === "2x50%") out.push("50%");
|
||
if (out.length) return out;
|
||
if (type === "none" || type === "unknown" || !type) return d !== null && d > 0 && d < 1 ? ["other"] : ["none"];
|
||
return ["other"];
|
||
}
|
||
|
||
export function hdrBuckets(hdr: readonly string[]): string[] {
|
||
if (hdr.length === 0) return ["SDR"];
|
||
const out = new Set<string>();
|
||
for (const h of hdr) out.add(h === "DV" || h === "HDR10" || h === "HDR10+" ? h : "other");
|
||
return [...out];
|
||
}
|
||
|
||
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 =
|
||
r.seasonEnd && r.seasonEnd > r.season ? Math.min(r.seasonEnd, r.season + MAX_SEASONS_PER_RELEASE) : r.season;
|
||
const out: string[] = [];
|
||
for (let s = r.season; s <= end; s++) out.push(String(s));
|
||
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;
|
||
const last = Math.min(Math.max(start, end), start + MAX_EPISODES_PER_RELEASE);
|
||
const out: string[] = [];
|
||
for (let e = start; e <= last; e++) out.push(String(e));
|
||
return out;
|
||
}
|
||
return r.season !== null && r.season !== undefined ? ["pack"] : [];
|
||
}
|
||
|
||
/** 一条资源在各维度上的档位(制作组按原名,匹配时不分大小写) */
|
||
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)],
|
||
site: [r.siteId],
|
||
promotion: promotionBuckets(r.promotion, now),
|
||
hdr: hdrBuckets(r.hdr),
|
||
hr: [hrBucket(r.hr)],
|
||
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)],
|
||
};
|
||
}
|
||
|
||
const norm = (dim: FacetDim, v: string) => (dim === "releaseGroup" ? v.toLowerCase() : v);
|
||
|
||
function normalizeSelection(sel: FacetSelection): Map<FacetDim, Set<string>> {
|
||
const m = new Map<FacetDim, Set<string>>();
|
||
for (const dim of FACET_DIMS) {
|
||
const vals = sel[dim]?.filter(Boolean);
|
||
if (vals?.length) m.set(dim, new Set(vals.map((v) => norm(dim, v))));
|
||
}
|
||
return m;
|
||
}
|
||
|
||
function matches(values: Record<FacetDim, string[]>, sel: Map<FacetDim, Set<string>>, except?: FacetDim): boolean {
|
||
for (const [dim, want] of sel) {
|
||
if (dim === except) continue;
|
||
if (!values[dim].some((v) => want.has(norm(dim, v)))) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export interface FacetOutcome<T> {
|
||
/** 通过全部已选条件的条目(保持输入顺序) */
|
||
items: T[];
|
||
facets: FacetResult;
|
||
}
|
||
|
||
/**
|
||
* 按已选条件筛选并统计各维度计数。
|
||
* 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。
|
||
*/
|
||
export function applyFacets<T>(
|
||
items: readonly T[],
|
||
pick: (t: T) => FacetInput,
|
||
selection: FacetSelection,
|
||
opts: { now: number; groupTop?: number } = { now: Date.now() },
|
||
): FacetOutcome<T> {
|
||
const sel = normalizeSelection(selection);
|
||
const rows = items.map((t) => {
|
||
const input = pick(t);
|
||
return { t, input, values: facetValues(input, opts.now) };
|
||
});
|
||
const out = {} as FacetResult;
|
||
for (const dim of FACET_DIMS) {
|
||
const universe = new Map<string, { label: string; total: number }>();
|
||
for (const r of rows)
|
||
for (const v of r.values[dim]) {
|
||
const key = norm(dim, v);
|
||
const u = universe.get(key);
|
||
if (u) u.total++;
|
||
else universe.set(key, { label: labelOf(dim, v, r.input), total: 1 });
|
||
}
|
||
const counts = new Map<string, number>();
|
||
for (const r of rows) {
|
||
if (!matches(r.values, sel, dim)) continue;
|
||
for (const v of new Set(r.values[dim].map((x) => norm(dim, x)))) counts.set(v, (counts.get(v) ?? 0) + 1);
|
||
}
|
||
const chosen = sel.get(dim) ?? new Set<string>();
|
||
const fixed = FIXED[dim];
|
||
let options: FacetOption[];
|
||
if (fixed) {
|
||
options = fixed
|
||
.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()]
|
||
.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 })[],
|
||
chosen,
|
||
opts.groupTop ?? RELEASE_GROUP_TOP,
|
||
);
|
||
for (const v of new Set(selection[dim] ?? []))
|
||
if (v && !options.some((o) => norm(dim, o.value) === norm(dim, v)))
|
||
options.push({ value: v, label: labelOf(dim, v), count: 0 });
|
||
}
|
||
out[dim] = options;
|
||
}
|
||
return { items: rows.filter((r) => matches(r.values, sel)).map((r) => r.t), facets: out };
|
||
}
|
||
|
||
function sortDynamic(
|
||
dim: FacetDim,
|
||
options: (FacetOption & { total: number })[],
|
||
chosen: Set<string>,
|
||
top: number,
|
||
): FacetOption[] {
|
||
const strip = (o: FacetOption & { total: number }): FacetOption => ({
|
||
value: o.value,
|
||
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);
|
||
}
|
||
// 年份新的在前
|
||
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" && 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);
|
||
}
|
||
|
||
function labelOf(dim: FacetDim, v: string, input?: FacetInput): string {
|
||
switch (dim) {
|
||
case "site":
|
||
return input?.siteName ?? v;
|
||
case "season":
|
||
return `S${v.padStart(2, "0")}`;
|
||
case "episode":
|
||
return v === "pack" ? "整季" : `E${v.padStart(2, "0")}`;
|
||
default:
|
||
return FIXED[dim]?.find((f) => f.value === v)?.label ?? v;
|
||
}
|
||
}
|
||
|
||
/** 查询参数 qf 的格式:`维度:值,值;维度:值`,值经 encodeURIComponent(制作组名可能含 `,` `;` `:`) */
|
||
export function parseFacetParam(qf: string | undefined): FacetSelection {
|
||
const out: FacetSelection = {};
|
||
if (!qf) return out;
|
||
for (const part of qf.split(";")) {
|
||
const i = part.indexOf(":");
|
||
if (i <= 0) continue;
|
||
const dim = part.slice(0, i).trim() as FacetDim;
|
||
if (!(FACET_DIMS as readonly string[]).includes(dim)) continue;
|
||
const vals = part
|
||
.slice(i + 1)
|
||
.split(",")
|
||
.map((v) => {
|
||
try {
|
||
return decodeURIComponent(v).trim();
|
||
} catch {
|
||
return v.trim();
|
||
}
|
||
})
|
||
.filter(Boolean);
|
||
if (vals.length) out[dim] = [...new Set([...(out[dim] ?? []), ...vals])];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function formatFacetParam(sel: FacetSelection): string {
|
||
return FACET_DIMS.filter((d) => sel[d]?.length)
|
||
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
|
||
.join(";");
|
||
}
|