feat(web): file manager v2 and shared organize wizard (FE-4)
- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px - 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多" - 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB - 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行), TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、 plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录 - 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…" - 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09a6b0b5d3
commit
c31df8b69a
24 files changed
+4820
-1607
No files matched your search
@@ -13,6 +13,7 @@ import type {
|
||||
FilesTransferResult,
|
||||
TrashItem,
|
||||
} from "@umt/contracts";
|
||||
import type { MediaSearchResult } from "~/api/files.ts";
|
||||
import { ApiRequestError } from "~/api/parse.ts";
|
||||
import type { FetcherArg } from "~/lib/api.ts";
|
||||
|
||||
@@ -558,74 +559,116 @@ function parseName(name: string) {
|
||||
}
|
||||
|
||||
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
|
||||
{ match: /severance/i, id: "e-sev", title: "人生切割术", year: 2022, kind: "series" },
|
||||
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
|
||||
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
|
||||
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
|
||||
];
|
||||
|
||||
/** 本地作品搜索(向导里改选作品) */
|
||||
export function mockEntitySearch(q: string) {
|
||||
/** 作品选择器搜索:本地作品 + 一条 TMDB 候选(演示"将新建作品") */
|
||||
export function mockMediaSearch(q: string): MediaSearchResult {
|
||||
const t = q.trim().toLowerCase();
|
||||
return KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
|
||||
const local = KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
|
||||
id: k.id,
|
||||
title: k.title,
|
||||
year: k.year,
|
||||
kind: k.kind,
|
||||
alias: null,
|
||||
}));
|
||||
const tmdb = t
|
||||
? [
|
||||
{
|
||||
provider: "tmdb" as const,
|
||||
entityType: "tv" as const,
|
||||
externalId: "95396",
|
||||
title: "人生切割术",
|
||||
originalTitle: "Severance",
|
||||
year: 2022,
|
||||
overview: null,
|
||||
popularity: null,
|
||||
localEntityId: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return { local, tmdb, tmdbConfigured: true };
|
||||
}
|
||||
|
||||
function identify(items: ItemRef[]): { items: FileIdentifyItem[]; truncated: boolean } {
|
||||
function identify(
|
||||
items: ItemRef[],
|
||||
minSize = 0,
|
||||
): { items: FileIdentifyItem[]; truncated: boolean; excludedBySize: number } {
|
||||
const out: FileIdentifyItem[] = [];
|
||||
let excludedBySize = 0;
|
||||
for (const it of items) {
|
||||
const { node } = find(it.root, it.path);
|
||||
const files = node.dir
|
||||
? lazyChildren(node)
|
||||
.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video")
|
||||
.slice(0, 200)
|
||||
.map((c) => ({ path: join(it.path, c.name), name: c.name }))
|
||||
: [{ path: it.path, name: node.name }];
|
||||
.map((c) => ({ path: join(it.path, c.name), name: c.name, size: c.size, meta: c.meta }))
|
||||
: [{ path: it.path, name: node.name, size: node.size, meta: node.meta }];
|
||||
for (const fl of files) {
|
||||
const parsed = parseName(fl.name);
|
||||
const hit = KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
||||
const candidates = hit
|
||||
const small = fl.size < minSize;
|
||||
if (small) excludedBySize++;
|
||||
const hit = small ? undefined : KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
||||
const sev = !small && /severance/i.test(parsed.title ?? "");
|
||||
const candidates = sev
|
||||
? [
|
||||
{
|
||||
mediaEntityId: hit.id,
|
||||
provider: "local",
|
||||
externalId: null,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
kind: hit.kind,
|
||||
level: "high" as const,
|
||||
score: 0.94,
|
||||
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
|
||||
},
|
||||
{
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: "99999",
|
||||
title: `${parsed.title}(同名旧作)`,
|
||||
year: (parsed.year ?? hit.year) - 30,
|
||||
kind: hit.kind,
|
||||
level: "low" as const,
|
||||
score: 0.31,
|
||||
reasons: ["仅标题相似"],
|
||||
externalId: "95396",
|
||||
title: "人生切割术",
|
||||
year: 2022,
|
||||
kind: "series" as const,
|
||||
level: "high" as const,
|
||||
score: 0.9,
|
||||
reasons: ["TMDB 标题命中", "季集结构一致"],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
: hit
|
||||
? [
|
||||
{
|
||||
mediaEntityId: hit.id,
|
||||
provider: "local",
|
||||
externalId: null,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
kind: hit.kind,
|
||||
level: "high" as const,
|
||||
score: 0.94,
|
||||
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
|
||||
},
|
||||
{
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: "99999",
|
||||
title: `${parsed.title}(同名旧作)`,
|
||||
year: (parsed.year ?? hit.year) - 30,
|
||||
kind: hit.kind,
|
||||
level: "low" as const,
|
||||
score: 0.31,
|
||||
reasons: ["仅标题相似"],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
out.push({
|
||||
root: it.root,
|
||||
path: fl.path,
|
||||
parsed,
|
||||
candidates,
|
||||
suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null },
|
||||
existing: { assetId: null },
|
||||
warnings: hit ? [] : ["本地与 TMDB 都没有找到可信候选"],
|
||||
existing: { assetId: fl.meta?.asset?.assetId ?? null },
|
||||
warnings: hit || sev || small ? [] : ["本地与 TMDB 都没有找到可信候选"],
|
||||
size: fl.size,
|
||||
skipped: small ? "below_min_size" : null,
|
||||
records: /Oppenheimer/.test(fl.name)
|
||||
? { count: 1, lastRecordId: "rec-old-1", lastStatus: "failed" as const }
|
||||
: { count: 0, lastRecordId: null, lastStatus: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
return { items: out, truncated: false };
|
||||
return { items: out, truncated: false, excludedBySize };
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
@@ -700,40 +743,114 @@ function doMove(body: {
|
||||
}
|
||||
|
||||
function organize(body: {
|
||||
items: { root: string; path: string; mediaEntityId: string; season?: number; episodes?: number[] }[];
|
||||
items: {
|
||||
root: string;
|
||||
path: string;
|
||||
mediaEntityId?: string;
|
||||
external?: { externalId: string; kind: string };
|
||||
season?: number;
|
||||
episodes?: number[];
|
||||
}[];
|
||||
action: "copy" | "move";
|
||||
dryRun: boolean;
|
||||
onExisting?: "skip" | "conflict" | "replace_with_verify";
|
||||
naming?: "template" | "keep";
|
||||
minSizeBytes?: number;
|
||||
}): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } {
|
||||
const byEntity = new Map<string, typeof body.items>();
|
||||
for (const it of body.items) byEntity.set(it.mediaEntityId, [...(byEntity.get(it.mediaEntityId) ?? []), it]);
|
||||
for (const it of body.items) {
|
||||
const id = it.mediaEntityId ?? (it.external ? "e-sev" : "?");
|
||||
byEntity.set(id, [...(byEntity.get(id) ?? []), it]);
|
||||
}
|
||||
const groups: OrganizeGroup[] = [];
|
||||
const onExisting = body.onExisting ?? "conflict";
|
||||
for (const [id, items] of byEntity) {
|
||||
const k = KNOWN.find((x) => x.id === id);
|
||||
const k =
|
||||
KNOWN.find((x) => x.id === id) ??
|
||||
(id === "e-sev" ? { title: "人生切割术", year: 2022, kind: "series" } : undefined);
|
||||
const tv = k?.kind === "series";
|
||||
const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies";
|
||||
const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`;
|
||||
const files = items.map((it) => {
|
||||
let conflict = false;
|
||||
const files = items.flatMap((it) => {
|
||||
const ext = it.path.split(".").pop();
|
||||
const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : "";
|
||||
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
|
||||
const name = tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
|
||||
const orig = it.path.split("/").pop() ?? "";
|
||||
const name = body.naming === "keep" ? orig : tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
|
||||
const dst = tv
|
||||
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
|
||||
: `${libRoot}/${dirName}/${name}`;
|
||||
const r = state.roots.find((x) => x.id === it.root);
|
||||
const { node } = find(it.root, it.path);
|
||||
return { source: `${r?.hostPath}/${it.path}`, target: dst, kind: "main", size: node.size };
|
||||
const { node, parent } = find(it.root, it.path);
|
||||
const exists = id === "e-dune";
|
||||
if (exists && onExisting === "conflict") conflict = true;
|
||||
const main = {
|
||||
source: `${r?.hostPath}/${it.path}`,
|
||||
target: dst,
|
||||
kind: "main",
|
||||
size: node.size,
|
||||
action: exists
|
||||
? onExisting === "skip"
|
||||
? "skip"
|
||||
: onExisting === "replace_with_verify"
|
||||
? "replace"
|
||||
: "hold"
|
||||
: "transfer",
|
||||
existing: exists
|
||||
? onExisting === "skip"
|
||||
? "skip"
|
||||
: onExisting === "replace_with_verify"
|
||||
? "replace"
|
||||
: "conflict"
|
||||
: null,
|
||||
skipReason: exists && onExisting === "skip" ? "target_exists" : null,
|
||||
} as OrganizeGroup["files"][number];
|
||||
const stem = orig.replace(/\.[^.]+$/, "");
|
||||
const dirPath = it.path.split("/").slice(0, -1).join("/");
|
||||
const subs = (parent?.children ?? [])
|
||||
.filter((c) => c !== node && !c.dir && c.name.startsWith(stem.slice(0, 16)) && /\.(srt|ass)$/.test(c.name))
|
||||
.map((c) => ({
|
||||
source: `${r?.hostPath}/${join(dirPath, c.name)}`,
|
||||
target: dst.replace(/\.[^.]+$/, `.zh.${c.name.split(".").pop()}`),
|
||||
kind: "subtitle",
|
||||
size: c.size,
|
||||
action: "transfer" as const,
|
||||
existing: null,
|
||||
skipReason: null,
|
||||
}));
|
||||
return [main, ...subs];
|
||||
});
|
||||
const segs = tv
|
||||
? [
|
||||
{ text: dirName, role: "root" as const, bound: id === "e-tlou" },
|
||||
{ text: `Season ${String(items[0]?.season ?? 1).padStart(2, "0")}`, role: "season" as const, bound: false },
|
||||
]
|
||||
: [{ text: dirName, role: "root" as const, bound: id === "e-dune" }];
|
||||
groups.push({
|
||||
mediaEntityId: id,
|
||||
contentUnitId: null,
|
||||
planId: `oplan-${id}`,
|
||||
planId: conflict ? null : `oplan-${id}`,
|
||||
files,
|
||||
excluded: [],
|
||||
truncated: false,
|
||||
recordId: body.dryRun ? null : `rec-${++seq}`,
|
||||
taskId: body.dryRun ? null : `task-${seq}`,
|
||||
recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
|
||||
taskId: body.dryRun || conflict ? null : `task-${seq}`,
|
||||
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
||||
error: null,
|
||||
error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按“交给待处理”整组不执行" } : null,
|
||||
action: body.action,
|
||||
entity: items.some((x) => x.external)
|
||||
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
|
||||
: null,
|
||||
onExisting,
|
||||
minSizeBytes: body.minSizeBytes,
|
||||
naming: {
|
||||
mode: body.naming ?? "template",
|
||||
templateSource: body.naming === "keep" ? "override" : "global",
|
||||
templateVersion: 2,
|
||||
},
|
||||
targetSegments: segs,
|
||||
refreshTaskId: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
@@ -743,6 +860,29 @@ function organize(body: {
|
||||
};
|
||||
}
|
||||
|
||||
function tree(params: Record<string, unknown>) {
|
||||
const root = String(params.root ?? "");
|
||||
const path = String(params.path ?? "");
|
||||
const { node } = find(root, path);
|
||||
const dirs = lazyChildren(node)
|
||||
.filter((c) => c.dir && !c.name.startsWith("."))
|
||||
.map((c) => ({
|
||||
name: c.name,
|
||||
path: join(path, c.name),
|
||||
hasChildren: !c.lazyCount && (c.children ?? []).some((x) => x.dir),
|
||||
}));
|
||||
const e = toEntry(node, path);
|
||||
return {
|
||||
root,
|
||||
path,
|
||||
name: node.name || rootOf(root).name,
|
||||
parent: path ? path.split("/").slice(0, -1).join("/") : null,
|
||||
dirs,
|
||||
truncated: false,
|
||||
summary: e.dirSummary ?? { videoCount: 0, registeredCount: 0, partial: false },
|
||||
};
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 与后端同路径同形状的内存实现 */
|
||||
@@ -880,7 +1020,73 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
}
|
||||
case "/files/identify":
|
||||
await delay(500);
|
||||
return identify(b.items);
|
||||
return identify(b.items, b.minSizeBytes ?? 0);
|
||||
case "/files/tree":
|
||||
return tree(p);
|
||||
case "/files/suggest-name": {
|
||||
const { node } = find(b.root, b.path);
|
||||
const parsed = parseName(node.name);
|
||||
const hit = node.meta?.asset ?? KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
||||
if (!hit)
|
||||
return {
|
||||
name: null,
|
||||
relativePath: null,
|
||||
reason: "identity_uncertain",
|
||||
mediaEntityId: null,
|
||||
templateSource: null,
|
||||
segments: [],
|
||||
};
|
||||
const title = "title" in hit ? hit.title : "";
|
||||
const year = hit.year;
|
||||
const ext = node.dir ? "" : `.${node.name.split(".").pop()}`;
|
||||
const se =
|
||||
parsed.season != null
|
||||
? ` - S${String(parsed.season).padStart(2, "0")}E${String(parsed.episodes[0] ?? 1).padStart(2, "0")}`
|
||||
: "";
|
||||
const name = node.dir ? `${title} (${year})` : `${title} (${year})${se}${ext}`;
|
||||
return {
|
||||
name,
|
||||
relativePath: `${title} (${year})/${name}`,
|
||||
reason: null,
|
||||
mediaEntityId: "mediaEntityId" in hit ? hit.mediaEntityId : hit.id,
|
||||
templateSource: "global",
|
||||
segments: node.dir
|
||||
? [{ text: name, role: "root" }]
|
||||
: [
|
||||
{ text: `${title} (${year})`, role: "root" },
|
||||
{ text: name, role: "file" },
|
||||
],
|
||||
};
|
||||
}
|
||||
case "/files/records":
|
||||
return {
|
||||
items: String(p.ids ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((id) => mockRecord(id)),
|
||||
planRecords: {},
|
||||
};
|
||||
case "/files/record-sources":
|
||||
return {
|
||||
recordId: p.recordId,
|
||||
mediaEntityId: "e-opp",
|
||||
action: "copy",
|
||||
status: "failed",
|
||||
items: [{ root: "dl:qb-main:0", path: "Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv" }],
|
||||
unresolved: [],
|
||||
};
|
||||
case "/files/scrape-batch": {
|
||||
const ids: string[] = b.mediaEntityIds ?? [];
|
||||
return {
|
||||
submitted: ids.map((id) => ({
|
||||
mediaEntityId: id,
|
||||
title: KNOWN.find((k) => k.id === id)?.title ?? id,
|
||||
taskId: `run-${++seq}`,
|
||||
lockedFields: ["title"],
|
||||
})),
|
||||
skipped: [],
|
||||
};
|
||||
}
|
||||
case "/files/organize":
|
||||
await delay(400);
|
||||
return organize(b);
|
||||
@@ -911,6 +1117,32 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function mockRecord(id: string) {
|
||||
const t = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
taskId: null,
|
||||
downloadTaskId: null,
|
||||
operationIds: [],
|
||||
media: null,
|
||||
trigger: "manual" as const,
|
||||
origin: "manual" as const,
|
||||
actor: { type: "admin" as const, id: "w31-admin" },
|
||||
action: "copy" as const,
|
||||
status: "succeeded" as const,
|
||||
source: { view: "local" as const, path: null },
|
||||
target: { view: "library" as const, path: null },
|
||||
reason: null,
|
||||
counts: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
|
||||
files: null,
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
startedAt: t,
|
||||
finishedAt: t,
|
||||
changeSeq: "1",
|
||||
};
|
||||
}
|
||||
|
||||
export async function mockPreview(root: string, path: string): Promise<string> {
|
||||
await delay(100);
|
||||
if (path.endsWith(".nfo"))
|
||||
|
||||
Reference in new issue
Block a user