From cdf62dbf02857b9bad4c2733a056d2feb44f7a2a Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:19:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(scrape):=20=E5=85=A5=E5=BA=93=E5=90=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=AE=E5=89=8A=E7=BC=BA=E7=9C=81=E5=BC=80?= =?UTF-8?q?=E5=90=AF,=E6=B2=A1=E6=8E=92=E4=B8=8A=E6=97=B6=E5=9C=A8?= =?UTF-8?q?=E6=95=B4=E7=90=86=E8=AE=B0=E5=BD=95=E4=B8=8A=E8=AE=B0=E3=80=8C?= =?UTF-8?q?=E6=9C=AA=E5=88=AE=E5=89=8A=EF=BC=88=E5=8E=9F=E5=9B=A0=EF=BC=89?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scrape_record 状态加 skipped(迁移 0074);目标不在媒体库、库根只登记、作品没有 TMDB id 也没有本地元数据、排队失败时 留一条挂在整理记录上的「未刮削」记录(同原因只记一次),设置关闭不记; - 缺省开启 scrapeAfterImport,只对有 TMDB id 或本地元数据的作品排; - 已结束记录重放与收尾同一口径补排刮削(按整理记录幂等); - 整理详情「整理后刮削」显示未刮削原因,并列出失败与非预期跳过(缺元数据、认不出集号)的文件。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../services/organize-exec.ts | 6 +- .../acquisition-organize/services/scrape.ts | 134 ++++++++++++++---- .../scrape-after-import.test.ts | 125 ++++++++++++++++ .../file-manager/organize-in-place.test.ts | 9 +- .../app/components/library/scrape-panel.tsx | 3 + .../organize/organize-scrape-history.tsx | 39 +++-- docs/runbook/scrape.md | 19 ++- packages/contracts/src/reasons.ts | 2 + packages/contracts/src/scrape.ts | 3 +- packages/contracts/src/settings.ts | 3 +- .../migrations/0074_scrape_record_skipped.ts | 24 ++++ packages/db/src/types.ts | 2 +- 12 files changed, 320 insertions(+), 49 deletions(-) create mode 100644 apps/server/test/acquisition-organize/scrape-after-import.test.ts create mode 100644 packages/db/migrations/0074_scrape_record_skipped.ts 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 38cc6677..23286904 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -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, diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index e4df6c9b..c9d17414 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -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 { + 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 { 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(); @@ -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 }); } } diff --git a/apps/server/test/acquisition-organize/scrape-after-import.test.ts b/apps/server/test/acquisition-organize/scrape-after-import.test.ts new file mode 100644 index 00000000..683f19ae --- /dev/null +++ b/apps/server/test/acquisition-organize/scrape-after-import.test.ts @@ -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([]); + }); +}); diff --git a/apps/server/test/file-manager/organize-in-place.test.ts b/apps/server/test/file-manager/organize-in-place.test.ts index 74cadfc3..bcd453b5 100644 --- a/apps/server/test/file-manager/organize-in-place.test.ts +++ b/apps/server/test/file-manager/organize-in-place.test.ts @@ -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(); diff --git a/apps/web/app/components/library/scrape-panel.tsx b/apps/web/app/components/library/scrape-panel.tsx index fe6e0429..c6071968 100644 --- a/apps/web/app/components/library/scrape-panel.tsx +++ b/apps/web/app/components/library/scrape-panel.tsx @@ -21,6 +21,7 @@ export const SCRAPE_STATUS: Record = { @@ -82,6 +83,8 @@ function FileRow({ f }: { f: ScrapeFileResult }) { /** 不必逐条列出的跳过:已存在(不是本工具写的 / 只补缺失)、内容没变 */ const QUIET_SKIPS = new Set(["scrape_exists", "scrape_unchanged"]); +/** 预期内的跳过(已存在、没变、被人改过);其余跳过(缺元数据、认不出集号)让记录算部分完成 */ +export const EXPECTED_SCRAPE_SKIPS = new Set([...QUIET_SKIPS, "scrape_user_modified"]); /** 一条刮削记录:状态、目录与识别结果、计数、逐文件结果(缺省不列"已存在"跳过的) */ export function ScrapeRecordView({ record: s, showPath = false }: { record: ScrapeRecord; showPath?: boolean }) { diff --git a/apps/web/app/components/organize/organize-scrape-history.tsx b/apps/web/app/components/organize/organize-scrape-history.tsx index d589043a..0b053ec2 100644 --- a/apps/web/app/components/organize/organize-scrape-history.tsx +++ b/apps/web/app/components/organize/organize-scrape-history.tsx @@ -1,14 +1,18 @@ import { useQuery } from "@tanstack/react-query"; -import { FilesScrapeRecordsResult } from "@umt/contracts"; +import { FilesScrapeRecordsResult, type ScrapeFileResult } from "@umt/contracts"; import { Link } from "react-router"; import { apiParsed } from "~/api/parse.ts"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PaginatedList } from "~/components/console/paginated-list.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx"; +import { EXPECTED_SCRAPE_SKIPS, SCRAPE_STATUS, scrapeCounts } from "~/components/library/scrape-panel.tsx"; import { reasonLine } from "~/lib/reasons.ts"; import { formatDateTime } from "~/lib/utils.ts"; +/** 失败的文件,以及缺元数据、认不出集号等不在预期内的跳过 */ +const unfinished = (files: ScrapeFileResult[]) => + files.filter((f) => f.action === "failed" || (f.action === "skipped" && !EXPECTED_SCRAPE_SKIPS.has(f.reason ?? ""))); + /** 整理与刮削分别展示结果,避免将刮削失败当作文件搬运失败。 */ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) { const query = useQuery({ @@ -39,21 +43,36 @@ export function OrganizeScrapeHistory({ recordId }: { recordId: string }) {

{record.match?.title ?? record.path} · {record.path || "/"}

-

{scrapeCounts(record) || "等待写入 NFO 与图片"}

- {record.reason ?

{reasonLine(record.reason)}

: null} - {record.files.some((file) => file.action === "failed") ? ( + {record.status === "skipped" ? null : ( +

{scrapeCounts(record) || "等待写入 NFO 与图片"}

+ )} + {record.reason ? ( +

+ {reasonLine(record.reason)} +

+ ) : null} + {unfinished(record.files).length ? (
- 失败文件 + 失败或没写的文件 file.action === "failed")} - label="刮削失败文件" + rows={unfinished(record.files)} + label="刮削失败或跳过的文件" resetKey={record.id} as="ul" className="mt-2 space-y-1" > {(file) => ( -
  • - {file.path} · {reasonLine(file.reason) || "写入失败"} +
  • + {file.path} · {reasonLine(file.reason) || (file.action === "failed" ? "写入失败" : "跳过")}
  • )}
    diff --git a/docs/runbook/scrape.md b/docs/runbook/scrape.md index 7818f353..7b2118e6 100644 --- a/docs/runbook/scrape.md +++ b/docs/runbook/scrape.md @@ -28,7 +28,7 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接 | --- | --- | | 文件管理「刮削…」(右键 / ⋯ 菜单、多选批量栏、详情按钮) | `POST /files/scrape {items[{root,path,mediaEntityId?,external?}], mode?, addToLibrary?}`;任意视频文件或目录,每项一条记录,请求内最多等 `FILES_TRANSFER_WAIT_SEC`(缺省 20 s),没刮完的 `pending=true`,前端按 `GET /files/scrape-records?ids=` 轮询 | | 作品详情「重新刮削」「覆盖重写」 | `POST /library/{id}/scrape {mode?}`(202):对作品目录刮削,每个目录一条记录;没有已入库文件 412(到文件管理里对目录刮削) | -| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport` 打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<序号>` | +| 整理入库成功(`finalizeRecord` 结果为已入库 / 部分入库) | 设置 `organize.scrapeAfterImport`(缺省开)打开时对作品目录排刮削,幂等键 `scrapeMedia:record:<整理记录 id>:<目录>`;只对有 TMDB id 或本地元数据的作品排,没排上见下「入库后没排上」 | | 元数据刷新后 NFO 字段有变化 | 只对刮削过的作品(有成功 / 部分成功的记录):对作品目录排「更新」模式刮削(`trigger=auto`),任务延后 2 分钟开始;见下「刷新后自动重刮」 | | 改作品的 TMDB 映射 | 清掉旧作品的 tmdb 来源字段 → 排 full 刷新(幂等键 `refreshMetadata:identity:<新身份 id>`)→ 刷新写回新字段后按上一行自动重刮 | @@ -81,6 +81,19 @@ SCR-2(用户 2026-09-30:"已经有的也可以重新刮削……如果是直接 TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算缺,照常写(和 MP 一样)。未登记目录直接取 TMDB 时同一规则:季详情失败的季跳过。 +### 入库后没排上 + +入库后该刮却没排上任务时,在整理记录上留一条 `status = skipped` 的刮削记录(迁移 0074),整理详情「整理后刮削」显示「未刮削」与原因; +同一整理记录同一原因只记一次。设置关闭(或本次整理取消勾选)是预期,不记。 + +| 原因 | 情况 | +| --- | --- | +| `scrape_not_library` | 整理目标不在媒体库目录里 | +| `scrape_root_read_only` | 库根是只登记模式 | +| `scrape_no_metadata` | 作品没有 TMDB id,也没有本地元数据(Local 补全的字段) | +| `task_enqueue_failed` | 排任务失败(记录状态 failed) | +| `scrape_internal_error` | 查库等意外错误 | + ### 识别(文件管理) 顺序:请求指定 > 目录里已登记文件所属的唯一作品(`source=library`)> 按路径识别(`source=auto`)。 @@ -167,7 +180,7 @@ TMDB 有这一集但没有译名 / 简介(只有占位名和播出日期)不算 | 字段 | 缺省 | 说明 | | --- | --- | --- | -| `organize.scrapeAfterImport` | 关 | 入库后自动刮削(MP 目录「刮削」缺省同样关) | +| `organize.scrapeAfterImport` | 开 | 入库后自动刮削(MP 目录「刮削」缺省关;我们缺省开,因为「更新」方式不动别人写的文件、缺元数据不写占位,且只对有 TMDB id 或本地元数据的作品排)。以前保存过设置的照旧按保存的值 | | `organize.scrapeMode` | `update` | `update` 更新 / `missing` 只补缺失(MP「仅缺失」)/ `overwrite` 全部覆盖,见上「覆盖方式」;手动刮削时对话框可临时改。以前保存过的 `missing` / `overwrite` 照旧生效,要用更新到设置里改 | | `organize.scrapeImages` | 开 | 海报、背景图、季海报 | | `organize.scrapeEpisodeThumbs` | 开 | 每集剧照;`scrapeImages` 关时不生效 | @@ -223,7 +236,7 @@ cd /opt/uhd_media_tool && deno run -A npm:@biomejs/biome check --write apps/serv | 现象 | 看哪里 | | --- | --- | -| 入库后没有 NFO | 设置里「入库后自动刮削」是否打开;作品详情「刮削」是否有记录;引擎运行 `scrapeMedia` | +| 入库后没有 NFO | 整理详情「整理后刮削」是否有「未刮削」及原因;设置里「入库后自动刮削」是否打开;引擎运行 `scrapeMedia` | | 刮削对话框一直让选作品 | 文件名解析不出标题,或 TMDB 有多个同名候选;选一次即可,打开「同时加入媒体库」后下次按已入库作品识别 | | 全部「跳过」 | 覆盖方式是只补缺失,或更新时文件不是本工具写的 / 被人改过(`scrape_exists` / `scrape_user_modified`);要替换选「全部覆盖」或作品详情「覆盖重写」 | | 改了映射 / 刷新了元数据,NFO 没变 | 作品以前没刮削过(不自动重刮);重刮任务要等 2 分钟;NFO 被人改过(`scrape_user_modified`) | diff --git a/packages/contracts/src/reasons.ts b/packages/contracts/src/reasons.ts index fd624f6c..181a4a50 100644 --- a/packages/contracts/src/reasons.ts +++ b/packages/contracts/src/reasons.ts @@ -117,6 +117,8 @@ const REASONS: Record = { scrape_image_failed: { text: "图片没下载成功", action: "稍后重新刮削" }, scrape_no_episode: { text: "文件名里认不出集号" }, scrape_no_work_dir: { text: "视频直接放在媒体库目录下,不写剧集和季的文件" }, + scrape_canceled: { text: "刮削任务被取消", action: "重新刮削" }, + scrape_not_library: { text: "整理目标不在媒体库目录里,不自动刮削", action: "到文件管理里对目录刮削" }, }; /** diff --git a/packages/contracts/src/scrape.ts b/packages/contracts/src/scrape.ts index d5c9ec09..d86078ce 100644 --- a/packages/contracts/src/scrape.ts +++ b/packages/contracts/src/scrape.ts @@ -26,7 +26,8 @@ export type ScrapeFileResult = z.infer; export const ScrapeMode = z.enum(["missing", "overwrite", "update"]); export type ScrapeMode = z.infer; -export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed"]); +/** skipped = 入库后自动刮削没排上(原因在 reason;0074),整理详情显示「未刮削」 */ +export const ScrapeStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "skipped"]); export type ScrapeStatus = z.infer; export const ScrapeTrigger = z.enum(["auto", "manual"]); export type ScrapeTrigger = z.infer; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3f810a2b..cadbf484 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -644,7 +644,8 @@ export const DEFAULT_SETTINGS: GlobalSettings = { defaultMode: "copy", allowCrossVolumeMove: true, minSizeBytes: 50 * 1024 * 1024, - scrapeAfterImport: false, + // 缺省开:只对有 TMDB id 或本地元数据的作品排刮削(否则在整理记录上记「未刮削」),「更新」方式不动别人写的文件 + scrapeAfterImport: true, scrapeMode: "update", scrapeImages: true, scrapeEpisodeThumbs: true, diff --git a/packages/db/migrations/0074_scrape_record_skipped.ts b/packages/db/migrations/0074_scrape_record_skipped.ts new file mode 100644 index 00000000..3a452cf7 --- /dev/null +++ b/packages/db/migrations/0074_scrape_record_skipped.ts @@ -0,0 +1,24 @@ +import type { Kysely } from "kysely"; +import { execDdl, oneOf } from "../src/migration-helpers.ts"; + +// 入库后自动刮削没排上(库根不是可写管理模式、作品没有 TMDB id 也没有本地元数据、排队失败)时在整理记录上可见: +// 写一条 status = skipped 的刮削记录(原因码在 reason),整理详情的「整理后刮削」里显示「未刮削(原因)」。 +// 设置关闭属预期,不写记录。 + +const OLD = ["queued", "running", "succeeded", "partial", "failed"]; +const NEW = [...OLD, "skipped"]; + +const swap = (values: readonly string[]) => ` +alter table scrape_record drop constraint if exists scrape_record_status_check; +alter table scrape_record add constraint scrape_record_status_check ${oneOf("status", values)}; +`; + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, swap(NEW)); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `delete from scrape_record where status = 'skipped'; ${swap(OLD)}`); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index fd96bd81..6f6d0918 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -231,7 +231,7 @@ export type ScheduledJobKind = "task" | "agent_check"; export type ScheduledJobLastStatus = "ok" | "failed" | "skipped" | "running"; export type ScheduledJobRunTrigger = "schedule" | "manual" | "skip"; export type ScheduledJobRunStatus = "ok" | "failed" | "skipped" | "running"; -export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed"; +export type ScrapeRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "skipped"; export type ScrapeRecordTrigger = "auto" | "manual"; export type ScrapeRecordMode = "missing" | "overwrite" | "update"; export type SecretPurpose =