import { join } from "node:path"; import type { FileRootId, FilesRecordSourcesResult, FilesTreeQuery, FilesTreeResult, OrganizationStatus, } from "@umt/contracts"; import { sql } from "@umt/db"; import { isRemotePath, vlstat as lstat, vreaddirents as readdirents } from "../../storage/vfs.ts"; import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts"; import { kindOfName } from "./list.ts"; // G8 目录摘要(树折叠 / 上一级):只列子目录(是否还有下级)+ 本目录递归视频数与已登记数; // G5 整理记录 → 源 FileRef(整理记录"重新整理"按登记目录 hostPath 换算)。 const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`; const hiddenName = (n: string) => n.startsWith(".") || isTempName(n); export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise { const r = await resolveIn(d, q.root, q.path); if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); const remote = isRemotePath(r.abs); const names = await readdirents(r.abs); const subdirs = names .filter((n) => n.isDirectory() && !hiddenName(n.name)) .map((n) => n.name) .sort((a, b) => a.localeCompare(b)); const max = d.options.listMaxEntries; const truncated = subdirs.length > max; const dirs = await Promise.all( subdirs.slice(0, max).map(async (name) => { const path = r.rel ? `${r.rel}/${name}` : name; // Alist:不逐个子目录再列一遍(避免 N 次网盘请求),按「可能有下级」展示,展开时再列 if (remote) return { name, path, hasChildren: true }; const kids = await readdirents(join(r.abs, name)).catch(() => []); return { name, path, hasChildren: kids.some((k) => k.isDirectory() && !hiddenName(k.name)) }; }), ); // 递归数视频(上限 dirSummaryMax 项,同列表目录摘要口径) let seen = 0; let videoCount = 0; let partial = false; // Alist:只数当前目录这一层(不递归打网盘接口),结果标为不完整 if (remote) { videoCount = names.filter((n) => n.isFile() && kindOfName(n.name) === "video").length; partial = names.some((n) => n.isDirectory()); } const stack = remote ? [] : [r.abs]; while (stack.length > 0 && !partial) { const dir = stack.pop()!; for (const n of await readdirents(dir).catch(() => [])) { if (++seen > d.options.dirSummaryMax) { partial = true; break; } if (n.name.startsWith(".")) continue; if (n.isDirectory()) stack.push(join(dir, n.name)); else if (n.isFile() && kindOfName(n.name) === "video") videoCount++; } } let registeredCount = 0; if (r.def.libraryRootId) { const row = await sql<{ n: number }>` select count(*)::int as n from asset_location where library_root_id = ${r.def.libraryRootId} and status = 'available' and (${r.rel} = '' or relative_path like ${likePrefix(r.rel)})`.execute(d.ctx.db); registeredCount = row.rows[0]?.n ?? 0; } const slash = r.rel.lastIndexOf("/"); return { root: q.root, path: r.rel, name: r.rel ? r.rel.slice(slash + 1) : r.def.name, parent: r.rel === "" ? null : slash < 0 ? "" : r.rel.slice(0, slash), dirs, truncated, summary: { videoCount, registeredCount, partial }, }; } export async function recordSources(d: FmDeps, recordId: string): Promise { const db = d.ctx.db; const rec = await db .selectFrom("organization_record") .select([ "id", "media_entity_id", "action", "status", "source_path", "source_path_view", "target_path", "target_library_root_id", "plan", ]) .where("id", "=", recordId) .executeTakeFirst(); if (!rec) throw new FilesError("not_found", "整理记录不存在"); const mains = await db .selectFrom("organization_file_result") .select(["source_path", "target_path", "target_library_root_id"]) .where("organization_record_id", "=", rec.id) .where("role", "=", "main") .orderBy("ordinal") .limit(500) .execute(); const defs = (await loadRootDefs(db)).sort((a, b) => b.hostPath.length - a.hostPath.length); const toRef = (abs: string) => { const def = defs.find((x) => within(abs, x.hostPath)); return def ? { root: def.id as FileRootId, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1) } : null; }; // Successful moves have removed their inputs: reorganize the existing library files. const moved = rec.action === "move" && rec.status === "succeeded"; const targetOf = (rootId: string | null, path: string | null) => { const root = defs.find((d) => d.libraryRootId === rootId); return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null; }; let sources = moved ? mains.map((m) => targetOf(m.target_library_root_id, m.target_path)).filter((p): p is string => !!p) : mains.map((m) => m.source_path); const dir = moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path; if ( dir && sources.length > 1 && sources.every((p) => within(p, dir)) && !defs.some((x) => x.hostPath === dir.replace(/\/+$/, "")) ) sources = [dir]; if (sources.length === 0 && dir) sources = [dir]; const items: FilesRecordSourcesResult["items"] = []; const unresolved: string[] = []; for (const abs of [...new Set(sources)]) { const ref = moved || rec.source_path_view === "local" ? toRef(abs) : null; const exists = ref ? await lstat(abs).then( () => true, () => false, ) : false; if (ref && exists) items.push(ref); else unresolved.push(abs); } return { recordId: rec.id, mediaEntityId: rec.media_entity_id, action: rec.action, status: rec.status as OrganizationStatus, items, unresolved, ...(rec.plan && typeof rec.plan === "object" && "sourceConfigId" in rec.plan && typeof rec.plan.sourceConfigId === "string" ? { sourceConfigId: rec.plan.sourceConfigId } : {}), }; }