参考 MP 资源卡片:促销放在标题前(免费用 success,其余折扣中性加粗,带剩余时间); 规格拆成中性小标签(季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 语言 → 制作组); 站点 / 大小 / 做种·下载 / 发布时间宽屏定宽对齐、窄屏折行,做种加粗、为 0 时弱化; 结果涉及多部作品时按作品分组(组头 = 作品名 + 年份 + 库存状态),只有一部时不重复作品名; 每行显示服务端规则判定(命中第 N 档 / 不满足 / 与库里版本比较)。保留工作台、委托智能体、下载。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
228 lines
8.1 KiB
TypeScript
228 lines
8.1 KiB
TypeScript
import type {
|
||
LocalMediaHit,
|
||
MediaKind,
|
||
SearchInventoryState,
|
||
SearchRelease,
|
||
SearchReleaseParsed,
|
||
SearchRuleVerdict,
|
||
SearchSiteStatus,
|
||
} from "@umt/contracts";
|
||
import { Compass, type LucideIcon, Zap } from "lucide-react";
|
||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||
|
||
// 搜索结果的展示口径
|
||
|
||
export const mediaKindLabel = (k: MediaKind) => MEDIA_KIND[k] ?? k;
|
||
|
||
/** 本地作品的一行摘要:订阅 / 版本 / 下载中 / 缺口 */
|
||
export function slotSummary(h: LocalMediaHit): string {
|
||
const c = h.slotCounts;
|
||
const parts: string[] = [];
|
||
if (h.subscribed) parts.push("已订阅");
|
||
if (h.versionCount) parts.push(`${h.versionCount} 个版本`);
|
||
if (h.downloadingCount) parts.push(`${h.downloadingCount} 个下载中`);
|
||
const gaps = c.empty + c.attention + c.upgrade_pending;
|
||
if (gaps) parts.push(`${gaps} 个收录版本未满足`);
|
||
return parts.join(" · ") || "未订阅";
|
||
}
|
||
|
||
export const actionIcon = (kind: "page" | "action"): LucideIcon => (kind === "page" ? Compass : Zap);
|
||
|
||
export const INVENTORY_STATE: Record<SearchInventoryState, { label: string; tone: StatusTone }> = {
|
||
downloading: { label: "下载中", tone: "warning" },
|
||
gap: { label: "有缺口", tone: "warning" },
|
||
owned: { label: "已有版本", tone: "success" },
|
||
subscribed: { label: "已订阅", tone: "neutral" },
|
||
none: { label: "本地无版本", tone: "neutral" },
|
||
};
|
||
|
||
export const SITE_STATUS: Record<SearchSiteStatus, string> = {
|
||
ok: "正常",
|
||
failed: "失败",
|
||
timeout: "超时",
|
||
skipped: "跳过",
|
||
};
|
||
|
||
export const SORT_OPTIONS = [
|
||
{ value: "default", label: "默认" },
|
||
{ value: "seeders", label: "做种数" },
|
||
{ value: "size", label: "大小" },
|
||
{ value: "uploadedAt", label: "发布时间" },
|
||
{ value: "site", label: "站点" },
|
||
] as const;
|
||
|
||
const PROMO: Record<string, string> = {
|
||
free: "免费",
|
||
"2xfree": "2x 免费",
|
||
"50%": "50%",
|
||
"2x50%": "2x 50%",
|
||
"30%": "30%",
|
||
"2x": "2x 上传",
|
||
};
|
||
/** 促销文案;none/unknown 返回 null */
|
||
export function promoLabel(type: string): string | null {
|
||
if (!type || type === "none" || type === "unknown") return null;
|
||
return PROMO[type] ?? type;
|
||
}
|
||
|
||
const GB = 1024 ** 3;
|
||
export const gbToBytes = (s: string): number | undefined => {
|
||
const n = Number(s);
|
||
return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined;
|
||
};
|
||
|
||
// ── 资源行(SEARCH-5:学 MP 的规格标签、促销角标、按作品分组;加上本工具的规则判定)──
|
||
|
||
export type SpecDim = "episode" | "resolution" | "source" | "codec" | "hdr" | "audio" | "lang" | "group";
|
||
export interface ReleaseSpecTag {
|
||
key: string;
|
||
dim: SpecDim;
|
||
label: string;
|
||
/** 悬停提示:「分辨率:1080p」 */
|
||
title: string;
|
||
}
|
||
|
||
export const SPEC_DIM_LABEL: Record<SpecDim, string> = {
|
||
episode: "季集",
|
||
resolution: "分辨率",
|
||
source: "来源",
|
||
codec: "编码",
|
||
hdr: "HDR",
|
||
audio: "音频",
|
||
lang: "语言",
|
||
group: "制作组",
|
||
};
|
||
|
||
const SUB_LABEL: Record<string, string> = {
|
||
zh: "中字",
|
||
"zh-Hans": "简中",
|
||
"zh-Hant": "繁中",
|
||
en: "英字",
|
||
ja: "日字",
|
||
ko: "韩字",
|
||
};
|
||
const DUB_LABEL: Record<string, string> = { cmn: "国语", yue: "粤语", en: "英语配音" };
|
||
|
||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||
|
||
/**
|
||
* 规格标签,固定顺序:季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 配音 / 字幕语言 → 制作组。
|
||
* 都来自标题解析;同名去重,不猜。
|
||
*/
|
||
export function releaseSpecTags(p: SearchReleaseParsed): ReleaseSpecTag[] {
|
||
const out: ReleaseSpecTag[] = [];
|
||
const seen = new Set<string>();
|
||
const add = (dim: SpecDim, label: string | null | undefined) => {
|
||
const l = label?.trim();
|
||
if (!l || seen.has(`${dim}:${l.toLowerCase()}`)) return;
|
||
seen.add(`${dim}:${l.toLowerCase()}`);
|
||
out.push({ key: `${dim}:${l}`, dim, label: l, title: `${SPEC_DIM_LABEL[dim]}:${l}` });
|
||
};
|
||
if (p.season !== null || p.episodes) {
|
||
const s =
|
||
p.season !== null
|
||
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
|
||
: "";
|
||
const e = p.episodes
|
||
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
|
||
: "";
|
||
add("episode", `${s}${e}`);
|
||
}
|
||
add("resolution", p.resolution);
|
||
add("source", p.source);
|
||
add("codec", p.videoCodec);
|
||
for (const h of p.hdr) add("hdr", h);
|
||
for (const a of p.audio ?? []) add("audio", a);
|
||
for (const l of p.audioLangs ?? []) add("lang", DUB_LABEL[l] ?? l);
|
||
for (const l of p.subtitles ?? []) add("lang", SUB_LABEL[l] ?? l);
|
||
add("group", p.releaseGroup);
|
||
return out;
|
||
}
|
||
|
||
/** 促销标签:免费(含 2x 免费)属状态,用 success;其余折扣中性 */
|
||
export function promoBadge(
|
||
r: Pick<SearchRelease, "promotion" | "free">,
|
||
): { label: string; tone: "success" | "neutral" } | null {
|
||
const label = promoLabel(r.promotion.type);
|
||
if (!label) return null;
|
||
return { label, tone: r.free ? "success" : "neutral" };
|
||
}
|
||
|
||
/** H&R:明确有 → warning;未知 → 中性「H&R 未知」;明确没有 → 不显示 */
|
||
export function hrBadge(r: Pick<SearchRelease, "hr">): { label: string; tone: "warning" | "neutral" } | null {
|
||
if (r.hr === true) return { label: "H&R", tone: "warning" };
|
||
if (r.hr === "unknown") return { label: "H&R 未知", tone: "neutral" };
|
||
return null;
|
||
}
|
||
|
||
export interface VerdictView {
|
||
label: string;
|
||
tone: "success" | "neutral";
|
||
/** 悬停提示:档位名、不满足的逐条说明 */
|
||
title: string;
|
||
}
|
||
|
||
/** 规则判定 → 文案:「命中第 1 档」「满足收录条件」「不满足:分辨率、来源」「无法判断:分辨率」;多个收录版本时带名字 */
|
||
export function verdictView(v: SearchRuleVerdict): VerdictView {
|
||
const dims = (xs: string[]) => (xs.length > 3 ? `${xs.slice(0, 3).join("、")} 等 ${xs.length} 项` : xs.join("、"));
|
||
const text =
|
||
v.outcome === "hit"
|
||
? v.tier !== null
|
||
? `命中第 ${v.tier} 档`
|
||
: "满足收录条件"
|
||
: v.outcome === "miss"
|
||
? `不满足:${dims(v.failed) || "收录条件"}`
|
||
: `无法判断:${dims(v.failed) || "缺少字段"}`;
|
||
const label = v.slotCount > 1 ? `「${v.slotName}」${text}` : text;
|
||
const title = [v.slotCount > 1 ? `收录版本「${v.slotName}」` : null, v.tierName, ...v.details]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
return { label, tone: v.outcome === "hit" ? "success" : "neutral", title };
|
||
}
|
||
|
||
/** 与库里版本比较:「比库里版本高 1 档」「比库里版本好」「与库里版本同档」「不如库里版本」 */
|
||
export function vsLibraryLabel(v: SearchRuleVerdict): string | null {
|
||
const c = v.vsLibrary;
|
||
if (!c) return null;
|
||
if (c.cmp === "higher") return c.steps ? `比库里版本高 ${c.steps} 档` : "比库里版本好";
|
||
if (c.cmp === "same") return "与库里版本同档";
|
||
return "不如库里版本";
|
||
}
|
||
|
||
export interface WorkGroup {
|
||
/** 作品 ID;未关联作品的一组为 null */
|
||
key: string | null;
|
||
entity: SearchRelease["entity"];
|
||
entityMatch: SearchRelease["entityMatch"];
|
||
inventory: SearchRelease["inventory"];
|
||
items: SearchRelease[];
|
||
}
|
||
|
||
/**
|
||
* 按作品分组(保持结果顺序,组按首次出现排;未关联作品的放最后)。
|
||
* 只涉及一部作品(或全都未关联)时 grouped = false:作品名只在列表上方出现一次,行里不重复。
|
||
*/
|
||
export function groupByWork(rows: readonly SearchRelease[]): { grouped: boolean; groups: WorkGroup[] } {
|
||
const map = new Map<string, WorkGroup>();
|
||
let loose: WorkGroup | null = null;
|
||
for (const r of rows) {
|
||
if (!r.entity) {
|
||
loose ??= { key: null, entity: null, entityMatch: null, inventory: null, items: [] };
|
||
loose.items.push(r);
|
||
continue;
|
||
}
|
||
let g = map.get(r.entity.id);
|
||
if (!g) {
|
||
g = { key: r.entity.id, entity: r.entity, entityMatch: r.entityMatch, inventory: r.inventory, items: [] };
|
||
map.set(r.entity.id, g);
|
||
}
|
||
// 同一作品里有已识别的,组头就不标「未确认」
|
||
if (r.entityMatch === "resolved") g.entityMatch = "resolved";
|
||
g.inventory ??= r.inventory;
|
||
g.items.push(r);
|
||
}
|
||
const groups = [...map.values(), ...(loose ? [loose] : [])];
|
||
return { grouped: groups.length > 1, groups };
|
||
}
|