diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts index 15f385b3..044e5db6 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -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, diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index 3b30e6e0..4df36557 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -383,7 +383,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise 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(); +export async function cachedSha256( + path: string, + facts: { size: number; mtimeMs: number; ino?: number | bigint | null }, +): Promise { + 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, diff --git a/apps/server/test/acquisition-organize/organize-season-inference.test.ts b/apps/server/test/acquisition-organize/organize-season-inference.test.ts new file mode 100644 index 00000000..44085d21 --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-season-inference.test.ts @@ -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"); + }); +}); diff --git a/apps/server/test/acquisition-organize/organize-sha-cache.test.ts b/apps/server/test/acquisition-organize/organize-sha-cache.test.ts new file mode 100644 index 00000000..b5f0ec3d --- /dev/null +++ b/apps/server/test/acquisition-organize/organize-sha-cache.test.ts @@ -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")); + }); +}); diff --git a/docs/runbook/organize.md b/docs/runbook/organize.md index cc94127f..cb462d4a 100644 --- a/docs/runbook/organize.md +++ b/docs/runbook/organize.md @@ -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 的对照