import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { createMockTmdb, createTempTree, type MockTmdb, type TempTree, TMDB_FIXTURES } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { AoPorts } from "../../src/modules/acquisition-organize/ports.ts"; import type { AoDeps } from "../../src/modules/acquisition-organize/services/common.ts"; import { RESCRAPE_DELAY_MS, runScrapeRecord, SCRAPE_TASK, scrapeEntity, } from "../../src/modules/acquisition-organize/services/scrape.ts"; import { createEntityFromTmdbCandidate } from "../../src/modules/sources-identity/service/entity-from-candidate.ts"; import { setEntityIdentity } from "../../src/modules/sources-identity/service/identity-admin.ts"; import { refreshMetadata, setFieldOverride } from "../../src/modules/sources-identity/service/metadata.ts"; import { createHarness, type Harness, pgAvailable } from "./harness.ts"; // 改作品的 TMDB 映射后:清掉旧作品的 tmdb 来源字段(人工锁定的保留)→ full 刷新 → 刮削过的作品延后排一次「更新」模式重刮; // 重刮后的 NFO 标题 / 简介 / IMDb / 海报都是新作品的,不残留旧作品;刷新期间再刷新合进同一条排队记录(防抖)。 const up = await pgAvailable(); const enc = (s: string) => new TextEncoder().encode(s); const VIDEO = "Movies/奥本海默 (2023)/Oppenheimer.2023.1080p.mkv"; const DIR = "Movies/奥本海默 (2023)"; const NFO = `${DIR}/Oppenheimer.2023.1080p.nfo`; type Final = { value: unknown; source: string; locked: boolean }; describe.skipIf(!up)("改映射后不再新旧混写:清字段 → 刷新 → 更新模式重刮", () => { let h: Harness; let tmdb: MockTmdb; let lib: TempTree; let d: AoDeps; let id = ""; const actor = { type: "user" as const, id: "tester" }; const enqueueOpts: { task: string; availableAt?: Date | undefined }[] = []; beforeAll(async () => { // 沙丘 2 补一张海报,验证改映射后海报换成新作品的 const fixtures = TMDB_FIXTURES.map((f) => (f.id === 693134 ? { ...f, poster_path: "/dune.jpg" } : f)); tmdb = await createMockTmdb({ fixtures }); h = await createHarness("idrescrape", { tmdbUrl: tmdb.url }); lib = await createTempTree({ [VIDEO]: 1000 }, { sentinel: true, prefix: "umt-idscr-" }); const orig = h.engine.enqueue.bind(h.engine); h.engine.enqueue = async (task, input, o) => { enqueueOpts.push({ task, availableAt: o.availableAt }); return orig(task, input, o); }; d = { ctx: h.ctx, ports: { now: () => new Date(), scrapeImage: async (p: string) => ({ content: enc(p), mime: "image/jpeg" }), scrapeTmdbDetail: async () => null, scrapeTmdbSeason: async () => null, } as unknown as AoPorts, }; }, 60_000); afterAll(async () => { await h?.close(); await tmdb?.close(); await lib?.cleanup(); }); const queuedUpdates = () => h.db .selectFrom("scrape_record") .select(["id", "mode", "trigger", "status"]) .where("media_entity_id", "=", id) .where("status", "=", "queued") .execute(); it("先按奥本海默建档、刷新、刮削;没刮削过时刷新不排重刮", async () => { const r = await createEntityFromTmdbCandidate( h.db, () => h.rt.tmdb(), { externalId: "872585", kind: "movie" }, actor, ); id = r.mediaEntityId; expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed"); expect(h.engine.enqueued.some((x) => x.task === SCRAPE_TASK)).toBe(false); await setFieldOverride(h.rt, id, "tagline", { value: "我的标语", lock: true }, actor); const root = await h.db .insertInto("library_root") .values({ name: "lib-idscr", media_kind: "mixed", local_path: lib.root, management_mode: "managed" }) .returning("id") .executeTakeFirstOrThrow(); const asset = await h.db .insertInto("media_asset") .values({ media_entity_id: id, kind: "video" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("asset_location") .values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: VIDEO }) .execute(); const q = await scrapeEntity(d, id, { trigger: "manual" }); expect(q).toHaveLength(1); const rec = await runScrapeRecord(d, q[0]!.recordId); expect(rec).toMatchObject({ status: "succeeded", mode: "update" }); expect(rec.files.every((f) => f.action !== "written" || /^[0-9a-f]{64}$/.test(f.hash ?? ""))).toBe(true); const nfo = await readFile(join(lib.root, NFO), "utf8"); expect(nfo).toContain("奥本海默"); expect(nfo).toContain("tt15398776"); expect(nfo).toContain("我的标语"); expect(await readFile(join(lib.root, DIR, "poster.jpg"), "utf8")).toBe("/oppenheimer.jpg"); }, 60_000); it("改映射到沙丘 2:tmdb 来源字段清空,锁定字段保留,排 full 刷新", async () => { const before = h.engine.enqueued.length; const r = await setEntityIdentity( h.rt, id, { provider: "tmdb", entityType: "movie", externalId: "693134", lock: true }, actor, ); expect(r.changed).toBe(true); const e = await h.db.selectFrom("media_entity").select(["metadata"]).where("id", "=", id).executeTakeFirstOrThrow(); const meta = e.metadata as Record; expect(meta.overview).toBeUndefined(); expect(meta.posterPath).toBeUndefined(); expect(meta.externalIds).toBeUndefined(); expect(meta.tagline).toMatchObject({ value: "我的标语", locked: true }); const tmdbRows = await h.db .selectFrom("metadata_field") .select("field") .where("media_entity_id", "=", id) .where("source", "=", "tmdb") .execute(); expect(tmdbRows).toEqual([]); expect(h.engine.enqueued.slice(before)).toEqual([ expect.objectContaining({ task: "refreshMetadata", input: { mediaEntityId: id, full: true } }), ]); }); it("刷新后延后排一次更新模式重刮;期间再刷新合进同一条", async () => { const t0 = Date.now(); expect((await refreshMetadata(h.rt, id, { full: true })).status).toBe("refreshed"); const queued = await queuedUpdates(); expect(queued).toEqual([expect.objectContaining({ mode: "update", trigger: "auto" })]); const opt = enqueueOpts.filter((x) => x.task === SCRAPE_TASK).at(-1); expect(opt?.availableAt?.getTime()).toBeGreaterThanOrEqual(t0 + RESCRAPE_DELAY_MS - 1000); // 防抖:还没开始时再来一次刷新(哪怕字段又变了)不另建记录、不另排任务 await h.db .updateTable("metadata_field") .set({ value: JSON.stringify("旧值") }) .where("media_entity_id", "=", id) .where("field", "=", "overview") .execute(); const scrapes = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length; await refreshMetadata(h.rt, id, { full: true }); expect(await queuedUpdates()).toHaveLength(1); expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(scrapes); }, 60_000); it("重刮后 NFO 的标题 / 简介 / IMDb / 海报都是沙丘 2,不残留奥本海默;锁定字段照写", async () => { const [q] = await queuedUpdates(); const rec = await runScrapeRecord(d, q!.id); expect(rec.status).toBe("succeeded"); expect(rec.files.find((f) => f.role === "movie_nfo")).toMatchObject({ action: "written" }); const nfo = await readFile(join(lib.root, NFO), "utf8"); expect(nfo).toContain("沙丘2"); expect(nfo).toContain("693134"); expect(nfo).toContain("tt15239678"); expect(nfo).not.toContain("tt15398776"); expect(nfo).not.toContain("奥本海默"); expect(nfo).not.toContain("原子弹之父"); expect(nfo).toContain("我的标语"); expect(await readFile(join(lib.root, DIR, "poster.jpg"), "utf8")).toBe("/dune.jpg"); // 字段没变的刷新不再排重刮 const scrapes = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length; await refreshMetadata(h.rt, id, { full: true }); expect(h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK).length).toBe(scrapes); }, 60_000); });