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:
1 parent
12bc4664b6
commit
71b129d85a
19 files changed
+668
-290
No files matched your search
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user