// 资源列表快速筛选(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>; export interface FacetOption { value: string; label: string; count: number; } export type FacetResult = Record; /** 计算档位需要的字段(搜索结果与资源表行都能取到) */ 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> = { 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(); 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 = { 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(); 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(); 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): 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): 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 { 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> { const m = new Map>(); 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, sel: Map>, 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 { /** 通过全部已选条件的条目(保持输入顺序) */ items: T[]; facets: FacetResult; } /** * 按已选条件筛选并统计各维度计数。 * 选项只列在其它维度已选条件下计数不为 0 的值(固定档位按固定顺序);已选的值总会列出(当前没有资源时计数 0)。 */ export function applyFacets( items: readonly T[], pick: (t: T) => FacetInput, selection: FacetSelection, opts: { now: number; groupTop?: number } = { now: Date.now() }, ): FacetOutcome { 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(); 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(); 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(); 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, 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(";"); }