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:
rootandClaude Opus 5.5 committed 2026-09-30 22:40:55 +08:00
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) {