feat(scrape): 入库后自动刮削缺省开启,没排上时在整理记录上记「未刮削(原因)」

- scrape_record 状态加 skipped(迁移 0074);目标不在媒体库、库根只登记、作品没有 TMDB id 也没有本地元数据、排队失败时
  留一条挂在整理记录上的「未刮削」记录(同原因只记一次),设置关闭不记;
- 缺省开启 scrapeAfterImport,只对有 TMDB id 或本地元数据的作品排;
- 已结束记录重放与收尾同一口径补排刮削(按整理记录幂等);
- 整理详情「整理后刮削」显示未刮削原因,并列出失败与非预期跳过(缺元数据、认不出集号)的文件。

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:19:28 +08:00
1 parent 8c4c7d028a
commit cdf62dbf02
12 files changed
+320 -49

No files matched your search

@@ -0,0 +1,125 @@
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { SCRAPE_TASK, scrapeAfterImport } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
// 入库后自动刮削(缺省开):只对有 TMDB id 或本地元数据的作品排;没排上(库根只登记、作品没有元数据、排队失败)时
// 在整理记录上留一条「未刮削(原因)」的刮削记录;设置关闭是预期,不留。
const hasPg = await pgAvailable();
const SIZE = 200_000;
describe.skipIf(!hasPg)("入库后自动刮削:缺省开启,没排上时整理记录上可见", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let rootId = "";
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-sailib-" });
src = await createTempTree(
{
"Tenet.2020.1080p.BluRay.x264-GRP.mkv": SIZE,
"Dune.2021.1080p.BluRay.x264-GRP.mkv": SIZE,
"Arrival.2016.1080p.BluRay.x264-GRP.mkv": SIZE,
"Heat.1995.1080p.BluRay.x264-GRP.mkv": SIZE,
},
{ prefix: "umt-saisrc-" },
);
h = await createHarness("scrape_after_import");
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" })).rootId;
}, 60_000);
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
async function movie(title: string, opts: { tmdb?: string; metadata?: object } = {}) {
const id = await seedEntity(h.db, { kind: "movie", title, year: 2020 });
if (opts.metadata)
await h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify(opts.metadata) })
.where("id", "=", id)
.execute();
if (opts.tmdb)
await h.db
.insertInto("external_identity")
.values({
media_entity_id: id,
provider: "tmdb",
entity_type: "movie",
external_id: opts.tmdb,
status: "accepted",
})
.execute();
return id;
}
async function organize(entity: string, file: string) {
const plan = await planImport(h.d, {
sourcePaths: [src.path(file)],
mediaEntityId: entity,
action: "copy",
trigger: "manual",
actorType: "user",
});
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
return plan.recordId!;
}
const recordsOf = (orgId: string) =>
h.db
.selectFrom("scrape_record")
.select(["status", "reason", "trigger"])
.where("organization_record_id", "=", orgId)
.execute();
it("缺省设置:有 TMDB id 的作品入库后排上刮削", async () => {
const id = await movie("信条", { tmdb: "577922" });
const org = await organize(id, "Tenet.2020.1080p.BluRay.x264-GRP.mkv");
expect(await recordsOf(org)).toEqual([{ status: "queued", reason: null, trigger: "auto" }]);
expect(h.engine.enqueued.some((x) => x.task === SCRAPE_TASK)).toBe(true);
});
it("没有 TMDB id 也没有本地元数据:不排任务,记「未刮削」scrape_no_metadata;重放不重复记", async () => {
const before = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length;
const id = await movie("沙丘");
const org = await organize(id, "Dune.2021.1080p.BluRay.x264-GRP.mkv");
expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_no_metadata", trigger: "auto" }]);
expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(before);
await executeRecord(h.d, org);
expect(await recordsOf(org)).toHaveLength(1);
});
it("库根是只登记模式:记 scrape_root_read_only;排队失败:记 task_enqueue_failed", async () => {
const id = await movie("降临", { tmdb: "329865" });
const org = await organize(id, "Arrival.2016.1080p.BluRay.x264-GRP.mkv");
await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute();
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
try {
await scrapeAfterImport(h.d, id, org);
expect(await recordsOf(org)).toEqual([{ status: "skipped", reason: "scrape_root_read_only", trigger: "auto" }]);
} finally {
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();
}
await h.db.deleteFrom("scrape_record").where("organization_record_id", "=", org).execute();
const enqueue = h.d.ctx.engine.enqueue;
h.d.ctx.engine.enqueue = (async () => {
throw new Error("引擎不可用");
}) as never;
try {
await scrapeAfterImport(h.d, id, org);
} finally {
h.d.ctx.engine.enqueue = enqueue;
}
expect(await recordsOf(org)).toEqual([{ status: "failed", reason: "task_enqueue_failed", trigger: "auto" }]);
});
it("设置关闭:不排也不记", async () => {
await h.setSettings("organize", { scrapeAfterImport: false });
const id = await movie("盗火线", { tmdb: "949" });
const org = await organize(id, "Heat.1995.1080p.BluRay.x264-GRP.mkv");
expect(await recordsOf(org)).toEqual([]);
});
});
@@ -205,9 +205,14 @@ describe.skipIf(skip)("file manager in-place import", () => {
try {
const r = await queue(s.items, { scrapeAfterImport: true });
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
// 不刮削,只在整理记录上留一条「未刮削(只登记模式)」
expect(
await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(),
).toEqual([]);
await h.db
.selectFrom("scrape_record")
.select(["status", "reason"])
.where("organization_record_id", "=", r.recordId)
.execute(),
).toEqual([{ status: "skipped", reason: "scrape_root_read_only" }]);
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]);
} finally {
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();