import { mkdir, 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 根元素是 ; // - 兜底异常记 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): 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 = 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("星际穿越"); // 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("星际穿越(新译名)"); await writeFile(lib.path(MOVIE_NFO), "我手改的"); 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("我手改的"); 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("星际穿越(再改)"); }); 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("星际穿越(旧记录)"); // 改动时间晚于那次刮削:当作人改过 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("半循环"); expect(ep2).toContain(""); // 本地已有的集字段不被 TMDB 现取的值替换 expect(await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E01.nfo`), "utf8")).toContain( "有关地狱的好消息", ); }); 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("没有 TMDB id 的作品(Local)季 / 集缺元数据时照旧写占位", async () => { const local = await entity("series", "本地剧", { title: f("本地剧") }); const dir = "TV/本地剧 (2020)"; await mkdir(lib.path(`${dir}/Season 1`), { recursive: true }); await writeFile(lib.path(`${dir}/Season 1/Local.S01E01.mkv`), "video"); const calls = seasonCalls.length; const r = await scrape(local, "tv", dir, "update", null); expect(seasonCalls.length).toBe(calls); expect(r.files.find((x) => x.path.endsWith("S01E01.nfo"))).toMatchObject({ action: "written" }); expect(await readFile(lib.path(`${dir}/Season 1/Local.S01E01.nfo`), "utf8")).toContain("第 1 集"); expect(r.files.some((x) => x.reason === "scrape_metadata_missing")).toBe(false); }); it("合集 / 其他类作品按电影刮削,NFO 根元素是 ", 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"); expect(xml).not.toContain(""); } }); 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" }); }); });