fix(organize): 跨卷 Move 删源前全量校验、计划期 sha256 缓存、补全元数据带超时

- 跨卷移动删源前一律全量 sha256(不再抽样);
- 计划期同尺寸目标的全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存,向导预览 / 复核 / 执行不重复算;
- metadata.resolve_organize 传 3 分钟超时的 signal;
- 季号推断已由解析器统一推定(seasonInferred),补回归用例。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:37:28 +08:00
1 parent 4b49be7228
commit d129a96110
5 files changed
+130 -11

No files matched your search

@@ -771,14 +771,8 @@ export async function transferFile(
},
};
}
const r = await px.crossDeviceMove(
src,
dst,
{
hash: d.ports.options.verifyHash,
},
call,
);
// 跨卷移动删源前一律全量 sha256 校验(抽样校验不够当删源依据;只影响跨卷 Move,复制 / 同卷仍按 verifyHash)
const r = await px.crossDeviceMove(src, dst, { hash: "sha256" }, call);
const failedStep = r.steps.find((st) => st.status === "failed");
return {
status: r.status === "completed" ? "succeeded" : r.status,
@@ -383,7 +383,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
actorId: req.actorId ?? "system:organize",
taskId: req.taskId ?? `organize:${dt?.id ?? rel.id}`,
scopeIds: [],
signal: new AbortController().signal,
// 带超时:上游(模型 / 网页搜索)挂住时不能一直占着 file 道
signal: AbortSignal.timeout(RESOLVE_ORGANIZE_TIMEOUT_MS),
source: "task",
},
)) as { mediaEntityId?: string | null; mode?: "tmdb" | "local" | null };
@@ -855,9 +856,12 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const e: ExistingTarget = { path: f.dst, size: st.facts.size };
if (st.facts.size === f.size) {
// 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full)
e.sha256 = (await sha256Full(probe)).digest;
e.sha256 = await cachedSha256(probe, st.facts);
const src = files.find((x) => x.path === f.src);
if (src) src.sha256 = (await sha256Full(src.path)).digest;
if (src) {
const ss = await statSafe(src.path);
src.sha256 = ss.kind === "ok" ? await cachedSha256(src.path, ss.facts) : (await sha256Full(src.path)).digest;
}
}
existing.push(e);
}
@@ -1236,6 +1240,32 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi
});
}
/**
* 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:向导预览 / 复核 / 执行各算一遍同一对大文件时只算一次。
* 文件被改动(大小 / 修改时间 / inode 变)就重算;只留最近的 SHA_CACHE_MAX 条。
*/
const SHA_CACHE_MAX = 2000;
const shaCache = new Map<string, string>();
export async function cachedSha256(
path: string,
facts: { size: number; mtimeMs: number; ino?: number | bigint | null },
): Promise<string> {
const key = `${path}\u0000${facts.size}\u0000${facts.mtimeMs}\u0000${facts.ino ?? ""}`;
const hit = shaCache.get(key);
if (hit) {
shaCache.delete(key);
shaCache.set(key, hit);
return hit;
}
const digest = (await sha256Full(path)).digest;
shaCache.set(key, digest);
if (shaCache.size > SHA_CACHE_MAX) shaCache.delete(shaCache.keys().next().value!);
return digest;
}
/** 整理时补全元数据(metadata.resolve_organize:模型 + 网页搜索)的超时 */
export const RESOLVE_ORGANIZE_TIMEOUT_MS = 3 * 60_000;
/** 作品标题 / 原名 / 别名 → 这批视频里标题明显不是本作品的(路径 → 文件标题) */
async function fileTitleMismatches(
d: AoDeps,