feat(contracts,server,web): files G6–G8 fields and identify S-7 contract
- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the unidentified filter, recursive, cached 5 min, invalidated by files.changed) - G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web subline uses it and no longer parses names itself - G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and catalog.lookup, local entities from metadata.posterPath); thumbnails in the identify panel - S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending, MediaServerInventory.partial, EventType identify.completed with IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard merge remote results by root + path, buffering events that beat the response Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fd2f7bc33d
commit
abc69886ea
26 files changed
+510
-134
No files matched your search
@@ -1,5 +1,12 @@
|
||||
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
|
||||
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
|
||||
import type {
|
||||
FileEntry,
|
||||
FileIdentifyItem,
|
||||
FileRoot,
|
||||
FileStatus,
|
||||
FilesIdentifyResult,
|
||||
IdentifyCompletedPayload,
|
||||
} from "~/api/files.ts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// ─── 状态徽标 ────────────────────────────────────────────────────
|
||||
@@ -8,7 +15,7 @@ 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: "符号链接指向登记目录之外,所有操作已禁用" };
|
||||
if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
|
||||
const s: FileStatus = e.status;
|
||||
switch (s) {
|
||||
case "temp":
|
||||
@@ -72,17 +79,17 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
|
||||
switch (op) {
|
||||
case "rename":
|
||||
if (!rootWritable) return "只读目录,不能改名";
|
||||
if (seeding) return "做种中,H&R 满足前不能改名";
|
||||
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 满足前不能移动";
|
||||
if (!rootWritable) return "只读目录,不能移出。可以复制";
|
||||
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
|
||||
return null;
|
||||
case "delete":
|
||||
if (!rootWritable) return "只读目录,不能删除";
|
||||
if (seeding) return "下载任务仍在使用,不能删除";
|
||||
if (!rootWritable) return "只读目录,不能删除";
|
||||
if (seeding) return "下载任务仍在使用,不能删除";
|
||||
return null;
|
||||
case "organize":
|
||||
case "identify":
|
||||
@@ -90,7 +97,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
|
||||
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
|
||||
return null;
|
||||
case "scrape":
|
||||
return e.asset ? null : "未登记,先整理入库";
|
||||
return e.asset ? null : "未登记,先整理入库";
|
||||
case "probe":
|
||||
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
|
||||
case "copy":
|
||||
@@ -189,8 +196,8 @@ export function transferTargetProblem(
|
||||
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 `目标在源目录之内(${baseName(it.path)}),换一个目录`;
|
||||
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -302,11 +309,11 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
|
||||
case "is_root":
|
||||
return { text: "不能删除登记目录本身", soft: false };
|
||||
case "needs_retire":
|
||||
return { text: "含已登记资产,勾选“同时退役资产”后才能删除", soft: true };
|
||||
return { text: "含已登记资产,勾选「同时退役资产」后才能删除", soft: true };
|
||||
case "needs_unbind":
|
||||
return { text: "是作品或季的绑定目录,勾选“解除路径绑定”后才能删除", soft: true };
|
||||
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
|
||||
case "root_unavailable":
|
||||
return { text: "目录离线,不能确认文件状态", soft: false };
|
||||
return { text: "目录离线,不能确认文件状态", soft: false };
|
||||
case "not_found":
|
||||
return { text: "文件已不存在", soft: false };
|
||||
case "escape":
|
||||
@@ -433,34 +440,7 @@ export const OP_LABEL: Record<MenuKey, string> = {
|
||||
delete: "删除",
|
||||
};
|
||||
|
||||
/** 从文件名做轻量解析(只用于待识别行的灰字副行,不查库):标题 · S01E01 · 1080p */
|
||||
export function parseHint(name: string): string | null {
|
||||
const base = name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
||||
const se = /\bS(\d{1,2})[ ._-]?E(\d{1,3})(?:-?E?(\d{1,3}))?/i.exec(base);
|
||||
const season = /\bS(\d{1,2})\b/i.exec(base);
|
||||
const yr = /[.( [](19\d{2}|20\d{2})(?=[.) \]]|$)/.exec(base);
|
||||
const res = /\b(2160p|1080p|1080i|720p|4K)\b/i.exec(base);
|
||||
const cuts = [se?.index, season?.index, yr?.index, res?.index].filter((x): x is number => x != null);
|
||||
const cut = cuts.length ? Math.min(...cuts) : -1;
|
||||
if (cut <= 0) return null;
|
||||
const title = base
|
||||
.slice(0, cut)
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/[-([ ]+$/, "")
|
||||
.trim();
|
||||
if (!title) return null;
|
||||
const parts = [title];
|
||||
if (yr && !se) parts[0] = `${title} (${yr[1]})`;
|
||||
if (se) {
|
||||
const s = `S${String(Number(se[1])).padStart(2, "0")}`;
|
||||
const e1 = `E${String(Number(se[2])).padStart(2, "0")}`;
|
||||
parts.push(se[3] ? `${s}${e1}-E${String(Number(se[3])).padStart(2, "0")}` : `${s}${e1}`);
|
||||
} else if (season) parts.push(`S${String(Number(season[1])).padStart(2, "0")}`);
|
||||
if (res) parts.push((res[1] as string).toLowerCase() === "4k" ? "2160p" : (res[1] as string).toLowerCase());
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 解析灰字;目录 → n 视频 · m 已登记 */
|
||||
/** 名称列副行(§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})` : ""}`;
|
||||
@@ -471,10 +451,7 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
|
||||
if (!s.videoCount && !s.registeredCount) return null;
|
||||
return { text: `${s.videoCount}${s.partial ? "+" : ""} 视频 · ${s.registeredCount} 已登记`, tone: "meta" };
|
||||
}
|
||||
if (e.type === "file" && e.fileKind === "video" && e.status === "unidentified") {
|
||||
const h = parseHint(e.name);
|
||||
return h ? { text: h, tone: "hint" } : null;
|
||||
}
|
||||
if (e.parsedHint) return { text: e.parsedHint, tone: "hint" };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -505,3 +482,37 @@ export function nameTaken(name: string, self: string, siblings: string[]): boole
|
||||
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 = `远端查询失败,只有本地结果${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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user