Merge branch 'search3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-29 20:52:51 +08:00
commit 4c7375b52f
20 files changed
+1273 -81

No files matched your search

+27 -1
View File
@@ -11,7 +11,7 @@ import type {
SearchSiteCoverage,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle, parseRelease } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { raceAbort } from "./media.ts";
@@ -286,6 +286,7 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
titleEn: p.titleEn ?? null,
year: p.year ?? null,
season: p.season ?? null,
seasonEnd: p.seasonEnd ?? null,
episodes: p.episodes ? { start: p.episodes.start, end: p.episodes.end } : null,
resolution: p.resolution ?? null,
source: p.source ?? null,
@@ -336,6 +337,31 @@ export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): Bas
});
}
/** 搜索结果 → 快速筛选口径的输入 */
export const searchFacetInput = (r: BaseRelease): FacetInput => ({
siteId: r.siteId,
siteName: r.siteName,
resolution: r.parsed.resolution,
source: r.parsed.source,
videoCodec: r.parsed.videoCodec,
hdr: r.parsed.hdr,
releaseGroup: r.parsed.releaseGroup,
season: r.parsed.season,
seasonEnd: r.parsed.seasonEnd ?? null,
episodes: r.parsed.episodes,
promotion: {
type: r.promotion.type,
downloadFactor: r.promotion.downloadFactor,
uploadFactor: r.promotion.uploadFactor,
expiresAt: r.promotion.expiresAt,
},
});
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
export function quickFilter(items: BaseRelease[], q: ReleaseSearchQuery, now: number) {
return applyFacets(filterReleases(items, q), searchFacetInput, parseFacetParam(q.qf), { now });
}
const time = (s: string | null) => (s ? Date.parse(s) : null);
/** 空值恒排最后;default = 做种数降序,同分发布时间新→旧 */
+4 -3
View File
@@ -34,7 +34,7 @@ import type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts";
import { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
import {
buildWorks,
detailSeasons,
@@ -278,7 +278,8 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
const terms = await releaseTerms(q);
const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" });
const all = hit.fanout.items;
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
const quick = quickFilter(all, q, d.now());
const filtered = sortReleases(quick.items, q.sort, q.order);
const offset = decodeCursor(q.cursor);
const pageItems = filtered.slice(offset, offset + q.limit);
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
@@ -290,7 +291,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
nextCursor: next,
total: filtered.length,
unfilteredTotal: all.length,
facets: facets(all),
facets: { ...facets(all), ...quick.facets },
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
@@ -1,12 +1,12 @@
import type {
ReleaseOrigin,
WorkbenchCoverage,
WorkbenchRelease,
WorkbenchReleaseDetail,
WorkbenchReleasePage,
WorkbenchReleaseQuery,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts";
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
@@ -103,6 +103,46 @@ function toWorkbench(row: ReleaseJoinRow): WorkbenchRelease {
};
}
/** 快速筛选用的轻量行:只取解析结果与促销 */
interface LightRow {
id: string;
parsed: unknown;
promotion: unknown;
promotion_expires_at: Date | 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);
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
const p = (r.parsed ?? {}) as Record<string, unknown>;
const promo = (r.promotion ?? {}) as { kind?: unknown; expiresAt?: unknown; raw?: unknown };
const raw = (promo.raw ?? {}) as { downloadFactor?: unknown; uploadFactor?: unknown };
const eps = p.episodes as { start?: unknown; end?: unknown } | undefined;
const start = num(eps?.start);
const group = p.releaseGroup as { name?: unknown } | undefined;
const expires = r.promotion_expires_at ?? (typeof promo.expiresAt === "string" ? new Date(promo.expiresAt) : null);
return {
siteId: site.id,
siteName: site.name,
resolution: str(p.resolution),
source: str(p.source),
videoCodec: str(p.videoCodec),
hdr: Array.isArray(p.hdr) ? p.hdr.filter((h): h is string => typeof h === "string") : [],
releaseGroup: str(group?.name),
season: num(p.season),
seasonEnd: num(p.seasonEnd),
episodes: start !== null ? { start, end: num(eps?.end) ?? start } : null,
promotion: {
type: str(promo.kind),
downloadFactor: num(raw.downloadFactor),
uploadFactor: num(raw.uploadFactor),
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
},
};
}
function releaseSelect(rt: SourcesRuntime) {
return rt.ctx.db
.selectFrom("source_release as r")
@@ -122,7 +162,7 @@ export async function listSiteReleases(
rt: SourcesRuntime,
siteId: string,
q: WorkbenchReleaseQuery,
): Promise<{ items: WorkbenchRelease[]; nextCursor: string | null; coverage: WorkbenchCoverage }> {
): Promise<WorkbenchReleasePage> {
const site = await rt.ctx.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const snapAt = await lastSnapshotAt(rt, site.id);
@@ -130,8 +170,18 @@ export async function listSiteReleases(
const stale = !snapAt || rt.now().getTime() - snapAt.getTime() > periodMs * 2;
const offset = decodeCursor(q.cursor);
const run = async () => {
let query = releaseSelect(rt).where("r.site_id", "=", site.id);
// 快速筛选(SEARCH-3):先按 q / 分类 / 促销 / 识别状态取出全部候选的轻量字段,在内存里算分面并筛选,
// 再只为当前页取完整行。只读本地资源表,不触发站点请求。
const run = async (): Promise<LightRow[]> => {
let query = rt.ctx.db
.selectFrom("source_release as r")
.select([
"r.id",
sql<unknown>`r.parsed`.as("parsed"),
sql<unknown>`r.promotion`.as("promotion"),
"r.promotion_expires_at",
])
.where("r.site_id", "=", site.id);
if (q.q) {
const like = `%${q.q.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
query = query.where((eb) =>
@@ -149,9 +199,7 @@ export async function listSiteReleases(
return (await query
.orderBy(sql`coalesce(r.source_published_at, r.first_seen_at)`, "desc")
.orderBy("r.id")
.offset(offset)
.limit(q.limit + 1)
.execute()) as unknown as ReleaseJoinRow[];
.execute()) as unknown as LightRow[];
};
let rows = await run();
@@ -169,10 +217,23 @@ export async function listSiteReleases(
if (live) rows = await run();
}
}
const pg = page(rows, offset, q.limit);
const quick = applyFacets(rows, (r) => lightFacetInput(r, site), parseFacetParam(q.qf), {
now: rt.now().getTime(),
});
const slice = quick.items.slice(offset, offset + q.limit + 1);
const ids = slice.map((r) => r.id);
const full = ids.length
? ((await releaseSelect(rt).where("r.id", "in", ids).execute()) as unknown as ReleaseJoinRow[])
: [];
const byId = new Map(full.map((r) => [r.id as string, r]));
const ordered = ids.map((id) => byId.get(id)).filter((r): r is ReleaseJoinRow => !!r);
const pg = page(ordered, offset, q.limit);
return {
items: pg.items.map(toWorkbench),
nextCursor: pg.nextCursor,
total: quick.items.length,
unfilteredTotal: rows.length,
facets: quick.facets,
coverage: {
siteEnabled: site.enabled,
lastSnapshotAt: snapAt?.toISOString() ?? null,
+84
View File
@@ -396,6 +396,90 @@ describe.skipIf(!up)("全局搜索", () => {
expect(p2.nextCursor).toBeNull();
});
it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => {
const q = (qf: string, extra = "") =>
req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();
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.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.resolutions.length).toBe(2);
d = await q("resolution:2160p");
expect(ids(d)).toEqual(["812345", "990001"]);
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
// 本维度其它选项仍按其它条件计数
expect(counts(d.facets.resolution)).toMatchObject({ "2160p": 2, "1080p": 1 });
expect(counts(d.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1 });
d = await q("resolution:2160p;releaseType:webdl");
expect(ids(d)).toEqual(["990001"]);
d = await q(`resolution:2160p,1080p;site:${sites.nexus}`);
expect(ids(d)).toEqual(["812345", "900001"]);
d = await q("releaseGroup:flux,grp");
expect(ids(d)).toEqual(["900001", "990001"]);
d = await q("hdr:DV");
expect(ids(d)).toEqual(["812345"]);
d = await q("videoCodec:AV1");
expect(d.items).toEqual([]);
expect(d.total).toBe(0);
// 与旧参数叠加(AND)
d = await q("resolution:2160p", "&source=REMUX");
expect(ids(d)).toEqual(["812345"]);
// 分页:同一筛选下逐页取完 = 一次取完,且排序一致
const whole = await q("videoCodec:H.265,H.264", "&sort=size&limit=50");
const seen: string[] = [];
let cursor: string | null = null;
do {
const p: Loose = await q("videoCodec:H.265,H.264", `&sort=size&limit=1${cursor ? `&cursor=${cursor}` : ""}`);
expect(p.total).toBe(3);
expect(p.facets.videoCodec).toEqual(whole.facets.videoCodec);
seen.push(...p.items.map((i: Loose) => i.siteReleaseId));
cursor = p.nextCursor;
} while (cursor);
expect(seen).toEqual(whole.items.map((i: Loose) => i.siteReleaseId));
});
it("站点资源列表的分面基于本地数据,不请求站点", async () => {
const before = [nexusSearches(), mteamSearches()];
const list = (qf: string) =>
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
let d = await list("");
expect(d.total).toBe(2);
expect(d.unfilteredTotal).toBe(2);
expect(d.facets.site).toEqual([{ value: sites.nexus, label: "观众", count: 2 }]);
expect(d.facets.resolution.find((f: Loose) => f.value === "2160p").count).toBe(1);
expect(d.facets.releaseType.find((f: Loose) => f.value === "remux").count).toBe(1);
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],
]);
// 分页
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);
const p2 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1&cursor=${p1.nextCursor}`).then(
(r) => r.body.data,
);
expect(p2.items.length).toBe(1);
expect(p2.items[0].id).not.toBe(p1.items[0].id);
expect(p2.nextCursor).toBeNull();
expect([nexusSearches(), mteamSearches()]).toEqual(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")];