Files
UHD_Media_Tool/packages/domain/src/release-facets/index.ts
T
rootandClaude Opus 5.5 f6d976a963 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
2026-10-06 15:39:32 +08:00

533 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 资源列表快速筛选(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(";");
}