fix(organize): recover legacy requests without dropping one-off intent
This commit is contained in:
1 parent
ee0d62ae6f
commit
407cb7eb36
6 files changed
+616
-32
No files matched your search
@@ -5,6 +5,7 @@ import { type AoDeps, type Exec, enqueueSweep, timeSlot, upsertDecision } from "
|
||||
import { SWEEP_SLOT_MS } from "./downloads.ts";
|
||||
import { isHoldReason, ROOT_OFFLINE } from "./organize-exec.ts";
|
||||
import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
|
||||
import { resolveOrganizeRecovery } from "./organize-retry.ts";
|
||||
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
@@ -203,7 +204,10 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
||||
message: (err as { message?: string }).message ?? JSON.stringify(err).slice(0, 300),
|
||||
}
|
||||
: null;
|
||||
return rows.map((r) => {
|
||||
const recoveries = includeFiles
|
||||
? await Promise.all(rows.map(async (r) => (await resolveOrganizeRecovery(exec, r)).recovery))
|
||||
: [];
|
||||
return rows.map((r, index) => {
|
||||
const e = entities.find((x) => x.id === r.media_entity_id);
|
||||
const u = units.find((x) => x.id === r.content_unit_id);
|
||||
const targetRef = {
|
||||
@@ -213,6 +217,7 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
||||
libraryRootId: r.target_library_root_id,
|
||||
};
|
||||
return {
|
||||
...(includeFiles ? { recovery: recoveries[index] } : {}),
|
||||
id: r.id,
|
||||
taskId: r.task_id,
|
||||
downloadTaskId: r.download_task_id,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { dirname } from "node:path";
|
||||
import { OrganizationRecovery, type OrganizationRecovery as Recovery } from "@umt/contracts";
|
||||
import { type OrganizationRecordRow, sql } from "@umt/db";
|
||||
import type { DecisionHookActor } from "../../decision-hooks.ts";
|
||||
import { type AoDeps, ensureTaskRow, markTask } from "./common.ts";
|
||||
import { type AoDeps, type Exec, ensureTaskRow, hostPathOf, loadInstances, markTask } from "./common.ts";
|
||||
import { sourceGuard } from "./organize-guard.ts";
|
||||
import { planImport } from "./organize-plan.ts";
|
||||
import type { ImportRequest, StoredImportRequest } from "./organize-types.ts";
|
||||
import type { FrozenPlan, ImportRequest, StoredImportRequest } from "./organize-types.ts";
|
||||
|
||||
/** 只保留业务参数;路径守卫与任务身份在恢复时重新装配。 */
|
||||
export function storedImportRequest(req: ImportRequest): StoredImportRequest {
|
||||
@@ -30,6 +32,137 @@ export function organizeRequestFromRecord(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 先找完整请求;冻结结果只提供已知事实,未知的一次性设置由用户确认。 */
|
||||
export async function resolveOrganizeRecovery(db: Exec, record: OrganizationRecordRow) {
|
||||
const snapshot = organizeRequestFromRecord(record);
|
||||
if (snapshot) return withDownloadSource(db, recoveryResult(snapshot, "request", []));
|
||||
const task = record.task_id
|
||||
? await db.selectFrom("task").select(["kind", "input"]).where("id", "=", record.task_id).executeTakeFirst()
|
||||
: undefined;
|
||||
const input = task?.input as (Partial<ImportRequest> & { planId?: string; recordId?: string }) | undefined;
|
||||
// 文件向导的老 task.input 只有分组基本参数;planId 表示缺少完整一次性设置,不能当完整请求。
|
||||
if (
|
||||
task?.kind === "verifyAndImport" &&
|
||||
input &&
|
||||
!input.planId &&
|
||||
!input.recordId &&
|
||||
(input.downloadTaskId || input.sourcePaths?.length)
|
||||
) {
|
||||
const request = storedImportRequest({ ...input, trigger: "manual", actorType: "system" });
|
||||
return withDownloadSource(db, recoveryResult(request, "task", []));
|
||||
}
|
||||
const plan = record.plan as unknown as Partial<FrozenPlan>;
|
||||
const request: StoredImportRequest = {};
|
||||
const missing: Recovery["missing"] = ["options"];
|
||||
if (record.download_task_id) request.downloadTaskId = record.download_task_id;
|
||||
if (record.media_entity_id ?? plan.entityId) request.mediaEntityId = record.media_entity_id ?? plan.entityId;
|
||||
if (record.content_unit_id) request.contentUnitId = record.content_unit_id;
|
||||
if (["copy", "move", "in_place"].includes(record.action)) request.action = record.action as ImportRequest["action"];
|
||||
if (record.target_library_root_id ?? plan.libraryRootId)
|
||||
request.targetLibraryRootId = record.target_library_root_id ?? plan.libraryRootId;
|
||||
if (plan.sourceConfigId) request.sourceConfigId = plan.sourceConfigId;
|
||||
if (plan.slotId) request.slotId = plan.slotId;
|
||||
if (plan.naming?.mode) request.namingMode = plan.naming.mode;
|
||||
if (plan.scrapeAfterImport !== undefined) request.scrapeAfterImport = plan.scrapeAfterImport;
|
||||
if (plan.replacement) {
|
||||
request.importMode = "replace";
|
||||
request.replacementAssetIds = plan.replacement.assets.map((a) => a.assetId);
|
||||
}
|
||||
const paths = [
|
||||
...(plan.organize?.files.filter((f) => f.role === "main").map((f) => f.src) ?? []),
|
||||
...(plan.organize?.excluded.filter((f) => f.kind === "video").map((f) => f.src) ?? []),
|
||||
];
|
||||
if (!paths.length) {
|
||||
const files = await db
|
||||
.selectFrom("organization_file_result")
|
||||
.select("source_path")
|
||||
.where("organization_record_id", "=", record.id)
|
||||
.where("role", "=", "main")
|
||||
.execute();
|
||||
paths.push(...files.map((f) => f.source_path));
|
||||
}
|
||||
if (!paths.length && input?.sourcePaths?.length) paths.push(...input.sourcePaths);
|
||||
if (!paths.length && record.source_path && record.source_path_view === "local") paths.push(record.source_path);
|
||||
if (!paths.length && record.download_task_id) {
|
||||
const download = await db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", record.download_task_id)
|
||||
.executeTakeFirst();
|
||||
if (download?.content_path) {
|
||||
const [topology] = await loadInstances(db, { enabledOnly: false, ids: [download.instance_id] });
|
||||
const path = topology ? hostPathOf(topology, download.content_path) : null;
|
||||
if (path) paths.push(path);
|
||||
}
|
||||
}
|
||||
if (!paths.length) {
|
||||
const decisions = await db
|
||||
.selectFrom("decision")
|
||||
.select("evidence")
|
||||
.where("subject_type", "=", "organization_record")
|
||||
.where("subject_id", "=", record.id)
|
||||
.execute();
|
||||
for (const decision of decisions) {
|
||||
const evidence = decision.evidence as { sourcePaths?: string[]; mainFile?: string } | null;
|
||||
if (evidence?.sourcePaths?.length) paths.push(...evidence.sourcePaths);
|
||||
else if (evidence?.mainFile?.startsWith("/")) paths.push(evidence.mainFile);
|
||||
}
|
||||
}
|
||||
if (paths.length) request.sourcePaths = [...new Set(paths)];
|
||||
if (!request.downloadTaskId && !request.sourcePaths?.length) missing.push("sourcePaths");
|
||||
// 实际目标目录可以预填,但不把它冒充原来的 targetDir 选项;确认后才按这次目录执行。
|
||||
const dirs = new Set(plan.organize?.files.filter((f) => f.role === "main").map((f) => dirname(f.dst)) ?? []);
|
||||
if (dirs.size === 1) request.targetDir = [...dirs][0];
|
||||
if (plan.episodeStructure) {
|
||||
request.episodeStructure = {
|
||||
groupId: plan.episodeStructure.groupId,
|
||||
layout: null,
|
||||
appliedToEntity: plan.episodeStructure.appliedToEntity,
|
||||
};
|
||||
if (plan.episodeStructure.temporary) missing.push("episodeStructure");
|
||||
}
|
||||
for (const [path, facts] of Object.entries(plan.facts ?? {})) {
|
||||
if (!facts.episodeResolution && (facts.season !== undefined || facts.episodes || facts.unitIds?.length)) {
|
||||
request.fileOverrides ??= {};
|
||||
request.fileOverrides[path] = { season: facts.season, episodes: facts.episodes, contentUnitIds: facts.unitIds };
|
||||
}
|
||||
}
|
||||
return recoveryResult(request, plan.organize ? "plan" : "record", missing);
|
||||
}
|
||||
|
||||
/** 下载任务原请求不增加 sourcePaths,避免改变下载器的选中文件语义;向导只预填当前已知宿主来源。 */
|
||||
async function withDownloadSource(db: Exec, resolved: ReturnType<typeof recoveryResult>) {
|
||||
const request = resolved.recovery.request;
|
||||
if (!request.sourcePaths?.length && request.downloadTaskId) {
|
||||
const download = await db
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", request.downloadTaskId)
|
||||
.executeTakeFirst();
|
||||
if (download?.content_path) {
|
||||
const [topology] = await loadInstances(db, { enabledOnly: false, ids: [download.instance_id] });
|
||||
const path = topology ? hostPathOf(topology, download.content_path) : null;
|
||||
if (path) request.sourcePaths = [path];
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function recoveryResult(request: StoredImportRequest, source: Recovery["source"], missing: Recovery["missing"]) {
|
||||
const recovery = OrganizationRecovery.parse({ ready: !missing.length, source, missing, request });
|
||||
return { recovery, request };
|
||||
}
|
||||
|
||||
export class OrganizeRecoveryError extends Error {
|
||||
readonly code = "conflict";
|
||||
constructor(readonly recovery: Recovery) {
|
||||
const labels = { sourcePaths: "来源文件", options: "本次整理设置", episodeStructure: "季集结构" };
|
||||
super(
|
||||
`历史整理缺少${recovery.missing.map((key) => labels[key]).join("、")},已保留可用信息,请在整理向导补齐后继续`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface RetryOptions {
|
||||
mediaEntityId?: string;
|
||||
idempotencyKey?: string;
|
||||
@@ -80,8 +213,17 @@ async function retryLocked(
|
||||
if (existing?.task_id)
|
||||
return { recordId: existing.id, taskId: existing.task_id, status: existing.status, reason: existing.reason };
|
||||
}
|
||||
const request = organizeRequestFromRecord(record);
|
||||
if (!request) throw new Error("整理记录没有可恢复的来源,重新选择来源后整理");
|
||||
const resolved = await resolveOrganizeRecovery(d.ctx.db, record);
|
||||
if (!resolved.recovery.ready) throw new OrganizeRecoveryError(resolved.recovery);
|
||||
const request = resolved.request;
|
||||
if (overrides.mediaEntityId && overrides.mediaEntityId !== request.mediaEntityId) {
|
||||
delete request.contentUnitId;
|
||||
delete request.episodeStructure;
|
||||
delete request.slotId;
|
||||
delete request.fileOverrides;
|
||||
delete request.importMode;
|
||||
delete request.replacementAssetIds;
|
||||
}
|
||||
if (overrides.sourcePaths) {
|
||||
const files = await d.ctx.db
|
||||
.selectFrom("organization_file_result")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join, relative } from "node:path";
|
||||
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
|
||||
import { overrideFor } from "@umt/domain";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import { vlstat as stat } from "../../storage/vfs.ts";
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
requestKey,
|
||||
sourceGuard,
|
||||
} from "../acquisition-organize/services/organize-guard.ts";
|
||||
import { walk } from "../acquisition-organize/services/organize-plan.ts";
|
||||
import { resolveOrganizeRecovery } from "../acquisition-organize/services/organize-retry.ts";
|
||||
import { organizeStructure, setEpisodeGroup } from "../sources-identity/service/episode-groups.ts";
|
||||
import type { Layout } from "../sources-identity/service/episode-structure.ts";
|
||||
import { entityFromCandidate, externalKey } from "./candidate.ts";
|
||||
@@ -21,6 +24,7 @@ import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resol
|
||||
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
|
||||
import { isWritableTarget } from "./list.ts";
|
||||
import { entitiesUnder } from "./records.ts";
|
||||
import { recordSources } from "./tree.ts";
|
||||
|
||||
// 整理向导落地:按 (mediaEntityId, contentUnitId, 操作, 目标目录, Slot) 分组,每组走 planImport(与 POST /organization-records
|
||||
// 同一实现,同格式整理记录)。逐项季/集/单元覆写以 fileOverrides 传入 planImport(计划前后路径即反映覆写),
|
||||
@@ -193,6 +197,42 @@ export async function organizeFiles(
|
||||
const db = d.ctx.db;
|
||||
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
|
||||
const actor = actorOfP(principal);
|
||||
const recoveryRecord = body.recoveryRecordId
|
||||
? await db.selectFrom("organization_record").selectAll().where("id", "=", body.recoveryRecordId).executeTakeFirst()
|
||||
: undefined;
|
||||
if (body.recoveryRecordId && !recoveryRecord) throw new FilesError("not_found", "原整理记录不存在");
|
||||
const recovered = recoveryRecord ? await resolveOrganizeRecovery(db, recoveryRecord) : undefined;
|
||||
const recoveryPaths = new Set<string>();
|
||||
const recoveryDirectories: string[] = [];
|
||||
const movedOverrides: Record<string, FileOverrideInput> = {};
|
||||
const recoverySources = recoveryRecord ? await recordSources(d, recoveryRecord.id) : undefined;
|
||||
if (recoveryRecord) {
|
||||
const results = await db
|
||||
.selectFrom("organization_file_result")
|
||||
.selectAll()
|
||||
.where("organization_record_id", "=", recoveryRecord.id)
|
||||
.execute();
|
||||
const roots = await db.selectFrom("library_root").select(["id", "local_path"]).execute();
|
||||
for (const file of results) {
|
||||
recoveryPaths.add(file.source_path);
|
||||
const root = roots.find((r) => r.id === file.target_library_root_id);
|
||||
if (root && file.target_path && file.status === "succeeded") {
|
||||
const target = join(root.local_path, file.target_path);
|
||||
recoveryPaths.add(target);
|
||||
const override = overrideFor(recovered?.request.fileOverrides, file.source_path);
|
||||
if (override) movedOverrides[target] = override;
|
||||
}
|
||||
}
|
||||
const frozen = recoveryRecord.plan as unknown as Partial<FrozenPlan>;
|
||||
for (const file of frozen.organize?.files ?? []) recoveryPaths.add(file.src);
|
||||
for (const file of frozen.organize?.excluded ?? []) recoveryPaths.add(file.src);
|
||||
if (!recoveryPaths.size) {
|
||||
for (const path of recovered?.recovery.request.sourcePaths ?? []) {
|
||||
recoveryPaths.add(path);
|
||||
if ((await stat(path).catch(() => null))?.isDirectory()) recoveryDirectories.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 先核验整批人工集号,避免无效输入创建外部作品或冻结计划。数字范围由请求 schema 校验。
|
||||
const localIds = [...new Set(body.items.flatMap((it) => (it.mediaEntityId ? [it.mediaEntityId] : [])))];
|
||||
@@ -232,9 +272,9 @@ export async function organizeFiles(
|
||||
const entityOf = (it: (typeof body.items)[number]) =>
|
||||
it.mediaEntityId ?? created.get(externalKey(it.external!))!.entityId;
|
||||
const fromExternal = new Map([...created.values()].map((c) => [c.entityId, c]));
|
||||
const minSizeBytes = body.minSizeBytes ?? (await settingsMinSize(d));
|
||||
const onExisting = body.onExisting ?? "conflict";
|
||||
const namingMode = body.naming ?? "template";
|
||||
const minSizeBytes = body.minSizeBytes ?? recovered?.request.minSizeBytes ?? (await settingsMinSize(d));
|
||||
const onExisting = body.onExisting ?? recovered?.request.onExisting ?? "conflict";
|
||||
const namingMode = body.naming ?? recovered?.request.namingMode ?? "template";
|
||||
|
||||
const entityIds = [...new Set(body.items.map(entityOf))];
|
||||
const entities = new Map(
|
||||
@@ -287,6 +327,22 @@ export async function organizeFiles(
|
||||
const roots = new Set<string>();
|
||||
for (const it of body.items) {
|
||||
const r = await resolveIn(d, it.root, it.path);
|
||||
if (recovered && !recoverySources?.recovery?.missing.includes("sourcePaths")) {
|
||||
const selected: string[] = [];
|
||||
await walk(r.abs, selected, d.ao.ports.options.walkMaxFiles + 1);
|
||||
if (
|
||||
!selected.length ||
|
||||
selected.some(
|
||||
(path) =>
|
||||
!recoveryPaths.has(path) &&
|
||||
!recoveryDirectories.some((dir) => {
|
||||
const rel = relative(dir, path);
|
||||
return rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/");
|
||||
}),
|
||||
)
|
||||
)
|
||||
throw new FilesError("bad_request", "重新整理只能选择原记录中的文件;请单独整理其它来源");
|
||||
}
|
||||
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个登记目录");
|
||||
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
|
||||
const action = it.operation ?? body.action;
|
||||
@@ -420,6 +476,7 @@ export async function organizeFiles(
|
||||
const hasOverrides = Object.keys(g.overrides).length > 0;
|
||||
const st = structures.get(g.entity);
|
||||
const reqHash = hashJson({
|
||||
...(body.recoveryRecordId ? { recoveryRecordId: body.recoveryRecordId, recovered: recovered?.request } : {}),
|
||||
key: requestKey(req),
|
||||
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
|
||||
...(hasOverrides ? { overrides: g.overrides } : {}),
|
||||
@@ -447,10 +504,30 @@ export async function organizeFiles(
|
||||
onExisting,
|
||||
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
|
||||
};
|
||||
const inherited = { ...recovered?.request };
|
||||
if (recovered && g.entity !== recovered.request.mediaEntityId) {
|
||||
delete inherited.contentUnitId;
|
||||
delete inherited.episodeStructure;
|
||||
delete inherited.slotId;
|
||||
delete inherited.fileOverrides;
|
||||
delete inherited.importMode;
|
||||
delete inherited.replacementAssetIds;
|
||||
} else if (inherited.fileOverrides) {
|
||||
inherited.fileOverrides = { ...inherited.fileOverrides, ...movedOverrides };
|
||||
}
|
||||
// 恢复向导提交完整的目标/来源/季集选择;省略代表选择自动或跟随作品,不能沿用旧覆写。
|
||||
delete inherited.targetDir;
|
||||
delete inherited.targetLibraryRootId;
|
||||
delete inherited.sourceConfigId;
|
||||
delete inherited.episodeStructure;
|
||||
const importReq = (dryRun: boolean, taskId?: string) => ({
|
||||
...inherited,
|
||||
...req,
|
||||
...(body.recoveryRecordId ? { retryOfRecordId: body.recoveryRecordId } : {}),
|
||||
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
|
||||
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
|
||||
...(hasOverrides || inherited.fileOverrides
|
||||
? { fileOverrides: { ...inherited.fileOverrides, ...g.overrides } }
|
||||
: {}),
|
||||
...(g.targetDir ? { targetDir: g.targetDir } : {}),
|
||||
...(g.slotId ? { slotId: g.slotId } : {}),
|
||||
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
|
||||
@@ -458,8 +535,8 @@ export async function organizeFiles(
|
||||
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
|
||||
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
|
||||
namingMode,
|
||||
skipSuccessfulRecords: body.skipSuccessfulRecords,
|
||||
scrapeAfterImport: body.scrapeAfterImport,
|
||||
skipSuccessfulRecords: body.skipSuccessfulRecords ?? recovered?.request.skipSuccessfulRecords,
|
||||
scrapeAfterImport: body.scrapeAfterImport ?? recovered?.request.scrapeAfterImport,
|
||||
// 勾了「同时设为作品的季集结构」:执行前已切换作品,之后按作品(新结构)整理;预览按临时结构算,命名相同
|
||||
...(st
|
||||
? {
|
||||
|
||||
@@ -7,9 +7,10 @@ import type {
|
||||
OrganizationStatus,
|
||||
} from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { isRemotePath, vlstat as lstat, vreaddirents as readdirents } from "../../storage/vfs.ts";
|
||||
import { isRemotePath, vreaddirents as readdirents } from "../../storage/vfs.ts";
|
||||
import { resolveOrganizeRecovery } from "../acquisition-organize/services/organize-retry.ts";
|
||||
import { FilesError, type FmDeps, isTempName, loadRootDefs, resolveIn, within } from "./common.ts";
|
||||
import { kindOfName } from "./list.ts";
|
||||
import { isWritableTarget, kindOfName } from "./list.ts";
|
||||
|
||||
// G8 目录摘要(树折叠 / 上一级):只列子目录(是否还有下级)+ 本目录递归视频数与已登记数;
|
||||
// G5 整理记录 → 源 FileRef(整理记录"重新整理"按登记目录 hostPath 换算)。
|
||||
@@ -82,22 +83,9 @@ export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeRes
|
||||
|
||||
export async function recordSources(d: FmDeps, recordId: string): Promise<FilesRecordSourcesResult> {
|
||||
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();
|
||||
const rec = await db.selectFrom("organization_record").selectAll().where("id", "=", recordId).executeTakeFirst();
|
||||
if (!rec) throw new FilesError("not_found", "整理记录不存在");
|
||||
const { recovery } = await resolveOrganizeRecovery(db, rec);
|
||||
const mains = await db
|
||||
.selectFrom("organization_file_result")
|
||||
.select(["source_path", "target_path", "target_library_root_id", "status"])
|
||||
@@ -129,6 +117,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
||||
return target;
|
||||
})
|
||||
.filter((p): p is string => !!p);
|
||||
if (!mains.length && recovery.request.sourcePaths?.length) sources = recovery.request.sourcePaths;
|
||||
// 部分移动的文件分布在媒体库与下载目录,不能折叠成一个目录丢掉已移动文件。
|
||||
const dir = partialMove ? null : moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path;
|
||||
if (
|
||||
@@ -142,9 +131,9 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
||||
const items: FilesRecordSourcesResult["items"] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const abs of [...new Set(sources)]) {
|
||||
const ref = moved || targetSources.has(abs) || rec.source_path_view === "local" ? toRef(abs) : null;
|
||||
const ref = toRef(abs);
|
||||
const exists = ref
|
||||
? await lstat(abs).then(
|
||||
? await resolveIn(d, ref.root, ref.path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
@@ -152,7 +141,25 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
||||
if (ref && exists) items.push(ref);
|
||||
else unresolved.push(abs);
|
||||
}
|
||||
if (!items.length) {
|
||||
recovery.ready = false;
|
||||
if (!recovery.missing.includes("sourcePaths")) recovery.missing.push("sourcePaths");
|
||||
}
|
||||
let targetDir: FilesRecordSourcesResult["targetDir"];
|
||||
if (recovery.request.targetDir) {
|
||||
const ref = toRef(recovery.request.targetDir);
|
||||
if (ref?.root.startsWith("lib:")) {
|
||||
const target = await resolveIn(d, ref.root, ref.path).catch(() => null);
|
||||
if (target?.lst?.isDirectory() && (await isWritableTarget(target.def, target.rootReal))) targetDir = ref;
|
||||
}
|
||||
}
|
||||
if (recovery.request.targetDir && !targetDir) {
|
||||
recovery.ready = false;
|
||||
if (!recovery.missing.includes("options")) recovery.missing.push("options");
|
||||
}
|
||||
return {
|
||||
recovery,
|
||||
...(targetDir ? { targetDir } : {}),
|
||||
recordId: rec.id,
|
||||
mediaEntityId: rec.media_entity_id,
|
||||
action: rec.action,
|
||||
|
||||
Reference in new issue
Block a user