Files
UHD_Media_Tool/apps/web/app/components/files/model.ts
T
rootandClaude Opus 5.5 32bf49a148 文案(文件管理):按 human-copy 重写对话框、状态、菜单与提示
- 永久删除的说明改成实际开关 FILES_ALLOW_PERMANENT_DELETE,去掉「在设置里开启」的错误说法
- 去掉「待处理」「仅 TMDB」「目录根」等旧称,收录版本退回状态写「需处理」
- 删除、移动/复制、重命名、刮削、刷新元数据对话框去掉复述界面的说明
- 加载与进行中文字统一带省略号;Toast 只写结果

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:20 +08:00

616 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
import type {
FileEntry,
FileIdentifyItem,
FileRoot,
FileStatus,
FilesIdentifyResult,
IdentifyCompletedPayload,
} from "~/api/files.ts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// ─── 状态徽标 ────────────────────────────────────────────────────
export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,不能操作" };
const s: FileStatus = e.status;
switch (s) {
case "temp":
return { label: "未完成", tone: "neutral", hint: "下载或整理还没完成的临时文件" };
case "downloading":
return {
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
tone: "neutral",
};
case "registered":
return { label: "已整理", tone: "success" };
case "seeding":
return e.download?.hnr === "unsatisfied"
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
: { label: "做种中", tone: "neutral" };
case "unidentified":
return { label: "未识别", tone: "warning" };
default:
return null;
}
}
export const FILTER_CHIPS = [
{ value: "", label: "全部" },
{ value: "video", label: "视频" },
{ value: "unidentified", label: "未识别" },
{ value: "registered", label: "已整理" },
{ value: "downloading", label: "下载中" },
{ value: "temp", label: "未完成" },
] as const;
export const SORT_OPTIONS = [
{ value: "name", label: "名称" },
{ value: "mtime", label: "修改时间" },
{ value: "size", label: "大小" },
{ value: "status", label: "状态" },
] as const;
// ─── 禁用原因(菜单 tooltip 用) ───────────────────────────────────
export type FileOp =
| "open"
| "rename"
| "move"
| "copy"
| "organize"
| "identify"
| "scrape"
| "writeNfo"
| "probe"
| "delete"
| "copyPath"
| "openMedia"
| "records";
/** 返回 null = 可用;否则为禁用原因 */
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
if (e.escape && op !== "copyPath") return "链接指向登记目录之外";
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
const rootWritable = root.writable || root.kind === "download";
const seeding = e.status === "seeding" || e.status === "downloading";
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
switch (op) {
case "rename":
if (!rootWritable) return "只读目录,不能改名";
if (seeding) return "做种中,H&R 满足前不能改名";
if (e.status === "temp") return "未完成的文件不能改名";
return null;
case "move":
if (!rootWritable) return "只读目录,不能移出。可以复制";
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
return null;
case "delete":
if (!rootWritable) return "只读目录,不能删除";
if (seeding) return "下载任务仍在使用,不能删除";
return null;
case "organize":
case "identify":
if (e.status === "temp") return "未完成的文件不能整理";
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
return e.asset ? null : "整理后才能刷新元数据";
case "writeNfo":
if (root.kind === "library" && !root.writable) return "只读目录,不能刮削";
if (e.status === "temp") return "未完成的文件不能刮削";
return null;
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "copy":
return null;
case "openMedia":
return e.asset ? null : "未关联作品";
case "records":
return e.asset || e.status === "registered" ? null : "没有整理记录";
default:
return null;
}
}
// ─── 选择模型(Shift 区间 / Ctrl 切换 / 全选) ─────────────────────
export type Selection = { keys: Set<string>; anchor: string | null; focus: string | null };
export const emptySelection = (): Selection => ({ keys: new Set(), anchor: null, focus: null });
/** 点击一行:plain 单选;ctrl 切换;shift 从锚点到当前的区间(与 ctrl 同按则并入) */
export function clickSelect(
sel: Selection,
order: string[],
key: string,
mod: { shift?: boolean; ctrl?: boolean },
): Selection {
if (mod.shift && sel.anchor && order.includes(sel.anchor)) {
const a = order.indexOf(sel.anchor);
const b = order.indexOf(key);
const [lo, hi] = a < b ? [a, b] : [b, a];
const range = order.slice(lo, hi + 1);
const keys = mod.ctrl ? new Set([...sel.keys, ...range]) : new Set(range);
return { keys, anchor: sel.anchor, focus: key };
}
if (mod.ctrl) {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
return { keys: new Set([key]), anchor: key, focus: key };
}
/** Space:切换焦点行的勾选 */
export function toggleSelect(sel: Selection, key: string): Selection {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
export function selectAll(order: string[], sel: Selection): Selection {
return { keys: new Set(order), anchor: sel.anchor ?? order[0] ?? null, focus: sel.focus ?? order[0] ?? null };
}
/** ↑↓ 移动焦点;shift 时从锚点扩选 */
export function moveFocus(sel: Selection, order: string[], delta: number, shift: boolean): Selection {
if (order.length === 0) return sel;
const cur = sel.focus ? order.indexOf(sel.focus) : -1;
const next = Math.max(0, Math.min(order.length - 1, cur < 0 ? (delta > 0 ? 0 : order.length - 1) : cur + delta));
const key = order[next] as string;
if (shift) return clickSelect(sel.anchor ? sel : { ...sel, anchor: sel.focus ?? key }, order, key, { shift: true });
return { ...sel, focus: key };
}
/** 列表刷新后丢掉已不存在的键 */
export function pruneSelection(sel: Selection, order: string[]): Selection {
const present = new Set(order);
const keys = new Set([...sel.keys].filter((k) => present.has(k)));
return {
keys,
anchor: sel.anchor && present.has(sel.anchor) ? sel.anchor : null,
focus: sel.focus && present.has(sel.focus) ? sel.focus : null,
};
}
// ─── 路径工具(根内相对路径,"" = 根) ─────────────────────────────
export function joinRel(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}
export function parentRel(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? "" : path.slice(0, i);
}
/**
* 移动 / 复制目标的前端预检(与服务端 transfer 校验同口径,QA U13):目标就是源所在目录,
* 或目标在源目录之内时直接提示,不发预览请求。
*/
export function transferTargetProblem(
items: { root: string; path: string }[],
target: { root: string; path: string },
): string | null {
for (const it of items) {
if (it.root !== target.root) continue;
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
return `目标不能是 ${baseName(it.path)} 或它的子目录,换一个目录`;
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
}
return null;
}
export function baseName(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? path : path.slice(i + 1);
}
export function segments(path: string): string[] {
return path ? path.split("/").filter(Boolean) : [];
}
/** 面包屑折叠:超过 max 段时保留首段(根)+ … + 最后 max-1 段 */
export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
if (items.length <= max) return items;
return [items[0] as T, null, ...items.slice(items.length - (max - 1))];
}
/** 名称校验(前端先挡一遍,服务端 sanitizeSegment 才是准绳) */
export function nameProblem(name: string): string | null {
const n = name.trim();
if (!n) return "名称不能为空";
if (n === "." || n === "..") return "名称不能是 . 或 ..";
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
if (n.length > 255) return "名称不能超过 255 个字符";
return null;
}
/** 改名时默认只选中主名(不含扩展名) */
export function stemRange(name: string, isDir: boolean): [number, number] {
if (isDir) return [0, name.length];
const i = name.lastIndexOf(".");
return [0, i > 0 ? i : name.length];
}
/** 选中项合计大小(目录不计) */
export function totalSize(entries: FileEntry[]): number {
return entries.reduce((s, e) => s + (e.type === "file" ? e.size : 0), 0);
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
}
/** 集号偏移:episodes 全体加 n(n 可为负);结果出现小于 1 的集号返回 null(调用方标为问题,不静默截断) */
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined | null {
if (!eps?.length) return eps;
const out = eps.map((e) => e + n);
return out.some((e) => e < 1) ? null : out;
}
/** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */
export function parseEpisodes(text: string): number[] | null {
const t = text.trim();
if (!t) return [];
const out: number[] = [];
for (const part of t.split(/[,\s]+/)) {
const m = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(part);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (b < a || b - a > 200) return null;
for (let i = a; i <= b; i++) out.push(i);
}
return out;
}
export function formatEpisodes(eps: number[] | undefined | null): string {
if (!eps?.length) return "";
const sorted = [...eps].sort((a, b) => a - b);
const first = sorted[0] as number;
const last = sorted[sorted.length - 1] as number;
if (sorted.length > 1 && last - first === sorted.length - 1) return `${first}-${last}`;
return sorted.join(",");
}
export function contentUnitLabel(season?: number | null, episodes?: number[] | null): string {
if (season == null) return "";
const s = `S${String(season).padStart(2, "0")}`;
if (!episodes?.length) return s;
const sorted = [...episodes].sort((a, b) => a - b);
const e = (n: number) => `E${String(n).padStart(2, "0")}`;
return sorted.length > 1
? `${s}${e(sorted[0] as number)}-${e(sorted[sorted.length - 1] as number)}`
: `${s}${e(sorted[0] as number)}`;
}
/** 前后路径对照:按 / 段取公共前缀,差异部分单独高亮 */
export function splitCommon(a: string, b: string): { common: string; a: string; b: string } {
const sa = a.split("/");
const sb = b.split("/");
let i = 0;
while (i < sa.length - 1 && i < sb.length - 1 && sa[i] === sb[i]) i++;
const common = i ? `${sa.slice(0, i).join("/")}/` : "";
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
}
/** 删除阻断原因文案;可通过勾选解除的返回 soft=true */
export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null {
switch (b) {
case null:
return null;
case "download_active":
return { text: "下载任务仍在做种或 H&R 未满足", soft: false };
case "is_root":
return { text: "不能删除登记目录本身", soft: false };
case "needs_retire":
return { text: "含已整理文件,勾选「同时从媒体库移除」后才能删除", soft: true };
case "needs_unbind":
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
case "root_unavailable":
return { text: "目录离线,无法确认文件状态", soft: false };
case "not_found":
return { text: "文件已不存在", soft: false };
case "escape":
return { text: "链接指向登记目录之外", soft: false };
default:
return { text: b, soft: false };
}
}
// ─── v2:菜单、行内操作、副行、初始位置 ─────────────────────────────
/** 菜单项键(顺序即规格 §5.2 的最终顺序) */
export type MenuKey =
| "open"
| "organize"
| "identify"
| "rename"
| "move"
| "copy"
| "scrape"
| "writeNfo"
| "probe"
| "copyPath"
| "openMedia"
| "records"
| "delete";
export type MenuModelItem = { key: MenuKey; disabled: string | null } | { key: `sep${number}`; separator: true };
const isVideo = (e: FileEntry) => e.type === "file" && e.fileKind === "video";
const hasRecords = (e: FileEntry) => !!e.asset || e.status === "registered";
/** 不适用的项隐藏(返回 false);被状态阻止的项另由 opDisabledReason 给原因 */
export function opApplies(op: MenuKey, e: FileEntry): boolean {
switch (op) {
case "organize":
return !e.asset && (isVideo(e) || e.type === "dir") && e.status !== "temp";
case "identify":
return isVideo(e) || e.type === "dir";
case "writeNfo":
// SCR-2:照 MP,任意视频文件或目录都能刮削(不要求已登记)
return isVideo(e) || e.type === "dir";
case "scrape":
case "openMedia":
return !!e.asset;
case "records":
return hasRecords(e);
case "probe":
return isVideo(e);
default:
return true;
}
}
/**
* 单项 / 多选菜单的项与顺序(规格 §5.2):
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 刮削 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 刮削 ─ 删除 n 项
*/
export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writable">): MenuModelItem[] {
const multi = list.length > 1;
const why = (op: FileOp) => list.map((x) => opDisabledReason(op, x, root)).find(Boolean) ?? null;
const any = (op: MenuKey) => list.some((x) => opApplies(op, x));
const all = (op: MenuKey) => list.every((x) => opApplies(op, x));
const out: MenuModelItem[] = [];
const sep = () => {
const last = out[out.length - 1];
if (last && !("separator" in last)) out.push({ key: `sep${out.length}`, separator: true });
};
const add = (key: MenuKey, disabled: string | null) => out.push({ key, disabled });
if (!multi) {
const e = list[0] as FileEntry;
add("open", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("organize", e)) add("organize", why("organize"));
if (opApplies("identify", e)) add("identify", why("identify"));
add("rename", why("rename"));
add("move", why("move"));
add("copy", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("scrape", e)) add("scrape", why("scrape"));
if (opApplies("writeNfo", e)) add("writeNfo", why("writeNfo"));
if (opApplies("probe", e)) add("probe", null);
sep();
add("copyPath", null);
if (opApplies("openMedia", e)) add("openMedia", null);
if (opApplies("records", e)) add("records", null);
sep();
add("delete", why("delete"));
return out;
}
if (any("identify"))
add(
"organize",
list
.filter((x) => opApplies("identify", x))
.map((x) => opDisabledReason("organize", x, root))
.find(Boolean) ?? null,
);
if (all("identify")) add("identify", why("identify"));
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) add("scrape", null);
if (all("writeNfo")) add("writeNfo", why("writeNfo"));
sep();
add("delete", why("delete"));
return out;
}
/**
* 行内 hover / 键盘聚焦时浮出的常用操作(桌面,§5.1;照 MP 的悬停图标,但只露 2–3 个,其余留在 ⋯ 菜单):
* 已整理 → 打开作品、刷新元数据、重命名;未整理视频 / 目录 → 整理、识别、刮削;
* 其余能识别的(临时文件里的视频等)→ 识别、重命名、移动到;别的文件 → 重命名、移动到。
*/
export function hoverOps(e: FileEntry): MenuKey[] {
if (e.asset) return ["openMedia", "scrape", "rename"];
if (opApplies("organize", e)) return ["organize", "identify", "writeNfo"];
if (opApplies("identify", e)) return ["identify", "rename", "move"];
return ["rename", "move"];
}
/** 行内图标按钮的文字(tooltip / aria-label):菜单里的"…"表示还要再选一步,图标上不带 */
export function hoverOpLabel(op: MenuKey): string {
return OP_LABEL[op].replace("…", "");
}
export const OP_LABEL: Record<MenuKey, string> = {
open: "打开",
organize: "整理…",
identify: "识别",
rename: "重命名",
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
writeNfo: "刮削…",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
records: "查看整理记录",
delete: "删除",
};
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 服务端轻量解析 parsedHint(G7);目录 → n 视频 · m 已登记 */
export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint" | "meta" } | null {
if (e.asset) {
const t = `${e.asset.title}${e.asset.year ? ` (${e.asset.year})` : ""}`;
return { text: [t, e.asset.contentUnit].filter(Boolean).join(" · "), tone: "link" };
}
if (e.type === "dir" && e.dirSummary) {
const s = e.dirSummary;
if (!s.videoCount && !s.registeredCount) return null;
return {
text: `${s.videoCount}${s.partial ? "+" : ""} 个视频${s.registeredCount ? ` · ${s.registeredCount} 个已整理` : s.videoCount ? " · 未整理" : ""}`,
tone: "meta",
};
}
if (e.parsedHint) return { text: e.parsedHint, tone: "hint" };
return null;
}
export const ROOT_KIND_LABEL: Record<FileRoot["kind"], string> = {
download: "下载目录",
library: "媒体库",
storage: "Alist 存储",
};
export type Crumb = { label: string; path: string };
/**
* 面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)+ 登记目录名 + 各级子目录。
* group 不可点;crumbs 每段可点(path 为登记目录内相对路径,根为 "")。
*/
export function breadcrumbItems(
root: Pick<FileRoot, "name" | "kind">,
path: string,
): { group: string; crumbs: Crumb[] } {
const segs = segments(path);
return {
group: ROOT_KIND_LABEL[root.kind],
crumbs: [
{ label: root.name, path: "" },
...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })),
],
};
}
export type Loc = { root: string; path: string };
/** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */
export function initialLocation(roots: Pick<FileRoot, "id" | "kind" | "health">[], last: Loc | null): Loc | null {
if (!roots.length) return null;
const online = roots.filter((r) => r.health !== "offline");
if (last && online.some((r) => r.id === last.root)) return last;
const pick = online.find((r) => r.kind === "download") ?? online.find((r) => r.kind === "library") ?? roots[0];
return pick ? { root: pick.id, path: "" } : null;
}
/** 登记目录分组顺序:下载目录在前(整理从下载开始),媒体库在后 */
export function groupRoots<T extends Pick<FileRoot, "kind">>(
roots: T[],
): { title: string; kind: string; items: T[] }[] {
return [
{ title: "下载目录", kind: "download", items: roots.filter((r) => r.kind === "download") },
{ title: "媒体库", kind: "library", items: roots.filter((r) => r.kind === "library") },
{ title: "Alist 存储", kind: "storage", items: roots.filter((r) => r.kind === "storage") },
].filter((g) => g.items.length > 0);
}
/** 根 + 相对路径的显示:本机给本机路径;Alist 给「存储名:/Alist 内路径」(不暴露内部 /@alist 前缀) */
export function displayPath(root: Pick<FileRoot, "hostPath" | "storage">, rel = ""): string {
const base = root.storage
? `${root.storage.name}:${root.storage.path === "/" ? "" : root.storage.path}`
: root.hostPath;
const joined = `${base}${rel ? `/${rel}` : ""}`;
return root.storage && !rel && root.storage.path === "/" ? `${root.storage.name}:/` : joined;
}
/** 目标名在当前目录已存在(忽略自身;不区分大小写只作提示,服务端仍按字节判断) */
export function nameTaken(name: string, self: string, siblings: string[]): boolean {
const n = name.trim();
if (!n || n === self) return false;
return siblings.some((s) => s !== self && s === n);
}
// ─── 识别:远端结果回填(S-7) ────────────────────────────────────
const identifyKey = (it: Pick<FileIdentifyItem, "root" | "path">) => `${it.root}\u0000${it.path}`;
/** 服务端给"远端在后台查"项加的提示;结果回来后去掉 */
const REMOTE_PENDING_WARNING = /^远端候选在后台查询/;
/**
* SSE identify.completed 回填:taskId 与响应的 remoteTaskId 一致时,按 root + path 用完整结果替换条目;
* 失败时保留本地结果,把"后台查询中"提示换成失败原因。不相关的事件原样返回。
*/
export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult {
if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r;
if (p.status === "failed") {
const note = `TMDB 查询失败,只显示本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`;
return {
...r,
remoteTaskId: null,
remotePending: 0,
items: r.items.map((it) =>
it.warnings.some((w) => REMOTE_PENDING_WARNING.test(w))
? { ...it, warnings: [...it.warnings.filter((w) => !REMOTE_PENDING_WARNING.test(w)), note] }
: it,
),
};
}
const by = new Map(p.items.map((it) => [identifyKey(it), it]));
return {
...r,
remoteTaskId: null,
remotePending: 0,
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
};
}
// ── 预览(FM-2)──
export type PreviewKind = "text" | "subtitle" | "torrent" | "image";
const PREVIEW_EXT: Record<string, PreviewKind> = {};
for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" "))
PREVIEW_EXT[e] = "text";
for (const e of "srt vtt ass ssa sup".split(" ")) PREVIEW_EXT[e] = "subtitle";
for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) PREVIEW_EXT[e] = "image";
PREVIEW_EXT.torrent = "torrent";
/** 与服务端 preview-parse.ts 同一张扩展名表;目录、链接、无扩展名返回 null */
export function previewKindOf(e: Pick<FileEntry, "type" | "ext">): PreviewKind | null {
if (e.type !== "file" || !e.ext) return null;
return PREVIEW_EXT[e.ext.toLowerCase()] ?? null;
}
/** 毫秒 → 00:01:02.345 */
export function cueTime(ms: number): string {
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
const f = ms % 1000;
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(h)}:${p(m)}:${p(s)}.${p(f, 3)}`;
}
/** JSON 文本美化;解析失败或已截断时原样返回 */
export function prettyJson(text: string, truncated: boolean): string {
if (truncated) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}