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:
1 parent
18ee466664
commit
f6d976a963
7 files changed
+176
-86
No files matched your search
@@ -437,14 +437,16 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
|
||||
let d = await q("");
|
||||
expect(d.total).toBe(3);
|
||||
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 });
|
||||
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 });
|
||||
expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 });
|
||||
// 只列结果里有的档位
|
||||
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1 });
|
||||
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1 });
|
||||
expect(counts(d.facets.releaseType)).toEqual({ remux: 1, webdl: 2 });
|
||||
expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 });
|
||||
expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众");
|
||||
expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 });
|
||||
expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2);
|
||||
expect(d.facets.season).toEqual([]);
|
||||
expect(d.facets.seasonEpisode).toEqual([]);
|
||||
expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3);
|
||||
// 旧分面仍在
|
||||
expect(d.facets.resolutions.length).toBe(2);
|
||||
@@ -500,10 +502,8 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
d = await list("resolution:1080p");
|
||||
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
|
||||
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([
|
||||
["FraMeSToR", 0],
|
||||
["GRP", 1],
|
||||
]);
|
||||
// 在 1080p 下没有资源的制作组不列
|
||||
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([["GRP", 1]]);
|
||||
// 分页
|
||||
const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data);
|
||||
expect(p1.items.length).toBe(1);
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
} from "./quick-filter-state.ts";
|
||||
|
||||
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
|
||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
||||
// 选项只列结果里有的值(服务端不给 0 计数的选项),带计数(按其它维度的已选条件);已选但当前为 0 的置灰;
|
||||
// 已选条件以可移除标签列出,只有一个「清除」。季集在最前(整季在前、单集在后)。
|
||||
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
||||
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
||||
// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R)
|
||||
@@ -62,7 +63,7 @@ export function QuickFilterBar({
|
||||
const dims = g.dims.filter((d) => !hide.includes(d.dim));
|
||||
if (!dims.length) return false;
|
||||
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
|
||||
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
|
||||
// 站点只有一个时不用筛;季集只在有剧集资源时出现
|
||||
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
|
||||
return (
|
||||
opts.length > 0 ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
|
||||
|
||||
const empty = {
|
||||
seasonEpisode: [],
|
||||
resolution: [],
|
||||
videoCodec: [],
|
||||
releaseType: [],
|
||||
@@ -41,9 +42,11 @@ describe("快速筛选状态", () => {
|
||||
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
|
||||
"hdr:DV;resolution:2160p;releaseGroup:A%2CB",
|
||||
);
|
||||
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe(
|
||||
"site:s;audioCodec:TrueHD;seeders:0",
|
||||
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"], seasonEpisode: ["S01"] })).toBe(
|
||||
"seasonEpisode:S01;site:s;audioCodec:TrueHD;seeders:0",
|
||||
);
|
||||
// 旧的季 / 集维度排在最后,仍会带上
|
||||
expect(formatQuickFilter({ season: ["2"], resolution: ["2160p"] })).toBe("resolution:2160p;season:2");
|
||||
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -57,11 +60,16 @@ describe("快速筛选状态", () => {
|
||||
expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1);
|
||||
});
|
||||
|
||||
it("已选标签取分面里的选项名,取不到用原值", () => {
|
||||
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
|
||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
|
||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
||||
it("已选标签取分面里的选项名,取不到用原值;季集在最前,旧的季 / 集在最后", () => {
|
||||
const facets = {
|
||||
...empty,
|
||||
releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }],
|
||||
seasonEpisode: [{ value: "S02 E14", label: "S02 E14", count: 1 }],
|
||||
};
|
||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"], seasonEpisode: ["S02 E14"] }, facets)).toEqual([
|
||||
{ dim: "seasonEpisode", value: "S02 E14", dimLabel: "季集", label: "S02 E14" },
|
||||
{ dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" },
|
||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -86,7 +94,7 @@ describe("快速筛选状态", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("「更多筛选」收的是 MP 筛选条之外的维度", () => {
|
||||
it("「更多筛选」收的是 MP 筛选条之外的维度;季集放最前", () => {
|
||||
expect(MORE_DIMS).toEqual([
|
||||
"audioCodec",
|
||||
"subtitle",
|
||||
@@ -99,8 +107,8 @@ describe("快速筛选状态", () => {
|
||||
"hr",
|
||||
]);
|
||||
expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([
|
||||
"季集",
|
||||
"站点",
|
||||
"季/集",
|
||||
"促销",
|
||||
"编码",
|
||||
"质量",
|
||||
|
||||
@@ -8,9 +8,10 @@ export type QuickDim = keyof ReleaseQuickFacets;
|
||||
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
|
||||
|
||||
/**
|
||||
* 筛选条上的一个入口;「季/集」一个入口里分两节。
|
||||
* 筛选条上的一个入口。
|
||||
* more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面
|
||||
* (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
|
||||
* (季集放最前,再是站点、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
|
||||
* 季集是一个维度(MP 的 season_episode):整季在前、单集在后,服务端排好序。
|
||||
*/
|
||||
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean };
|
||||
|
||||
@@ -22,15 +23,8 @@ const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({
|
||||
});
|
||||
|
||||
export const QUICK_GROUPS: QuickGroup[] = [
|
||||
one("seasonEpisode", "季集"),
|
||||
one("site", "站点"),
|
||||
{
|
||||
key: "seasonEpisode",
|
||||
label: "季/集",
|
||||
dims: [
|
||||
{ dim: "season", label: "季" },
|
||||
{ dim: "episode", label: "集" },
|
||||
],
|
||||
},
|
||||
one("promotion", "促销"),
|
||||
one("videoCodec", "编码"),
|
||||
one("releaseType", "质量"),
|
||||
@@ -51,7 +45,16 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
||||
export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more);
|
||||
export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
|
||||
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
/** 旧的季 / 集两个维度:筛选条上不再出现,旧链接带着时仍生效,并以可删除的标签列出 */
|
||||
const LEGACY_DIMS: { dim: QuickDim; label: string }[] = [
|
||||
{ dim: "season", label: "季" },
|
||||
{ dim: "episode", label: "集" },
|
||||
];
|
||||
|
||||
const DIM_ORDER: QuickDim[] = [
|
||||
...QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)),
|
||||
...LEGACY_DIMS.map((d) => d.dim),
|
||||
];
|
||||
|
||||
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */
|
||||
const PERSISTED: QuickDim[] = [
|
||||
@@ -97,7 +100,9 @@ export function selectedTags(
|
||||
sel: QuickSelection,
|
||||
facets: ReleaseQuickFacets | undefined,
|
||||
): { dim: QuickDim; value: string; dimLabel: string; label: string }[] {
|
||||
const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const)));
|
||||
const dimLabel = new Map(
|
||||
[...QUICK_GROUPS.flatMap((g) => g.dims), ...LEGACY_DIMS].map((d) => [d.dim, d.label] as const),
|
||||
);
|
||||
return DIM_ORDER.flatMap((dim) =>
|
||||
(sel[dim] ?? []).map((value) => ({
|
||||
dim,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,10 +436,12 @@ 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]) => ({
|
||||
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,
|
||||
@@ -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);
|
||||
|
||||
@@ -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,计数按其它维度已选条件", () => {
|
||||
|
||||
Reference in new issue
Block a user