import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import type { TmdbRichDetail, TmdbRichSeasonDetail } from "@umt/adapters"; import type { ScrapeMatch } from "@umt/contracts"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts"; import { createFmHarness, type FmHarness, pgAvailable } from "../file-manager/harness.ts"; // SCR-2 刮削文件布局差分:同一组目录结构,MP ScrapingChain.scrape_metadata(gen-scrape-layout-mp.py 生成 // scrape-layout-mp.json)写哪些文件 ⇔ 我们就地刮削写哪些文件。偏离只允许下面 DEVIATIONS 里登记的几类 // (理由见 docs/runbook/scrape.md「与 MP 的差分」)。 const skip = !(await pgAvailable()); const MP = JSON.parse(await readFile(new URL("./scrape-layout-mp.json", import.meta.url), "utf8")) as Record< string, { target: string; ok: boolean; written: string[] } >; const SHOW = "人生切割术 (2022)"; const EP1 = `${SHOW}/Season 1/人生切割术 - S01E01 - 有关地狱的好消息.mkv`; const EP2 = `${SHOW}/Season 1/人生切割术 - S01E02 - 半循环.mkv`; const MOVIE_FILE = "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv"; const LOOSE = "Interstellar.2014.1080p.BluRay.mkv"; const TREE = [EP1, EP2, MOVIE_FILE, LOOSE, "Heat (1995)/BDMV/index.bdmv"]; /** 登记的偏离:把 MP 的输出换算成我们应有的输出 */ const DEVIATIONS: { id: string; apply: (files: string[], scenario: string) => string[] }[] = [ // 1. 不写 backdrop.jpg 副本,只写 fanart.jpg(Emby / Jellyfin / Kodi 都认 fanart) { id: "no_backdrop_copy", apply: (fs) => fs.filter((f) => !/(^|\/)backdrop\.jpg$/.test(f)) }, // 2. 剧照 <视频名>-thumb.jpg(Kodi 约定;MP 写 <视频名>.jpg) { id: "episode_thumb_suffix", apply: (fs) => fs.map((f) => (/ - S\d+E\d+ - [^/]+\.jpg$/.test(f) ? f.replace(/\.jpg$/, "-thumb.jpg") : f)), }, // 3. 视频直接在登记目录根:图片写 <视频名>-poster.jpg / -fanart.jpg,不往根里放 poster.jpg(MP 会) { id: "root_movie_images", apply: (fs, s) => s !== "movie_file_in_root" ? fs : fs.map((f) => (f === "poster.jpg" || f === "fanart.jpg" ? `${LOOSE.replace(/\.mkv$/, "")}-${f}` : f)), }, ]; const detail = (entityType: "movie" | "tv", externalId: string): TmdbRichDetail => ({ entityType, externalId, title: "x", titleLanguage: "zh-CN", titleZh: "x", titleZhLanguage: "zh-CN", originalTitle: null, originalLanguage: "en", overview: null, overviewLanguage: null, tagline: null, year: 2022, releaseDate: null, lastAirDate: null, genres: [], voteAverage: null, voteCount: null, status: null, runtime: null, seasonCount: null, episodeCount: null, networks: [], originCountry: [], posterPath: "/poster.jpg", backdropPath: "/backdrop.jpg", cast: [], crew: [], externalIds: { imdb: null, tvdb: null, wikidata: null }, translations: [], alternativeTitles: [], seasons: [], languages: ["zh-CN"], }) satisfies TmdbRichDetail; const season1: TmdbRichSeasonDetail = { tvId: "95396", seasonNumber: 1, name: null, nameLanguage: null, overview: null, overviewLanguage: null, airDate: null, posterPath: "/season.jpg", fetchedLanguages: ["zh-CN"], episodes: [1, 2].map((n) => ({ seasonNumber: 1, episodeNumber: n, name: null, nameLanguage: null, overview: null, overviewLanguage: null, airDate: null, runtime: null, stillPath: "/still.jpg", voteAverage: null, voteCount: null, })), }; async function files(root: string): Promise> { const out = new Set(); const walk = async (d: string) => { for (const n of await readdir(d).catch(() => [])) { if (n.startsWith(".")) continue; const p = join(d, n); if ((await stat(p)).isDirectory()) await walk(p); else out.add(relative(root, p)); } }; await walk(root); return out; } describe.skipIf(skip)("刮削文件布局与 MP 差分", () => { let h: FmHarness; beforeAll(async () => { h = await createFmHarness( "scrlayout", { ao: { scrapeImage: async () => ({ content: new Uint8Array([0xff, 0xd8, 0xff]), mime: "image/jpeg" }), scrapeTmdbDetail: async (type, id) => detail(type, id), scrapeTmdbSeason: async (_tv, n) => (n === 1 ? season1 : null), }, }, { runTransfers: false }, ); }); afterAll(async () => h?.close()); for (const [scenario, mp] of Object.entries(MP)) { it(`${scenario}:${mp.target}`, async () => { // 每个场景一份独立目录树;"视频直接在根"的场景用下载根(整个根就是这棵树) const inRoot = scenario === "movie_file_in_root"; const base = inRoot ? h.dl : join(h.lib, scenario); for (const f of TREE) { await mkdir(dirname(join(base, f)), { recursive: true }); await writeFile(join(base, f), "video"); } const before = await files(base); const tv = mp.target.startsWith(SHOW); const match: ScrapeMatch = { mediaEntityId: null, provider: "tmdb", externalId: tv ? "95396" : "157336", kind: tv ? "tv" : "movie", title: "x", year: null, source: "chosen", }; const q = await queueScrape(h.module.deps().ao, { ...(inRoot ? { rootId: h.dlRoot, rootPath: h.dl, libraryRootId: null } : { rootId: h.libRoot, rootPath: h.lib, libraryRootId: h.libraryRootId }), path: inRoot ? mp.target : `${scenario}/${mp.target}`, match, mode: "overwrite", trigger: "manual", }); const rec = await runScrapeRecord(h.module.deps().ao, q.recordId); expect(rec.status).toBe("succeeded"); const after = await files(base); const ours = [...after].filter((f) => !before.has(f)).sort(); const expected = DEVIATIONS.reduce((fs, d) => d.apply(fs, scenario), [...mp.written]).sort(); expect(ours).toEqual(expected); }); } });