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

@@ -72,7 +72,10 @@ export const UnitMatch = z.object({
slotId: Id.nullable(),
state: UnitMatchState,
assetId: Id.nullable(),
/** 比对说明原文(界面放进技术信息) */
reasons: z.array(z.string()),
/** 差距原因码(`version_resolution:2160p` …),人话见 reasons.ts matchGapText */
codes: z.array(z.string()),
});
export type UnitMatch = z.infer<typeof UnitMatch>;
export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt });
+48
View File
@@ -73,3 +73,51 @@ export function reasonSentence(raw: string | null | undefined, fallback = "处
/** 看起来像内部码(小写下划线 / 冒号)而不是给人看的句子 */
export const looksLikeCode = (v: string | null | undefined) => !!v && /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(v);
// ── 入库版本与收录版本的差距(POLISH-1)──
// 码由 @umt/domain matchVersions 给出,冒号后是参数:目标分辨率 / 来源 / 编码 / 字幕语言。
const SOURCE_NAME: Record<string, string> = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" };
const LANG_NAME: Record<string, string> = { "zh-Hans": "简中", "zh-Hant": "繁中", zh: "中文", en: "英文", ja: "日文" };
const GAPS: Record<string, (p: string) => string> = {
version_resolution: (p) => (p ? `可换成 ${p}` : "分辨率不符合收录版本"),
version_resolution_upgrade: (p) => (p ? `可换成 ${p}` : "分辨率还能更高"),
version_source: (p) => (p ? `可换成 ${SOURCE_NAME[p] ?? p}` : "来源不符合收录版本"),
version_source_upgrade: () => "可换成更好的来源",
version_encode: () => "收录版本要蓝光压制",
version_hdr: (p) => `缺 ${p || "HDR"} 版本`,
version_sdr: () => "收录版本要 SDR",
version_hdr_excluded: () => "HDR 格式被收录版本排除",
version_codec: (p) => (p ? `可换成 ${p} 编码` : "编码被收录版本排除"),
version_subtitles: (p) => {
const langs = p
.split("/")
.filter(Boolean)
.map((l) => LANG_NAME[l] ?? l)
.join("/");
if (!langs) return "缺字幕";
return /^[\x20-\x7e]+$/.test(langs) ? `缺 ${langs} 字幕` : `缺${langs}字幕`;
},
version_size: () => "体积不在收录范围",
version_edition: () => "剪辑版本不符合",
version_group: () => "制作组被排除",
version_content_type: () => "内容类型不符合",
version_rules: () => "不符合收录版本的规则",
version_spec_unknown: () => "认不出规格",
version_priority: () => "还有更好的版本可换",
};
/** 一个差距码 → 短句("可换成 2160p"、"缺 HDR 版本");不认识的码返回 null */
export function matchGapText(code: string | null | undefined): string | null {
if (!code) return null;
const i = code.indexOf(":");
const head = i < 0 ? code : code.slice(0, i);
const fn = GAPS[head];
return fn ? fn(i < 0 ? "" : code.slice(i + 1)) : null;
}
/** 一组差距码 → 去重后的短句列表 */
export function matchGapTexts(codes: readonly string[] | null | undefined): string[] {
return [...new Set((codes ?? []).map(matchGapText).filter((t): t is string => !!t))];
}
+3 -1
View File
@@ -12,7 +12,7 @@ export type FacetCount = z.infer<typeof FacetCount>;
* 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。
* 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
* releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、
* hdr(DV/HDR10/HDR10+/other/SDR)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
*/
export const QuickFilterParam = z.string().max(4000).optional();
@@ -24,6 +24,8 @@ export const ReleaseQuickFacets = z.object({
site: z.array(FacetCount),
promotion: z.array(FacetCount),
hdr: z.array(FacetCount),
/** H&R:有 / 无 / 未知(POLISH-1) */
hr: z.array(FacetCount),
/** 计数最多的前 20 个 + 已选的 */
releaseGroup: z.array(FacetCount),
/** 没有剧集资源时为空 */
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { looksLikeCode, reasonSentence, reasonText } from "../src/reasons.ts";
import { looksLikeCode, matchGapText, matchGapTexts, reasonSentence, reasonText } from "../src/reasons.ts";
describe("reasonText(DEAI-2 原因码人话化)", () => {
it("裸码、blocked:多码、带后缀的码都能翻译", () => {
@@ -24,3 +24,21 @@ describe("reasonText(DEAI-2 原因码人话化)", () => {
expect(looksLikeCode("Connection refused")).toBe(false);
});
});
describe("matchGapText(POLISH-1 入库版本差距)", () => {
it("带参数的码转成短句", () => {
expect(matchGapText("version_resolution:2160p")).toBe("可换成 2160p");
expect(matchGapText("version_hdr")).toBe("缺 HDR 版本");
expect(matchGapText("version_hdr:DV")).toBe("缺 DV 版本");
expect(matchGapText("version_source:REMUX")).toBe("可换成 Remux");
expect(matchGapText("version_subtitles:zh-Hans")).toBe("缺简中字幕");
expect(matchGapText("version_subtitles:chs")).toBe("缺 chs 字幕");
});
it("不认识的码返回 null;列表去重", () => {
expect(matchGapText("nope")).toBeNull();
expect(matchGapTexts(["version_resolution:2160p", "version_resolution_upgrade:2160p", "nope"])).toEqual([
"可换成 2160p",
]);
});
});
+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),
+19 -1
View File
@@ -145,6 +145,7 @@ describe("与版本目标比对", () => {
state: "missing",
assetId: null,
reasons: [],
codes: [],
});
});
@@ -158,12 +159,29 @@ describe("与版本目标比对", () => {
expect(m.state).toBe("upgradeable");
expect(m.assetId).toBe("bd");
expect(m.reasons.join(" ")).toMatch(/1080p/);
expect(m.codes).toEqual(["version_resolution:2160p"]);
});
it("差距原因码:HDR / 字幕 / 来源带目标参数", () => {
const hdr: SlotSpec = { id: "h", name: "h", hard: { hdr: { anyOf: ["DV", "HDR10"] } } };
expect(matchVersions(hdr, [v("bd", BD_FILE)], { isEpisode: true }).codes).toEqual(["version_hdr"]);
const dvOnly: SlotSpec = { id: "d", name: "d", hard: { hdr: { anyOf: ["DV"] } } };
expect(matchVersions(dvOnly, [v("bd", BD_FILE)], { isEpisode: true }).codes).toEqual(["version_hdr:DV"]);
const remux: SlotSpec = { id: "r", name: "r", hard: { sources: ["REMUX", "BluRay-Disc"] } };
expect(matchVersions(remux, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).codes).toEqual([
"version_source:REMUX",
]);
});
it("达标但升级策略还有余量 = upgradeable;到顶 = satisfied", () => {
const up: SlotSpec = { id: "s", name: "x", hard: {}, upgrade: { enabled: true } };
const web = matchVersions(up, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
expect(web).toMatchObject({ state: "upgradeable", assetId: "dv", reasons: ["来源还能升级"] });
expect(web).toMatchObject({
state: "upgradeable",
assetId: "dv",
reasons: ["来源还能升级"],
codes: ["version_source_upgrade"],
});
const capped: SlotSpec = { ...up, upgrade: { enabled: true, ceilingTier: 2 } };
expect(matchVersions(capped, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
const onlyWeb: SlotSpec = { ...up, hard: { sources: ["WEB-DL"] } };
@@ -100,6 +100,19 @@ describe("release-facets", () => {
expect(ids(run({ promotion: ["2x"] }))).toEqual(["4"]);
});
it("H&R 三态分面:缺省按未知;未知档只在有资源时列出", () => {
const hrItems = [mk("y", { hr: true }), mk("n", { hr: false }), mk("n2", { hr: false }), mk("u")];
const r = applyFacets(hrItems, (x) => x, {}, { now: NOW });
expect(r.facets.hr).toEqual([
{ value: "yes", label: "有", count: 1 },
{ value: "no", label: "无", count: 2 },
{ value: "unknown", label: "未知", count: 1 },
]);
expect(ids(applyFacets(hrItems, (x) => x, { hr: ["no", "unknown"] }, { now: NOW }))).toEqual(["n", "n2", "u"]);
const known = applyFacets([mk("y", { hr: true })], (x) => x, {}, { now: NOW });
expect(known.facets.hr.map((o) => o.value)).toEqual(["yes", "no"]);
});
it("已选但没有结果的值仍列出(计数 0)", () => {
const r = run({ releaseGroup: ["NoSuch"], site: ["zzz"] });
expect(r.items).toEqual([]);