feat(web): merge search filters into one row and tidy episode versions (POLISH-1)

- H&R becomes a quick-filter facet (yes/no/unknown, with counts) on search
  results and the site resources tab; drop the old free/H&R selects, keep
  size range and sort on the same row (mobile: drawer), single clear button
- legacy free/hr URL params migrate into quick filters; stored selections
  keep the same localStorage key
- search timing and query terms move into TechInfo
- library episode versions: click the whole row to expand, versions listed
  as sibling rows, details only under the selected version, raw values in
  TechInfo
- slot comparison gaps get reason codes (matchGapText in contracts
  reasons.ts): "可换成 2160p", "缺 HDR 版本"

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:06:32 +08:00
1 parent 12bc4664b6
commit 71b129d85a
19 files changed
+668 -290

No files matched your search

+75 -9
View File
@@ -8,7 +8,7 @@ import {
qualityTier,
RESOLUTION_RANK,
} from "../slot/evaluate.ts";
import type { SlotSpec } from "../slot/types.ts";
import type { SlotSpec, Violation } from "../slot/types.ts";
// LIBV-1 入库版本:一集(或一部电影)可以挂多个本地文件,每个文件是一个"版本"。
// 版本规格 = 文件名解析 + ffprobe 结果合并:技术事实(分辨率、编码、HDR、音轨、字幕、时长)以探测为准,
@@ -354,8 +354,10 @@ export interface UnitMatch {
state: UnitMatchState;
/** 满足目标的最好版本(satisfied / 可升级但已达标时);都不达标时为最接近的版本 */
assetId: string | null;
/** 可读原因:不达标的条件、升级余量 */
/** 可读原因:不达标的条件、升级余量(原文,界面放进技术信息) */
reasons: string[];
/** 差距原因码(`version_resolution:2160p`、`version_hdr` …),界面经 @umt/contracts matchGapText 转成人话 */
codes: string[];
}
export interface LocalVersion {
@@ -429,7 +431,7 @@ export function matchVersions(
opts: { isEpisode: boolean },
): UnitMatch {
const slotId = target.id ?? null;
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [] };
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [], codes: [] };
const spec = localSlotSpec(target, opts.isEpisode);
const evals = versions.map((v) => {
const r = pseudoRelease(v);
@@ -444,19 +446,27 @@ export function matchVersions(
const best = ok[0];
if (!best) {
const near = [...evals].sort(better)[0]!;
const reasons = near.ev.decision === "reject" ? uniq(near.ev.violations.map(describeViolation)) : ["规格未知"];
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons };
const rejected = near.ev.decision === "reject" ? near.ev.violations : null;
const reasons = rejected ? uniq(rejected.map(describeViolation)) : ["规格未知"];
const codes = rejected ? uniq(rejected.map((v) => gapCode(v, target))) : ["version_spec_unknown"];
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons, codes };
}
const up = target.upgrade;
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
const tiered = !!target.soft?.tiers?.length || !!target.rules?.groups.length;
const basis = up.basis ?? (tiered ? "priority" : "tier");
if (basis === "priority") {
const pri = best.ev.decision === "accept" ? (best.ev.pri ?? currentPri(spec, best.r)) : 0;
const goal = up.targetPri ?? 100;
return pri < goal
? { slotId, state: "upgradeable", assetId: best.v.assetId, reasons: [`优先级 ${pri},目标 ${goal}`] }
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
? {
slotId,
state: "upgradeable",
assetId: best.v.assetId,
reasons: [`优先级 ${pri},目标 ${goal}`],
codes: ["version_priority"],
}
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
}
const ceil = ceilingOf(target);
const belowCeiling = up.ceilingTier === undefined || best.tier.source < up.ceilingTier;
@@ -468,6 +478,62 @@ export function matchVersions(
state: "upgradeable",
assetId: best.v.assetId,
reasons: [bySource ? "来源还能升级" : "分辨率还能升级"],
codes: [bySource ? "version_source_upgrade" : withParam("version_resolution_upgrade", topResolution(target))],
};
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
}
const withParam = (code: string, p: string | null | undefined) => (p ? `${code}:${p}` : code);
/** 目标允许的最高分辨率名 */
function topResolution(spec: SlotSpec, allowed?: readonly string[]): string | null {
const list = (allowed ?? spec.hard.resolutions ?? Object.keys(RESOLUTION_RANK)) as Resolution[];
const known = list.filter((r) => RESOLUTION_RANK[r] !== undefined);
return known.sort((a, b) => RESOLUTION_RANK[b] - RESOLUTION_RANK[a])[0] ?? null;
}
/** 目标允许的最好来源 */
function topSource(spec: SlotSpec, allowed: readonly string[]): string | null {
const tiers: Record<string, number> = { ...DEFAULT_SOURCE_TIERS, ...spec.upgrade?.sourceTiers };
return [...allowed].sort((a, b) => (tiers[b] ?? -1) - (tiers[a] ?? -1))[0] ?? null;
}
const strList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
/**
* 本地版本与收录版本的差距 → 原因码(POLISH-1)。码表与文案在 @umt/contracts reasons.ts(matchGapText),
* 冒号后是参数(目标分辨率、来源、编码、字幕语言)。
*/
export function gapCode(v: Violation, spec: SlotSpec): string {
const expected = strList(v.expected);
switch (`${v.field}.${v.code}`) {
case "resolution.not_allowed":
return withParam("version_resolution", topResolution(spec, expected));
case "source.not_allowed":
return withParam("version_source", topSource(spec, expected));
case "source.excluded":
return "version_source";
case "source.not_encode":
return "version_encode";
case "hdr.missing_required":
return expected.length && expected.every((h) => h === "DV") ? "version_hdr:DV" : "version_hdr";
case "hdr.not_sdr":
return "version_sdr";
case "hdr.excluded":
return "version_hdr_excluded";
case "codec.not_allowed":
return withParam("version_codec", expected[0]);
case "codec.excluded":
return "version_codec";
case "subtitles.missing_language":
return withParam("version_subtitles", expected.join("/"));
case "cut.not_allowed":
return "version_edition";
case "group.excluded":
return "version_group";
case "contentType.not_allowed":
return "version_content_type";
default:
return v.field === "size" ? "version_size" : "version_rules";
}
}
@@ -12,6 +12,7 @@ export const FACET_DIMS = [
"site",
"promotion",
"hdr",
"hr",
"releaseGroup",
"season",
"episode",
@@ -46,6 +47,8 @@ export interface FacetInput {
uploadFactor: number | null;
expiresAt: string | null;
};
/** H&R 三态;缺省 / null 按未知 */
hr?: boolean | "unknown" | null;
}
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
@@ -84,6 +87,11 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
{ value: "other", label: "其他 HDR", hideEmpty: true },
{ value: "SDR", label: "SDR" },
],
hr: [
{ value: "yes", label: "有" },
{ value: "no", label: "无" },
{ value: "unknown", label: "未知", hideEmpty: true },
],
};
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
@@ -143,6 +151,10 @@ export function hdrBuckets(hdr: readonly string[]): string[] {
return [...out];
}
export function hrBucket(hr: FacetInput["hr"]): string {
return hr === true ? "yes" : hr === false ? "no" : "unknown";
}
function seasonValues(r: FacetInput): string[] {
if (r.season === null || r.season === undefined) return [];
const end =
@@ -172,6 +184,7 @@ export function facetValues(r: FacetInput, now: number): Record<FacetDim, string
site: [r.siteId],
promotion: promotionBuckets(r.promotion, now),
hdr: hdrBuckets(r.hdr),
hr: [hrBucket(r.hr)],
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
season: seasonValues(r),
episode: episodeValues(r),