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

@@ -82,10 +82,8 @@ export async function executeRecord(
await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
const frozen = rec.plan as unknown as FrozenPlan;
if (
(frozen.metadataMode === "local" || frozen.scrapeAfterImport !== undefined) &&
["succeeded", "partial"].includes(rec.status)
) {
// 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮
if (["succeeded", "partial"].includes(rec.status)) {
await scrapeAfterImport(d, frozen.entityId, recordId, {
force: frozen.metadataMode === "local",
enabled: frozen.scrapeAfterImport,
@@ -422,7 +422,55 @@ export async function scheduleEntityRescrape(d: QueueDeps, entityId: string): Pr
return out.length;
}
/** 整理入库成功后(设置打开或本次要求刮削):照 MP 对作品目录刮削,集只刮本次整理的文件。 */
/**
* 入库后自动刮削没排上:写一条 skipped 刮削记录挂在整理记录上(整理详情「整理后刮削」显示「未刮削(原因)」)。
* 同一整理记录同一原因只记一次(崩溃重放会再调)。失败只告警。
*/
async function recordScrapeSkipped(
d: AoDeps,
recordId: string,
reason: string,
at: { root?: { id: string; local_path: string } | undefined; path?: string; match?: ScrapeMatch | null },
): Promise<void> {
try {
const dup = await d.ctx.db
.selectFrom("scrape_record")
.select("id")
.where("organization_record_id", "=", recordId)
.where("status", "=", "skipped")
.where("reason", "=", reason)
.executeTakeFirst();
if (dup) return;
const now = d.ports.now();
await d.ctx.db
.insertInto("scrape_record")
.values({
root_id: at.root ? `lib:${at.root.id}` : "",
root_path: at.root?.local_path ?? "",
library_root_id: at.root?.id ?? null,
path: at.path ?? "",
media_entity_id: at.match?.mediaEntityId ?? null,
match: at.match ? jsonb(at.match) : null,
status: "skipped",
trigger: "auto",
mode: (await scrapeSettings(d)).scrapeMode,
organization_record_id: recordId,
actor_type: "system",
reason,
requested_at: now,
finished_at: now,
})
.execute();
} catch (err) {
d.ctx.logger.warn({ recordId, reason, err: { message: errMessage(err) } }, "记录「未刮削」失败");
}
}
/**
* 整理入库成功后(设置打开或本次要求刮削):照 MP 对作品目录刮削,集只刮本次整理的文件。
* 只对有 TMDB id 或本地元数据(Local)的作品排;没排上(目标不在媒体库、库根只登记、作品没有元数据、排队失败)时
* 在整理记录上记「未刮削(原因)」;设置关闭是预期,不记。
*/
export async function scrapeAfterImport(
d: AoDeps,
entityId: string | null,
@@ -431,23 +479,11 @@ export async function scrapeAfterImport(
): Promise<void> {
if (!entityId) return;
if (opts.enabled === false) return;
let root: { id: string; local_path: string; management_mode: string } | undefined;
let match: ScrapeMatch | null = null;
try {
const s = await scrapeSettings(d);
if (!s.scrapeAfterImport && !opts.force && opts.enabled !== true) return;
const organization = await d.ctx.db
.selectFrom("organization_record")
.select(["target_library_root_id"])
.where("id", "=", recordId)
.executeTakeFirst();
const match = await entityScrapeMatch(d, entityId, "library");
const root = organization?.target_library_root_id
? await d.ctx.db
.selectFrom("library_root")
.selectAll()
.where("id", "=", organization.target_library_root_id)
.executeTakeFirst()
: undefined;
if (!match || !root || root.management_mode !== "managed") return;
const files = await d.ctx.db
.selectFrom("organization_file_result")
.select("target_path")
@@ -456,6 +492,35 @@ export async function scrapeAfterImport(
.where("status", "=", "succeeded")
.orderBy("ordinal")
.execute();
// 本次没有新入库的视频(都已在库等):没什么可刮
if (!files.some((f) => f.target_path)) return;
const organization = await d.ctx.db
.selectFrom("organization_record")
.select(["target_library_root_id"])
.where("id", "=", recordId)
.executeTakeFirst();
root = organization?.target_library_root_id
? await d.ctx.db
.selectFrom("library_root")
.select(["id", "local_path", "management_mode"])
.where("id", "=", organization.target_library_root_id)
.executeTakeFirst()
: undefined;
match = await entityScrapeMatch(d, entityId, "library");
if (!root) return await recordScrapeSkipped(d, recordId, "scrape_not_library", { match });
if (root.management_mode !== "managed")
return await recordScrapeSkipped(d, recordId, "scrape_root_read_only", { root, match });
if (!match) return await recordScrapeSkipped(d, recordId, "scrape_no_metadata", { root });
if (!match.externalId) {
// 没有 TMDB id:只有本地元数据(Local 模式补全的字段)时才刮
const e = await d.ctx.db
.selectFrom("media_entity")
.select("metadata")
.where("id", "=", entityId)
.executeTakeFirst();
if (!Object.keys((e?.metadata ?? {}) as object).length)
return await recordScrapeSkipped(d, recordId, "scrape_no_metadata", { root, match });
}
// 照 MP 整理后刮削:按作品目录分组,对作品目录刮(作品 / 季的 NFO 与图片照常初始化),集只刮本次整理的文件;
// 视频直接在库根(推不出作品目录)时对文件本身刮。
const byDir = new Map<string, string[]>();
@@ -464,20 +529,35 @@ export async function scrapeAfterImport(
const dir = inferWorkDir(match.kind, f.target_path) ?? f.target_path;
byDir.set(dir, [...(byDir.get(dir) ?? []), f.target_path]);
}
for (const [dir, videos] of byDir)
await queueScrape(d, {
rootId: `lib:${root.id}`,
rootPath: root.local_path,
libraryRootId: root.id,
path: dir,
match,
trigger: "auto",
organizationRecordId: recordId,
idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${dir}`,
...(dir === videos[0] ? {} : { videos }),
});
for (const [dir, videos] of byDir) {
try {
await queueScrape(d, {
rootId: `lib:${root.id}`,
rootPath: root.local_path,
libraryRootId: root.id,
path: dir,
match,
trigger: "auto",
organizationRecordId: recordId,
idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${dir}`,
...(dir === videos[0] ? {} : { videos }),
});
} catch (err) {
// 新记录入队失败时 queueScrape 已把它标成 task_enqueue_failed(整理详情可见);这里再兜一条,防止没留下记录
d.ctx.logger.warn({ entityId, recordId, dir, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
const marked = await d.ctx.db
.selectFrom("scrape_record")
.select("id")
.where("organization_record_id", "=", recordId)
.where("path", "=", dir)
.where("reason", "=", "task_enqueue_failed")
.executeTakeFirst();
if (!marked) await recordScrapeSkipped(d, recordId, "task_enqueue_failed", { root, path: dir, match });
}
}
} catch (err) {
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
await recordScrapeSkipped(d, recordId, "scrape_internal_error", { root, match });
}
}