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( // 跨卷移动删源前一律全量 sha256 校验(抽样校验不够当删源依据;只影响跨卷 Move,复制 / 同卷仍按 verifyHash)
src, const r = await px.crossDeviceMove(src, dst, { hash: "sha256" }, call);
dst,
{
hash: d.ports.options.verifyHash,
},
call,
);
const failedStep = r.steps.find((st) => st.status === "failed"); const failedStep = r.steps.find((st) => st.status === "failed");
return { return {
status: r.status === "completed" ? "succeeded" : r.status, 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", actorId: req.actorId ?? "system:organize",
taskId: req.taskId ?? `organize:${dt?.id ?? rel.id}`, taskId: req.taskId ?? `organize:${dt?.id ?? rel.id}`,
scopeIds: [], scopeIds: [],
signal: new AbortController().signal, // 带超时:上游(模型 / 网页搜索)挂住时不能一直占着 file 道
signal: AbortSignal.timeout(RESOLVE_ORGANIZE_TIMEOUT_MS),
source: "task", source: "task",
}, },
)) as { mediaEntityId?: string | null; mode?: "tmdb" | "local" | null }; )) 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 }; const e: ExistingTarget = { path: f.dst, size: st.facts.size };
if (st.facts.size === f.size) { if (st.facts.size === f.size) {
// 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full) // 同尺寸已存在的目标:全量 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); 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); 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( async function fileTitleMismatches(
d: AoDeps, d: AoDeps,
@@ -0,0 +1,51 @@
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 季号推断(审视 ④ 风险 9):解析器对有集号没季号的文件已推定第 1 季(seasonInferred),服务端与域层用同一个值;
// 带季号与不带季号的文件混在一起时,不带季号的不会因为「季目录不唯一」被静默排除(回归用例)。
const hasPg = await pgAvailable();
const SIZE = 200_000;
describe.skipIf(!hasPg)("整理:季号推断收敛到一处", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-silib-" });
src = await createTempTree(
{
"Pack/Show.S02E01.1080p.WEB-DL.mkv": SIZE,
"Pack/Show.E05.1080p.WEB-DL.mkv": SIZE,
},
{ prefix: "umt-sisrc-" },
);
h = await createHarness("season_inference");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
it("没季号的集按第 1 季入库,不以 no_target_dir 排除", async () => {
const entity = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 });
const plan = await planImport(h.d, {
sourcePaths: [src.path("Pack")],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
dryRun: true,
});
const o = plan.plan!.organize;
expect(o.excluded.filter((x) => x.reason === "no_target_dir")).toEqual([]);
const e05 = o.files.find((f) => f.src.endsWith("Show.E05.1080p.WEB-DL.mkv"))!;
expect(e05).toMatchObject({ season: 1, episodes: { start: 5, end: 5 } });
expect(e05.dst).toContain("Season 1");
expect(o.files.find((f) => f.src.endsWith("S02E01.1080p.WEB-DL.mkv"))!.dst).toContain("Season 2");
});
});
@@ -0,0 +1,38 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { cachedSha256 } from "../../src/modules/acquisition-organize/services/organize-plan.ts";
// 计划期全量 sha256 按 (路径, 大小, 修改时间, inode) 缓存:同一文件没变时不重算,变了就重算。
const sha = (s: string) => createHash("sha256").update(s).digest("hex");
describe("计划期 sha256 缓存", () => {
let dir = "";
afterAll(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
});
it("事实不变命中缓存;修改时间变了重算", async () => {
dir = await mkdtemp(join(tmpdir(), "umt-shacache-"));
const p = join(dir, "a.mkv");
await writeFile(p, "AAAA");
const t = new Date("2024-01-01T00:00:00Z");
await utimes(p, t, t);
const facts = async () => {
const s = await stat(p);
return { size: s.size, mtimeMs: s.mtimeMs, ino: s.ino };
};
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
// 同大小、同修改时间、同 inode 的改写:按缓存(这正是缓存键的语义)
await writeFile(p, "BBBB");
await utimes(p, t, t);
expect(await cachedSha256(p, await facts())).toBe(sha("AAAA"));
// 修改时间变了:重算
const t2 = new Date("2024-01-02T00:00:00Z");
await utimes(p, t2, t2);
expect(await cachedSha256(p, await facts())).toBe(sha("BBBB"));
});
});
+6
View File
@@ -59,6 +59,12 @@
用户在向导里逐文件指定过的不核。 用户在向导里逐文件指定过的不核。
- ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。 - ffprobe 判定「不是视频」(没有视频流,伪装或损坏)的文件:核验失败,已拷 / 搬进库的新文件和它的字幕等附属文件移入回收站(Move 时源已删,回收站里是唯一一份),附属文件结果记跳过 `main_failed`,开待处理「N 个文件不是视频,没有入库」。原地入库不动原文件;洗版替换照旧回滚旧文件。
- 跨卷 Move(复制 → 校验 → 删源)删源前一律全量 sha256 校验,不用抽样;复制与同卷移动仍按 `verifyHash`。
- 计划期判断「目标已存在且同尺寸是不是同一文件」的全量 sha256 按(路径、大小、修改时间、inode)缓存,向导预览 / 复核 / 执行不再各算一遍。
- 整理时补全元数据(`metadata.resolve_organize`,模型 + 网页搜索)带 3 分钟超时,上游挂住不会一直占着文件道。
- 季号:解析器对有集号没季号的文件推定第 1 季,服务端与域层用同一个值(`organize-season-inference.test.ts`)。
- 回收站清除先删文件再删行;清过期项时顺带删各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的目录。
关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。 关联迁移 `0060_manual_acquisition`,已于 2026-10-01 随 v0.3.10 发布执行。业务依据与验收见 [MP 对照 §5.6](../porting/moviepilot-v3-comparison.md#56-本轮实现与验收已部署-v0310)。
## 与 MP 的对照 ## 与 MP 的对照