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:
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,
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,12 @@
|
||||
用户在向导里逐文件指定过的不核。
|
||||
- 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)。
|
||||
|
||||
## 与 MP 的对照
|
||||
|
||||
Reference in new issue
Block a user