diff --git a/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts index ef844c30..6372e2fa 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/library-versions.test.ts @@ -175,7 +175,9 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => { const [e1, e2, e3] = s1.episodes; expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort()); - expect(e1.matches).toEqual([{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [] }]); + expect(e1.matches).toEqual([ + { slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [], codes: [] }, + ]); const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv); expect(dv).toMatchObject({ resolution: "2160p", diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index d44809dd..7643c6e5 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -355,6 +355,7 @@ export const searchFacetInput = (r: BaseRelease): FacetInput => ({ uploadFactor: r.promotion.uploadFactor, expiresAt: r.promotion.expiresAt, }, + hr: r.hr, }); /** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */ diff --git a/apps/server/src/modules/sources-identity/service/workbench.ts b/apps/server/src/modules/sources-identity/service/workbench.ts index cad7e3ef..f138a282 100644 --- a/apps/server/src/modules/sources-identity/service/workbench.ts +++ b/apps/server/src/modules/sources-identity/service/workbench.ts @@ -109,6 +109,8 @@ interface LightRow { parsed: unknown; promotion: unknown; promotion_expires_at: Date | null; + /** raw._site.hr(列表快照解析的 H&R);没有为 null */ + hr: unknown; } const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); @@ -140,6 +142,7 @@ export function lightFacetInput(r: LightRow, site: Pick) uploadFactor: num(raw.uploadFactor), expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null, }, + hr: r.hr === true || r.hr === false ? r.hr : "unknown", }; } @@ -180,6 +183,7 @@ export async function listSiteReleases( sql`r.parsed`.as("parsed"), sql`r.promotion`.as("promotion"), "r.promotion_expires_at", + sql`r.raw->'_site'->'hr'`.as("hr"), ]) .where("r.site_id", "=", site.id); if (q.q) { diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index c8f611da..8fe4c90e 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -17,7 +17,7 @@ import { EventBus } from "../../src/http/sse.ts"; import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts"; import { createAgentModule } from "../../src/modules/agent/index.ts"; import { createSearchModule } from "../../src/modules/search/index.ts"; -import { type BaseRelease, filterReleases } from "../../src/modules/search/releases.ts"; +import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts"; import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts"; import type { ServerContext } from "../../src/modules/types.ts"; import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts"; @@ -412,6 +412,7 @@ describe.skipIf(!up)("全局搜索", () => { 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.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3); // 旧分面仍在 expect(d.facets.resolutions.length).toBe(2); @@ -461,6 +462,8 @@ describe.skipIf(!up)("全局搜索", () => { 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); + // H&R 分面来自资源表 raw._site.hr,每条资源恰好落在一档 + expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(2); d = await list("resolution:1080p"); expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]); expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 }); @@ -487,6 +490,15 @@ describe.skipIf(!up)("全局搜索", () => { filterReleases(items, { q: "x", hr, sort: "default", order: "desc", limit: 50 }).map((i) => i.siteReleaseId); expect(f("true")).toEqual(["a"]); expect(f("false")).toEqual(["b", "c"]); + // 快速筛选里的 H&R 分面(POLISH-1):有 / 无 / 未知,多选为 OR + const qfq = (qf: string) => quickFilter(items, { q: "x", qf, sort: "default", order: "desc", limit: 50 }, 0); + const all = qfq(""); + expect(all.facets.hr).toEqual([ + { value: "yes", label: "有", count: 1 }, + { value: "no", label: "无", count: 1 }, + { value: "unknown", label: "未知", count: 1 }, + ]); + expect(qfq("hr:no,unknown").items.map((i) => i.siteReleaseId)).toEqual(["b", "c"]); }); it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => { diff --git a/apps/web/app/components/library/versions.tsx b/apps/web/app/components/library/versions.tsx index 586d7e2d..677221d6 100644 --- a/apps/web/app/components/library/versions.tsx +++ b/apps/web/app/components/library/versions.tsx @@ -1,15 +1,23 @@ -import type { AssetVersion, SeasonVersions, UnitMatch, VersionSummary } from "@umt/contracts"; +import { + type AssetVersion, + matchGapTexts, + type SeasonVersions, + type UnitMatch, + type VersionSummary, +} from "@umt/contracts"; import { ChevronDown } from "lucide-react"; import { type ReactNode, useState } from "react"; import { Link } from "react-router"; import { useLibraryVersions } from "~/api/library-versions.ts"; import { type KvItem, KvList } from "~/components/console/kv-list.tsx"; import { consolePath } from "~/components/console/nav.ts"; +import { TechInfo } from "~/components/console/tech-info.tsx"; import { Panel } from "~/components/library/parts.tsx"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; -// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小),多个版本可展开逐行; -// 季头一行汇总;路径、音轨、字幕、探测原始值放进「技术信息」折叠。 +// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小);季头一行汇总。 +// POLISH-1:点整行展开,多个版本同级并列,选中的版本下方才显示路径、音轨、字幕、时长; +// 规格来源、探测原始值、比对原文放进 TechInfo;与收录版本的差距用 matchGapTexts 转成人话。 const SOURCE_LABEL: Record = { REMUX: "Remux", @@ -85,9 +93,15 @@ function duration(sec: number | null): string | null { const SPEC_FROM: Record = { name: "文件名", probe: "ffprobe" }; -/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */ -// TODO(DEAI-2):TechInfo 组件合入后改用 components/console/tech-info.tsx,和其他页面统一折叠样式与默认展开偏好 -function VersionTech({ v }: { v: AssetVersion }) { +/** 与收录版本的差距 → 人话短句("可换成 2160p"、"缺 HDR 版本");原文留给技术信息 */ +export function gapLine(match: UnitMatch | undefined): string | null { + if (match?.state !== "upgradeable") return null; + const texts = matchGapTexts(match.codes); + return texts.length ? texts.join(" · ") : "还有更好的版本可换"; +} + +/** 选中版本的详情:路径、音轨、字幕、时长;规格来源、探测原始值、比对原文进「技术信息」 */ +function VersionDetail({ v, match }: { v: AssetVersion; match?: UnitMatch | undefined }) { const items: KvItem[] = [ { label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, mono: true, wide: true }, { label: "音轨", value: audioText(v) }, @@ -103,58 +117,113 @@ function VersionTech({ v }: { v: AssetVersion }) { ) : null, }, - { label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null }, ].filter((it) => it.value !== null && it.value !== undefined && it.value !== ""); + const ownMatch = match && match.assetId === v.assetId ? match : undefined; return ( -
- - 技术信息 - - -
- +
+ + SPEC_FROM[f] ?? f).join(" + ") || null }, + { label: "比对说明", value: ownMatch?.reasons.join(";") || null, wide: true }, + { label: "原因码", value: ownMatch?.codes.join(", ") || null, wide: true }, + ]} + > {v.probe ? ( -
- - 探测原始值 - -
-              {JSON.stringify(v.probe, null, 2)}
-            
-
+
+            {JSON.stringify(v.probe, null, 2)}
+          
) : null} -
-
- ); -} - -/** 一个版本:规格一行 + 技术信息折叠 */ -export function VersionRow({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) { - const parts = versionParts(v); - const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available"); - return ( -
-

- {parts.length ? ( - parts.map((p, i) => ( - - {i > 0 ? · : null} - {p} - - )) - ) : ( - {v.fileName} - )} - {best ? 符合收录版本 : null} - {missing ? 文件不在原位置 : null} - {aside} -

- +
); } -/** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「N 个版本」展开逐行 */ +/** 一个版本的规格一行:2160p · WEB-DL · HEVC · DV · FLUX · 13.2 GB + 状态 */ +export function VersionSpecLine({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) { + const parts = versionParts(v); + const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available"); + return ( + + {parts.length ? ( + parts.map((p, i) => ( + + {i > 0 ? · : null} + {p} + + )) + ) : ( + {v.fileName} + )} + {best ? 符合收录版本 : null} + {missing ? 文件不在原位置 : null} + {aside} + + ); +} + +const ROW_CLASS = + "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"; + +/** + * 版本列表(POLISH-1):每个版本一行同级并列,点一行选中并在它下方展开详情,再点收起。 + * 电影面板、没对上集数的文件都用它;剧集每集先收成一行,见 EpisodeVersionList。 + */ +export function VersionList({ + versions, + match, + initial, + onCollapse, +}: { + versions: AssetVersion[]; + match?: UnitMatch | undefined; + /** 初始选中的版本;缺省不选 */ + initial?: string | null; + /** 收起选中版本时回调(剧集用来收回到一行) */ + onCollapse?: () => void; +}) { + const [selected, setSelected] = useState(initial ?? null); + const tagBest = match?.state === "satisfied" && versions.length > 1; + return ( +
    + {versions.map((v) => { + const on = selected === v.assetId; + return ( +
  • + + {on ? : null} +
  • + ); + })} +
+ ); +} + +/** + * 一集的版本:收起时一行(达标或最接近的版本 + "N 个版本"),点整行展开; + * 展开后每个版本一行同级并列,选中的版本下方显示文件路径、音轨、字幕等。 + * 与收录版本的差距用人话写在下面("可换成 2160p · 缺 HDR 版本")。 + */ export function EpisodeVersionList({ versions, match, @@ -167,46 +236,32 @@ export function EpisodeVersionList({ const [open, setOpen] = useState(false); if (!versions.length) return null; const lead = versions.find((v) => v.assetId === match?.assetId) ?? versions[0]!; - const upgrade = match?.state === "upgradeable" ? match : null; - const note = upgrade ? ( -

- 可升级 - {upgrade.reasons.length ? :{upgrade.reasons.join(";")} : null} -

- ) : null; - if (versions.length === 1) - return ( -
- - {note} -
- ); - const rest = versions.filter((v) => v !== lead); - const tagBest = match?.state === "satisfied"; - const toggle = ( - - ); + const ordered = [lead, ...versions.filter((v) => v !== lead)]; + const gap = gapLine(match); return ( -
- - {note} +
{open ? ( -
    - {rest.map((v) => ( -
  • - -
  • - ))} -
- ) : null} + setOpen(false)} /> + ) : ( + + )} + {gap ?

{gap}

: null}
); } @@ -221,25 +276,12 @@ export function MovieVersionsPanel({ entityId }: { entityId: string }) { const versions = [...data.movie.versions].sort( (a, b) => Number(b.assetId === match?.assetId) - Number(a.assetId === match?.assetId), ); - const state = match - ? match.state === "satisfied" - ? `符合「${target?.name}」` - : match.state === "upgradeable" - ? `可升级${match.reasons.length ? `:${match.reasons.join(";")}` : ""}` - : null - : null; + const state = match?.state === "satisfied" ? `符合「${target?.name}」` : gapLine(match); return ( -
    - {versions.map((v) => ( -
  • - 1 && match?.state === "satisfied" && v.assetId === match.assetId} - /> -
  • - ))} -
+
+ +
); } @@ -260,13 +302,9 @@ export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) { {open ? ( -
    - {versions.map((v) => ( -
  • - -
  • - ))} -
+
+ +
) : null}
); diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx index 7303b69d..43b41235 100644 --- a/apps/web/app/components/search/quick-filter-bar.tsx +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -1,6 +1,6 @@ import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts"; -import { Check, ChevronDown, X } from "lucide-react"; -import { useState } from "react"; +import { Check, ChevronDown, SlidersHorizontal, X } from "lucide-react"; +import { type ReactNode, useState } from "react"; import { useIsMobile } from "~/components/files/use-mobile.ts"; import { Button } from "~/components/ui/button.tsx"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; @@ -18,8 +18,12 @@ import { } from "./quick-filter-state.ts"; // 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选; -// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。 +// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。 // 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。 +// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。 + +/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */ +export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void }; export function QuickFilterBar({ facets, @@ -27,6 +31,10 @@ export function QuickFilterBar({ onChange, hide = [], className, + aside, + extraTags = [], + onClearAll, + hideClear = false, }: { facets: ReleaseQuickFacets | undefined; value: QuickSelection; @@ -34,9 +42,17 @@ export function QuickFilterBar({ /** 不显示的维度(站点资源 Tab 不显示站点) */ hide?: QuickDim[]; className?: string; + /** 同一行右侧的控件(桌面);手机上收进一个抽屉,label 是入口名,active 时入口高亮 */ + aside?: { content: (layout: "inline" | "sheet") => ReactNode; label: string; active: boolean }; + extraTags?: ExtraTag[]; + /** 「清除」同时清掉页面自己的条件;缺省只清分面 */ + onClearAll?: () => void; + /** 页面已有自己的「清除筛选」时不再显示 */ + hideClear?: boolean; }) { const mobile = useIsMobile(); const [sheet, setSheet] = useState(null); + const [asideOpen, setAsideOpen] = useState(false); const groups = QUICK_GROUPS.filter((g) => { const dims = g.dims.filter((d) => !hide.includes(d.dim)); if (!dims.length) return false; @@ -52,74 +68,110 @@ export function QuickFilterBar({ ); }); const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim)); - if (!groups.length && !tags.length) return null; + if (!groups.length && !tags.length && !extraTags.length && !aside) return null; + const clearAll = onClearAll ?? (() => onChange({})); return (
-
- {groups.map((g) => { - const dims = g.dims.map((d) => d.dim); - const n = selectedCount(value, dims); - const chip = ( +
+
+ {/* 手机:大小与排序放在最前,免得滚到最后才看到 */} + {aside && mobile ? ( - ); - return mobile ? ( - chip - ) : ( - - {chip} - - - - - ); - })} + ) : null} + {groups.map((g) => { + const dims = g.dims.map((d) => d.dim); + const n = selectedCount(value, dims); + const chip = ( + + ); + return mobile ? ( + chip + ) : ( + + {chip} + + + + + ); + })} +
+ {aside && !mobile ?
{aside.content("inline")}
: null}
- {tags.length ? ( + {tags.length || extraTags.length ? (
{tags.map((t) => ( - + dimLabel={t.dimLabel} + label={t.label} + onRemove={() => onChange(toggleValue(value, t.dim, t.value))} + /> ))} - + {extraTags.map((t) => ( + + ))} + {hideClear ? null : ( + + )}
) : null} + {aside && mobile ? ( + + + + {aside.label} + {aside.label} + +
{aside.content("sheet")}
+ + + +
+
+ ) : null} + {mobile ? ( !o && setSheet(null)}> @@ -166,6 +218,21 @@ export function QuickFilterBar({ ); } +function RemovableTag({ dimLabel, label, onRemove }: { dimLabel: string; label: string; onRemove: () => void }) { + return ( + + ); +} + /** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */ function GroupPanel({ group, diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts index 0672d873..69ad1b16 100644 --- a/apps/web/app/components/search/quick-filter-state.test.ts +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { clearDims, formatQuickFilter, + mergeLegacyFilters, parseStored, persistable, quickFilterStorageKey, @@ -19,6 +20,7 @@ const empty = { site: [], promotion: [], hdr: [], + hr: [], releaseGroup: [], season: [], episode: [], @@ -60,4 +62,20 @@ describe("快速筛选状态", () => { }); expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search")); }); + + it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => { + expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] }); + expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search"); + const old = JSON.stringify({ resolution: ["2160p"], promotion: ["free"] }); + expect(parseStored(old)).toEqual({ resolution: ["2160p"], promotion: ["free"] }); + }); + + it("旧 URL 参数 free / hr 并进快速筛选", () => { + expect(mergeLegacyFilters({ promotion: ["2x"] }, { free: "true", hr: "true" })).toEqual({ + promotion: ["2x", "free"], + hr: ["yes"], + }); + expect(mergeLegacyFilters({}, { hr: "false" })).toEqual({ hr: ["no", "unknown"] }); + expect(mergeLegacyFilters({}, { free: "false" })).toEqual({}); + }); }); diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts index 5b773bf1..7c0d5066 100644 --- a/apps/web/app/components/search/quick-filter-state.ts +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -17,6 +17,7 @@ export const QUICK_GROUPS: QuickGroup[] = [ { key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] }, { key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] }, { key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] }, + { key: "hr", label: "H&R", dims: [{ dim: "hr", label: "H&R" }] }, { key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] }, { key: "seasonEpisode", @@ -31,7 +32,7 @@ export const QUICK_GROUPS: QuickGroup[] = [ const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); /** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */ -const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"]; +const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr", "hr"]; /** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */ export function formatQuickFilter(sel: QuickSelection): string | undefined { @@ -98,6 +99,23 @@ export function parseStored(raw: string | null): QuickSelection { } } +/** + * 旧版筛选行(免费 / H&R 下拉,URL 参数 free、hr)并进快速筛选(POLISH-1): + * free=true → 促销「免费」;hr=true → H&R「有」;hr=false → H&R「无 / 未知」(旧口径只排除明确 H&R)。 + * free=false 没有对应的分面组合,丢弃。 + */ +export function mergeLegacyFilters(sel: QuickSelection, legacy: { free?: string; hr?: string }): QuickSelection { + const add = (s: QuickSelection, dim: QuickDim, vals: string[]): QuickSelection => ({ + ...s, + [dim]: [...new Set([...(s[dim] ?? []), ...vals])], + }); + let next = sel; + if (legacy.free === "true") next = add(next, "promotion", ["free"]); + if (legacy.hr === "true") next = add(next, "hr", ["yes"]); + if (legacy.hr === "false") next = add(next, "hr", ["no", "unknown"]); + return next; +} + /** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */ export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`; diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index 15ee52b7..cb017f44 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -17,8 +17,8 @@ import { Link } from "react-router"; import { toast } from "sonner"; import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { FilterSelect } from "~/components/console/filter-bar.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { TechInfo } from "~/components/console/tech-info.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; @@ -26,13 +26,19 @@ import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatBytes } from "~/lib/utils.ts"; import { QuickFilterBar } from "./quick-filter-bar.tsx"; -import { formatQuickFilter, type QuickSelection, selectedCount, useQuickFilters } from "./quick-filter-state.ts"; +import { + formatQuickFilter, + mergeLegacyFilters, + type QuickSelection, + selectedCount, + useQuickFilters, +} from "./quick-filter-state.ts"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; // 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/ -// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标; -// 行操作同站点工作台(库存叠加、下载、委托 Agent)。 +// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页; +// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。 /** URL 里的资源筛选(字符串形态,空串 = 不筛) */ export type ReleaseFilterState = { @@ -76,7 +82,6 @@ export function toApiFilters( siteIds = "", quick: QuickSelection = {}, ): ReleaseSearchFilters { - const bool = (v: string) => (v === "true" || v === "false" ? v : undefined); const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined; return { ...target, @@ -87,8 +92,7 @@ export function toApiFilters( qf: formatQuickFilter(quick), minSize: gbToBytes(f.minGb), maxSize: gbToBytes(f.maxGb), - free: bool(f.free), - hr: bool(f.hr), + // free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发 sort, order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined, }; @@ -99,7 +103,6 @@ export function ReleaseResults({ siteIds, filters, setFilter, - resetFilters, onChangeSites, }: { target: ReleaseTarget; @@ -108,7 +111,6 @@ export function ReleaseResults({ onChangeSites?: () => void; filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; - resetFilters: () => void; }) { const [quick, setQuick] = useQuickFilters("search"); const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]); @@ -120,10 +122,20 @@ export function ReleaseResults({ const [dl, setDl] = useState(null); const filtered = selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); - const resetAll = () => { + // 旧链接里的免费 / H&R 参数并进快速筛选 + // biome-ignore lint/correctness/useExhaustiveDependencies: 只在旧参数出现时迁移一次 + useEffect(() => { + if (!filters.free && !filters.hr) return; + setQuick(mergeLegacyFilters(quick, { free: filters.free, hr: filters.hr })); + setFilter("free", ""); + setFilter("hr", ""); + }, [filters.free, filters.hr]); + /** 唯一的「清除」:分面、大小区间都清掉,排序保留 */ + const clearAll = () => { setQuick({}); - resetFilters(); + for (const k of RELEASE_FILTER_KEYS) if (k !== "sort" && k !== "order") setFilter(k, ""); }; + const sizeTag = sizeLabel(filters.minGb, filters.maxGb); return (
@@ -142,8 +154,7 @@ export function ReleaseResults({ {first ? (

{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "} - {relTime(first.fetchedAt)} · {first.tookMs} ms - {first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""} + {relTime(first.fetchedAt)}

) : null}
@@ -167,8 +178,34 @@ export function ReleaseResults({ {first ? : null} - {first ? : null} - {first ? : null} + {first ? ( + { + setFilter("minGb", ""); + setFilter("maxGb", ""); + }, + }, + ] + : [] + } + aside={{ + label: "大小与排序", + active: !!sizeTag || !!filters.sort || filters.order === "asc", + content: (layout) => , + }} + /> + ) : null}
{res.isPending ? ( @@ -202,6 +239,18 @@ export function ReleaseResults({ ) : null}
+ {first ? ( + `「${x}」`).join("") : null }, + { label: "搜索词", value: first.q || null }, + ...first.sites.map((x) => ({ label: x.siteName, value: `${x.count} 条 · ${x.tookMs} ms` })), + ]} + /> + ) : null} + {dl ? !o && setDl(null)} /> : null} ); @@ -258,100 +307,99 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) { ); } -function Filters({ +/** 大小区间的标签文字:「5–20 GB」「≥ 5 GB」「≤ 20 GB」 */ +export function sizeLabel(min: string, max: string): string | null { + if (min && max) return `${min}–${max} GB`; + if (min) return `≥ ${min} GB`; + if (max) return `≤ ${max} GB`; + return null; +} + +/** 大小区间 + 排序:桌面放在快速筛选同一行右侧,手机在抽屉里竖排 */ +function SizeSortControls({ filters, setFilter, - onReset, + layout, }: { filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; - onReset?: () => void; + layout: "inline" | "sheet"; }) { const [minGb, setMinGb] = useState(filters.minGb); const [maxGb, setMaxGb] = useState(filters.maxGb); useEffect(() => setMinGb(filters.minGb), [filters.minGb]); useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]); const desc = filters.order !== "asc"; + const sheet = layout === "sheet"; + const commit = (k: "minGb" | "maxGb", v: string) => setFilter(k, v.trim()); - return ( -
-
-
- 大小 - setMinGb(e.target.value)} - onBlur={() => setFilter("minGb", minGb.trim())} - onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())} - placeholder="最小" - aria-label="最小大小(GB)" - className="h-8 w-20" - /> - – - setMaxGb(e.target.value)} - onBlur={() => setFilter("maxGb", maxGb.trim())} - onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())} - placeholder="最大" - aria-label="最大大小(GB)" - className="h-8 w-20" - /> - GB -
- setFilter("free", v)} - options={[ - { value: "true", label: "只看免费" }, - { value: "false", label: "排除免费" }, - ]} - /> - setFilter("hr", v)} - options={[ - { value: "true", label: "只看 H&R" }, - { value: "false", label: "排除 H&R" }, - ]} - /> -
- - -
- {onReset ? ( - - ) : null} -
+ const size = ( +
+ 大小 + setMinGb(e.target.value)} + onBlur={() => commit("minGb", minGb)} + onKeyDown={(e) => e.key === "Enter" && commit("minGb", minGb)} + placeholder="最小" + aria-label="最小大小(GB)" + className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")} + /> + – + setMaxGb(e.target.value)} + onBlur={() => commit("maxGb", maxGb)} + onKeyDown={(e) => e.key === "Enter" && commit("maxGb", maxGb)} + placeholder="最大" + aria-label="最大大小(GB)" + className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")} + /> + GB
); + const sort = ( +
+ {sheet ? 排序 : null} + + +
+ ); + return sheet ? ( + <> + {size} + {sort} + + ) : ( + <> + {size} + + {sort} + + ); } function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) { diff --git a/apps/web/app/routes/console/search.tsx b/apps/web/app/routes/console/search.tsx index 65408721..759cdb09 100644 --- a/apps/web/app/routes/console/search.tsx +++ b/apps/web/app/routes/console/search.tsx @@ -82,7 +82,6 @@ export default function SearchPage() { siteIds={siteIds} filters={f.values as ReleaseFilterState} setFilter={f.set} - resetFilters={f.reset} onChangeSites={() => setPicking(true)} /> 0)) return false; if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false; if (f.size) { @@ -116,7 +114,7 @@ export default function SiteWorkbenchPage() { const q = useDebounced(qInput.trim()); const [category, setCategory] = useState(""); const [resolution, setResolution] = useState(""); - const [local, setLocal] = useState({ hr: "", seeding: "", size: "", age: "" }); + const [local, setLocal] = useState({ seeding: "", size: "", age: "" }); // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 const [quick, setQuick] = useQuickFilters("site"); const qf = formatQuickFilter(quick); @@ -167,7 +165,7 @@ export default function SiteWorkbenchPage() { setCategory(""); setQuick({}); setResolution(""); - setLocal({ hr: "", seeding: "", size: "", age: "" }); + setLocal({ seeding: "", size: "", age: "" }); }; const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick)); @@ -331,15 +329,6 @@ export default function SiteWorkbenchPage() { > - setLocal((l) => ({ ...l, hr: v }))} - options={[ - { value: "no", label: "无 H&R" }, - { value: "yes", label: "有 H&R" }, - ]} - /> - + {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} + {coverage || refreshFailed ? ( @@ -389,7 +379,7 @@ export default function SiteWorkbenchPage() { } description={ all.length - ? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。" + ? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。" : quickHidden ? "减少快速筛选条件再看。" : coverage?.liveError diff --git a/packages/contracts/src/library-versions.ts b/packages/contracts/src/library-versions.ts index bc6c0b34..b54057d4 100644 --- a/packages/contracts/src/library-versions.ts +++ b/packages/contracts/src/library-versions.ts @@ -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; export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt }); diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index a6cd04f5..4d6c51c4 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -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 = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" }; +const LANG_NAME: Record = { "zh-Hans": "简中", "zh-Hant": "繁中", zh: "中文", en: "英文", ja: "日文" }; + +const GAPS: Record 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))]; +} diff --git a/packages/contracts/src/release-facets.ts b/packages/contracts/src/release-facets.ts index 349e6d67..7a018afe 100644 --- a/packages/contracts/src/release-facets.ts +++ b/packages/contracts/src/release-facets.ts @@ -12,7 +12,7 @@ export type FacetCount = z.infer; * 快速筛选参数 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), /** 没有剧集资源时为空 */ diff --git a/packages/contracts/test/reasons.test.ts b/packages/contracts/test/reasons.test.ts index aaedff5c..ddd92aa0 100644 --- a/packages/contracts/test/reasons.test.ts +++ b/packages/contracts/test/reasons.test.ts @@ -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", + ]); + }); +}); diff --git a/packages/domain/src/library/versions.ts b/packages/domain/src/library/versions.ts index c6d165a7..7641e3ec 100644 --- a/packages/domain/src/library/versions.ts +++ b/packages/domain/src/library/versions.ts @@ -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 = { ...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"; + } } diff --git a/packages/domain/src/release-facets/index.ts b/packages/domain/src/release-facets/index.ts index 4b479d5a..4a8c169f 100644 --- a/packages/domain/src/release-facets/index.ts +++ b/packages/domain/src/release-facets/index.ts @@ -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 { 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"] } }; diff --git a/packages/domain/test/release-facets.test.ts b/packages/domain/test/release-facets.test.ts index fe8e97f8..d9a72bb5 100644 --- a/packages/domain/test/release-facets.test.ts +++ b/packages/domain/test/release-facets.test.ts @@ -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([]);