merge: fix/e-scrape

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 21:17:16 +08:00
commit b3555c04b5
24 files changed
+1039 -79

No files matched your search

@@ -0,0 +1,327 @@
import { readFile, stat, utimes, writeFile } from "node:fs/promises";
import type { TmdbRichSeasonDetail } from "@umt/adapters";
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
// 刮削准确度:
// - 「更新」模式(缺省):不存在的写入;本工具上次写入且没被改过的按新内容覆盖(内容没变不重写);被人改过的跳过
// (scrape_user_modified);不是本工具写的不动;旧版本没有 hash 的记录按修改时间兜底;
// - 集 / 季缺元数据:先按 TMDB id 现取季详情补齐;取不到跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO;
// - 合集 / 其他类作品按电影刮,NFO 根元素是 <movie>;
// - 兜底异常记 scrape_internal_error。
const hasPg = await pgAvailable();
const at = new Date().toISOString();
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
const enc = (s: string) => new TextEncoder().encode(s);
function season1(names: Record<number, string>): TmdbRichSeasonDetail {
return {
tvId: "95396",
seasonNumber: 1,
name: "第 1 季",
nameLanguage: "zh-CN",
overview: null,
overviewLanguage: null,
airDate: "2022-02-17",
posterPath: null,
fetchedLanguages: ["zh-CN"],
episodes: Object.entries(names).map(([n, name]) => ({
seasonNumber: 1,
episodeNumber: Number(n),
name,
nameLanguage: "zh-CN",
overview: `${name}的简介`,
overviewLanguage: "zh-CN",
airDate: "2022-02-25",
runtime: 50,
stillPath: null,
voteAverage: null,
voteCount: null,
})),
};
}
describe.skipIf(!hasPg)("刮削准确度:更新模式、缺元数据不写占位、作品类型", () => {
let h: Harness;
let lib: TempTree;
let rootId = "";
let seasonDetail: (n: number) => Promise<TmdbRichSeasonDetail | null> = async () => null;
const seasonCalls: number[] = [];
beforeAll(async () => {
lib = await createTempTree(
{
"Movies/星际穿越 (2014)/Interstellar.2014.mkv": 100,
"Movies/星际穿越 (2014)/fanart.jpg": "别的工具放的背景图",
"TV/人生切割术 (2022)/Season 1/Severance.S01E01.mkv": 100,
"TV/人生切割术 (2022)/Season 1/Severance.S01E02.mkv": 100,
"Movies/合集 (2020)/Collection.2020.mkv": 100,
"Movies/其他 (2021)/Other.2021.mkv": 100,
},
{ sentinel: true, prefix: "umt-scracc-" },
);
h = await createHarness("scrape_acc", {
scrapeImage: async (p) => ({ content: enc(p), mime: "image/jpeg" }),
scrapeTmdbSeason: async (_tv, n) => {
seasonCalls.push(n);
return seasonDetail(n);
},
});
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId;
}, 60_000);
afterAll(async () => {
await h?.close();
await lib?.cleanup();
});
async function entity(kind: "movie" | "series" | "collection" | "other", title: string, meta: object) {
return (
await h.db
.insertInto("media_entity")
.values({ kind, title, year: 2020, status: "active", metadata: JSON.stringify(meta) })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function scrape(
entityId: string,
kind: "movie" | "tv",
path: string,
mode?: "missing" | "overwrite" | "update",
externalId: string | null = null,
) {
const match: ScrapeMatch = {
mediaEntityId: entityId,
provider: externalId ? "tmdb" : null,
externalId,
kind,
title: "x",
year: null,
source: "library",
};
const q = await queueScrape(h.d, {
rootId: `lib:${rootId}`,
rootPath: lib.root,
libraryRootId: rootId,
path,
match,
mode,
trigger: "manual",
});
return runScrapeRecord(h.d, q.recordId);
}
const fileOf = (files: ScrapeFileResult[], role: string) => files.find((x) => x.role === role);
const setTitle = (id: string, title: string) =>
h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ title: f(title), posterPath: f("/inter.jpg") }) })
.where("id", "=", id)
.execute();
const MOVIE_DIR = "Movies/星际穿越 (2014)";
const MOVIE_NFO = `${MOVIE_DIR}/Interstellar.2014.nfo`;
let movie = "";
it("更新模式:不存在的写入并记 hash;不是本工具写的不动", async () => {
movie = await entity("movie", "星际穿越", { title: f("星际穿越"), posterPath: f("/inter.jpg") });
const r = await scrape(movie, "movie", MOVIE_DIR);
expect(r).toMatchObject({ status: "succeeded", mode: "update" });
expect(fileOf(r.files, "movie_nfo")).toMatchObject({
action: "written",
hash: expect.stringMatching(/^[0-9a-f]{64}$/),
});
expect(fileOf(r.files, "poster")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越</title>");
// fanart.jpg 是别的工具放的:元数据没有背景图,不在目标里;加上背景图后更新模式也不覆盖它
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({ title: f("星际穿越"), posterPath: f("/inter.jpg"), backdropPath: f("/bg.jpg") }),
})
.where("id", "=", movie)
.execute();
const again = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(again.files, "fanart")).toMatchObject({ action: "skipped", reason: "scrape_exists" });
expect(await readFile(lib.path(`${MOVIE_DIR}/fanart.jpg`), "utf8")).toBe("别的工具放的背景图");
// 内容没变的不重写
expect(fileOf(again.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
expect(fileOf(again.files, "poster")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
expect(again.status).toBe("succeeded");
});
it("更新模式:本工具写的、没被改过的按新内容覆盖;被人改过的跳过并记原因", async () => {
await setTitle(movie, "星际穿越(新译名)");
const upd = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(upd.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(新译名)</title>");
await writeFile(lib.path(MOVIE_NFO), "<movie><title>我手改的</title></movie>");
await setTitle(movie, "星际穿越(再改)");
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toBe("<movie><title>我手改的</title></movie>");
expect(kept.status).toBe("succeeded");
// 之后每次更新都还认得出是人改过的(跳过的结果不带 hash,不会把人改的内容当成本工具写的)
const still = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(still.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
// 全部覆盖照旧改写
const over = await scrape(movie, "movie", MOVIE_DIR, "overwrite");
expect(fileOf(over.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(再改)</title>");
});
it("更新模式:旧版本记录没有 hash 时按修改时间判断", async () => {
// 模拟旧版本:去掉所有记录里的 hash
const rows = await h.db.selectFrom("scrape_record").select(["id", "files", "finished_at"]).execute();
for (const r of rows)
await h.db
.updateTable("scrape_record")
.set({
files: JSON.stringify((r.files as ScrapeFileResult[]).map(({ hash: _h, ...x }) => x)),
})
.where("id", "=", r.id)
.execute();
const last = rows
.filter((r) => r.finished_at)
.sort((a, b) => +new Date(b.finished_at!) - +new Date(a.finished_at!))[0]!;
const past = new Date(+new Date(last.finished_at!) - 60_000);
await utimes(lib.path(MOVIE_NFO), past, past);
await setTitle(movie, "星际穿越(旧记录)");
const r = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(r.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(旧记录)</title>");
// 改动时间晚于那次刮削:当作人改过
const rows2 = await h.db.selectFrom("scrape_record").select(["id", "files"]).execute();
for (const x of rows2)
await h.db
.updateTable("scrape_record")
.set({ files: JSON.stringify((x.files as ScrapeFileResult[]).map(({ hash: _h, ...y }) => y)) })
.where("id", "=", x.id)
.execute();
const future = new Date(Date.now() + 60_000);
await utimes(lib.path(MOVIE_NFO), future, future);
expect((await stat(lib.path(MOVIE_NFO))).mtimeMs).toBeGreaterThan(Date.now());
await setTitle(movie, "星际穿越(不该写进去)");
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
});
const SHOW = "TV/人生切割术 (2022)";
let series = "";
it("集单元没有元数据:按 TMDB id 现取季详情补齐,写出正确的集 NFO", async () => {
series = await entity("series", "人生切割术", {
title: f("人生切割术"),
overview: f("马克领导着一个办公室团队。"),
});
const season = await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
kind: "season",
season_number: 1,
status: "confirmed",
title: "第 1 季",
metadata: JSON.stringify({ name: f("第 1 季") }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: 1,
status: "confirmed",
title: "有关地狱的好消息",
metadata: JSON.stringify({ name: f("有关地狱的好消息"), overview: f("第一集简介") }),
})
.execute();
// 第 2 集是整理懒建的空单元
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: 2,
status: "provisional",
})
.execute();
seasonDetail = async (n) => (n === 1 ? season1({ 1: "TMDB 第一集", 2: "半循环" }) : null);
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(seasonCalls).toEqual([1]);
expect(r.status).toBe("succeeded");
const ep2 = await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E02.nfo`), "utf8");
expect(ep2).toContain("<title>半循环</title>");
expect(ep2).toContain("<plot><![CDATA[半循环的简介]]></plot>");
// 本地已有的集字段不被 TMDB 现取的值替换
expect(await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E01.nfo`), "utf8")).toContain(
"<title>有关地狱的好消息</title>",
);
});
it("TMDB 季详情失败:缺元数据的集跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO", async () => {
await writeFile(lib.path(`${SHOW}/Season 1/Severance.S01E03.mkv`), "video");
seasonDetail = async () => {
throw new Error("TMDB 超时");
};
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(r).toMatchObject({ status: "partial", reason: "scrape_metadata_missing" });
expect(r.files.find((x) => x.path.endsWith("S01E03.nfo"))).toMatchObject({
action: "skipped",
reason: "scrape_metadata_missing",
});
await expect(stat(lib.path(`${SHOW}/Season 1/Severance.S01E03.nfo`))).rejects.toThrow();
// 季单元也没有元数据时同样不写「季 N」占位
await h.db
.updateTable("content_unit")
.set({ title: null, metadata: JSON.stringify({}) })
.where("media_entity_id", "=", series)
.where("kind", "=", "season")
.execute();
const r2 = await scrape(series, "tv", SHOW, "overwrite", "95396");
expect(fileOf(r2.files, "season_nfo")).toMatchObject({ action: "skipped", reason: "scrape_metadata_missing" });
expect(r2.status).toBe("partial");
});
it("合集 / 其他类作品按电影刮削,NFO 根元素是 <movie>", async () => {
expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe(
"movie",
);
for (const [kind, dir, nfo] of [
["collection", "Movies/合集 (2020)", "Collection.2020.nfo"],
["other", "Movies/其他 (2021)", "Other.2021.nfo"],
] as const) {
const id = await entity(kind, `${kind} 作品`, { title: f(`${kind} 作品`) });
const r = await scrape(id, "movie", dir);
expect(r.status).toBe("succeeded");
const xml = await readFile(lib.path(`${dir}/${nfo}`), "utf8");
expect(xml).toContain("\n<movie>");
expect(xml).not.toContain("<tvshow>");
}
});
it("兜底异常记 scrape_internal_error(不是写文件失败)", async () => {
h.d.ports.scrapeTmdbSeason = (() => {
throw new Error("意外错误");
}) as never;
await h.db
.updateTable("content_unit")
.set({ title: null, metadata: JSON.stringify({}) })
.where("media_entity_id", "=", series)
.execute();
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(r).toMatchObject({ status: "failed", reason: "scrape_internal_error" });
});
});
@@ -96,7 +96,7 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
}
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
async function rescrape(entity: string, mode?: "missing" | "overwrite" | "update") {
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
expect(q.length).toBe(1);
return runScrapeRecord(h.d, q[0]!.recordId);
@@ -193,7 +193,7 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
expect(first).toMatchObject({
media_entity_id: series,
trigger: "auto",
mode: "missing",
mode: "update", // 缺省覆盖方式:更新
organization_record_id: rec,
});
@@ -124,6 +124,12 @@ describe.skipIf(skip)("file manager in-place import", () => {
.where("organization_record_id", "=", r.recordId)
.execute();
expect(recs).toMatchObject([{ path: title, videos: [ep1] }]);
// 季 / 集单元缺元数据时不写占位 NFO:模拟刷新元数据已填过整理懒建的单元
await h.db
.updateTable("content_unit")
.set({ metadata: JSON.stringify({ name: { value: "名称", source: "tmdb", language: null, at, locked: false } }) })
.where("media_entity_id", "=", entity)
.execute();
await runScrapeRecord(h.module.deps().ao, recs[0]!.id);
const show = await readdir(join(h.lib, title));
const season = await readdir(join(h.lib, title, "Season 1"));
+1 -1
View File
@@ -277,7 +277,7 @@ describe.skipIf(skip)("files 刮削(SCR-2:任意目录就地刮削)", () => {
expect(show).toMatchObject({
status: "succeeded",
path: SHOW,
mode: "missing",
mode: "update",
trigger: "manual",
match: {
mediaEntityId: null,
@@ -0,0 +1,179 @@
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);
});