feat(search): multi-select quick filters for search results and site releases (SEARCH-3)
- domain release-facets: bucket resolution / codec / release type / site / promotion / HDR /
release group / season / episode; OR within a dimension, AND across; disjunctive counts
- GET /search/releases and GET /sites/{id}/releases accept qf and return facets; site list
facets come from local source_release rows only (no site requests), paging stays consistent
- web QuickFilterBar shared by search results and site resources tab: per-dimension popover
(bottom sheet on phones) with counts, removable tags, clear all, localStorage memory per page
- runbook: quick filter params and bucket definitions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d282212a6b
commit
970296218c
20 files changed
+1273
-81
No files matched your search
@@ -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 = 做种数降序,同分发布时间新→旧 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user