feat(search): align quick-filter facets with MP (SEARCH-4)
Add audioCodec, subtitle, dub, feature, year, category, size and seeders facets with disjunctive counts; parser records dub languages (audioLangs). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c3b25be676
commit
d9cf8f1c3a
10 files changed
+409
-5
No files matched your search
@@ -295,6 +295,12 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
|
||||
videoCodec: p.videoCodec ?? null,
|
||||
hdr: [...p.hdr],
|
||||
releaseGroup: p.releaseGroup?.name ?? null,
|
||||
audio: [...p.audio],
|
||||
subtitles: p.subtitles ? [...p.subtitles] : null,
|
||||
audioLangs: p.audioLangs ? [...p.audioLangs] : null,
|
||||
bitDepth: p.bitDepth ?? null,
|
||||
fps: p.fps ?? null,
|
||||
cut: p.cut ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -358,6 +364,16 @@ export const searchFacetInput = (r: BaseRelease): FacetInput => ({
|
||||
expiresAt: r.promotion.expiresAt,
|
||||
},
|
||||
hr: r.hr,
|
||||
audio: r.parsed.audio ?? [],
|
||||
subtitles: r.parsed.subtitles ?? null,
|
||||
audioLangs: r.parsed.audioLangs ?? null,
|
||||
bitDepth: r.parsed.bitDepth ?? null,
|
||||
fps: r.parsed.fps ?? null,
|
||||
cut: r.parsed.cut ?? null,
|
||||
year: r.parsed.year,
|
||||
category: r.category.name,
|
||||
sizeBytes: r.sizeBytes,
|
||||
seeders: r.seeders,
|
||||
});
|
||||
|
||||
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
WorkbenchReleaseQuery,
|
||||
} from "@umt/contracts";
|
||||
import { type SiteRow, sql } from "@umt/db";
|
||||
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
|
||||
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, scanAudioLangs } from "@umt/domain";
|
||||
import { ApiException } from "../../../http/errors.ts";
|
||||
import { decodeCursor, page, toApi } from "../rows.ts";
|
||||
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
|
||||
@@ -111,10 +111,19 @@ interface LightRow {
|
||||
promotion_expires_at: Date | null;
|
||||
/** raw._site.hr(列表快照解析的 H&R);没有为 null */
|
||||
hr: unknown;
|
||||
/** 站点分类名(列表快照 → RSS → 分类 ID) */
|
||||
category: string | null;
|
||||
size_bytes: number | string | null;
|
||||
seeders: number | null;
|
||||
/** 旧解析结果没有 audioLangs 时从标题 / 副标题补 */
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
||||
const strs = (v: unknown): string[] | null =>
|
||||
Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.length > 0) : null;
|
||||
|
||||
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
|
||||
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
|
||||
@@ -143,6 +152,16 @@ export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">)
|
||||
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
|
||||
},
|
||||
hr: r.hr === true || r.hr === false ? r.hr : "unknown",
|
||||
audio: strs(p.audio) ?? [],
|
||||
subtitles: strs(p.subtitles),
|
||||
audioLangs: strs(p.audioLangs) ?? scanAudioLangs(`${r.title ?? ""} ${r.subtitle ?? ""}`),
|
||||
bitDepth: num(p.bitDepth),
|
||||
fps: num(p.fps),
|
||||
cut: str(p.cut),
|
||||
year: num(p.year),
|
||||
category: r.category,
|
||||
sizeBytes: r.size_bytes === null || r.size_bytes === undefined ? null : Number(r.size_bytes),
|
||||
seeders: r.seeders,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -184,6 +203,15 @@ export async function listSiteReleases(
|
||||
sql<unknown>`r.promotion`.as("promotion"),
|
||||
"r.promotion_expires_at",
|
||||
sql<unknown>`r.raw->'_site'->'hr'`.as("hr"),
|
||||
sql<
|
||||
string | null
|
||||
>`coalesce(nullif(r.raw->'_site'->>'categoryName', ''), nullif(r.raw->'rss'->>'categoryName', ''), r.category_raw)`.as(
|
||||
"category",
|
||||
),
|
||||
"r.size_bytes",
|
||||
"r.seeders",
|
||||
"r.title",
|
||||
"r.subtitle",
|
||||
])
|
||||
.where("r.site_id", "=", site.id);
|
||||
if (q.q) {
|
||||
|
||||
@@ -483,6 +483,48 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
expect([nexusSearches(), mteamSearches()]).toEqual(before);
|
||||
});
|
||||
|
||||
it("SEARCH-4 新维度:音频 / 年份 / 分类 / 做种 / 大小,搜索结果与站点资源列表同一口径", async () => {
|
||||
const q = (qf: string) => req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
||||
const list = (qf: string) =>
|
||||
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
|
||||
const pick = (f: Loose) =>
|
||||
Object.fromEntries(
|
||||
["audioCodec", "subtitle", "dub", "feature", "year", "category", "size", "seeders"].map((k) => [
|
||||
k,
|
||||
Object.fromEntries(f[k].map((o: Loose) => [o.value, o.count])),
|
||||
]),
|
||||
);
|
||||
const d = await q("");
|
||||
expect(pick(d.facets)).toMatchObject({
|
||||
audioCodec: { Atmos: 1, TrueHD: 1, DDP: 1, AAC: 1 },
|
||||
subtitle: { zh: 1, unknown: 2 },
|
||||
dub: { cmn: 1, en: 1, unknown: 2 },
|
||||
feature: {},
|
||||
year: { "2024": 3 },
|
||||
category: { 电影: 3 },
|
||||
size: { "5-10": 1, "20-50": 1, "50+": 1 },
|
||||
seeders: { "10-49": 1, "50+": 2 },
|
||||
});
|
||||
// 组合:年份 × 做种 × 音频(同维度 OR)
|
||||
const hit = await q("year:2024;seeders:50+;audioCodec:AAC,DDP");
|
||||
expect(hit.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(hit.facets.audioCodec.find((o: Loose) => o.value === "DDP").count).toBe(0);
|
||||
expect(hit.facets.seeders.find((o: Loose) => o.value === "10-49").count).toBe(1);
|
||||
// 站点资源列表:从资源表的解析结果 / 分类 / 大小 / 做种算,不请求站点
|
||||
const before = nexusSearches();
|
||||
const l = await list("");
|
||||
expect(pick(l.facets)).toMatchObject({
|
||||
audioCodec: { Atmos: 1, TrueHD: 1, AAC: 1 },
|
||||
dub: { cmn: 1, unknown: 1 },
|
||||
category: { 电影: 2 },
|
||||
size: { "5-10": 1, "50+": 1 },
|
||||
seeders: { "50+": 2 },
|
||||
});
|
||||
expect((await list("dub:cmn;category:电影")).total).toBe(1);
|
||||
expect((await list("size:5-10;audioCodec:AAC")).items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
|
||||
expect(nexusSearches()).toBe(before);
|
||||
});
|
||||
|
||||
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
|
||||
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
|
||||
const items = [base("a", true), base("b", false), base("c", "unknown")];
|
||||
|
||||
Reference in new issue
Block a user