Files
UHD_Media_Tool/apps/server/test/sources-identity/identity-change-rescrape.test.ts
T
rootandClaude Opus 5.5 37cf0a398e feat(identity): 改 TMDB 映射后清旧字段、full 刷新并自动更新重刮
- setEntityIdentity:TMDB 映射变化时同事务清掉作品及季 / 集单元的 tmdb 来源字段(人工覆盖 / 锁定保留,
  海报背景图不再保留旧作品的),退役没锁的 IMDb 映射,提交后排 full 刷新
- refreshMetadata:NFO 相关字段(作品或季 / 集)有变化时调 scheduleEntityRescrape,评分票数变化不触发

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 21:04:57 +08:00

180 lines
8.1 KiB
TypeScript

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("<title>奥本海默</title>");
expect(nfo).toContain("tt15398776");
expect(nfo).toContain("<tagline>我的标语</tagline>");
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<string, Final>;
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("<title>沙丘2</title>");
expect(nfo).toContain("<tmdbid>693134</tmdbid>");
expect(nfo).toContain("tt15239678");
expect(nfo).not.toContain("tt15398776");
expect(nfo).not.toContain("奥本海默");
expect(nfo).not.toContain("原子弹之父");
expect(nfo).toContain("<tagline>我的标语</tagline>");
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);
});