import { readFileSync } from "node:fs"; import { fetchRichDetail, fetchRichSeason } from "@umt/adapters"; import { episodeNfo, multiEpisodeNfo, planScrapeTargets, seasonNfo, seasonPosterName, workNfo } from "@umt/domain"; import { describe, expect, it } from "vitest"; import { nfoEpisode, nfoSeason, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts"; import { richEntityFields } from "../../src/modules/sources-identity/service/metadata-rich.ts"; // SCR-1 NFO 差分:同一份 TMDB 原始响应(tmdb-raw.json),MP 走 set_tmdb_info + TmdbScraper(gen-scrape-mp.py → scrape-mp.json), // 我们走 fetchRichDetail / fetchRichSeason → 字段投影(metadata)→ scrape-input → domain NFO 生成器。 // 双方去掉空元素后逐行比较,已登记的差异(nfo-deviations.json)按元素排除并要求确实不同。 const read = (f: string) => JSON.parse(readFileSync(new URL(f, import.meta.url), "utf8")); const raw = read("./tmdb-raw.json") as Record; const mp = read("./scrape-mp.json") as Record; const devFile = read("./nfo-deviations.json"); const devs = devFile.deviations as { case: string; tag: string }[]; const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; const client = { async getJson(path: string): Promise { const r = raw[path]; if (!r) throw new Error(`fixture 缺 ${path}`); return structuredClone(r) as T; }, }; const final = (fields: Record) => Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v.value }])); async function ours(): Promise> { const out: Record = {}; const movie = await fetchRichDetail(client, "movie", 157336, LANGS); out.movie = workNfo( nfoWork( { kind: "movie", title: movie.title, original_title: movie.originalTitle, year: movie.year, metadata: final(richEntityFields(movie) as never), }, { tmdb: "157336" }, ), ); const tv = await fetchRichDetail(client, "tv", 95396, LANGS); out.tvshow = workNfo( nfoWork( { kind: "series", title: tv.title, original_title: tv.originalTitle, year: tv.year, metadata: final(richEntityFields(tv) as never), }, { tmdb: "95396" }, ), ); const sd = await fetchRichSeason(client, 95396, 1, LANGS, { now: new Date("2026-01-01") }); out.season1 = seasonNfo( nfoSeason(1, { season_number: 1, episode_number: null, title: sd.name, air_date: sd.airDate, metadata: { name: { value: sd.name }, overview: { value: sd.overview }, airDate: { value: sd.airDate } }, }), ); const eps = sd.episodes.map((e) => nfoEpisode(1, e.episodeNumber, { season_number: 1, episode_number: e.episodeNumber, title: e.name, air_date: e.airDate, metadata: { name: { value: e.name }, overview: { value: e.overview }, airDate: { value: e.airDate }, runtime: { value: e.runtime }, voteAverage: { value: e.voteAverage }, }, }), ); for (const e of eps) out[`s01e${String(e.episode).padStart(2, "0")}`] = episodeNfo(e); out["s01e01-e02"] = multiEpisodeNfo(eps.slice(0, 2)); return out; } /** 去空元素(MP 缺值写 、空 CDATA、评分 0) */ const normalize = (xml: string) => xml .split("\n") .filter((l) => l.trim() !== "") .filter((l) => !/^\s*<[\w]+(\s[^>]*)?\/>$/.test(l)) .filter((l) => !//.test(l)) .filter((l) => !/^\s*0<\/rating>$/.test(l)); const tagOf = (l: string) => /^ {2}<(\w+)/.exec(l)?.[1] ?? null; describe("NFO 与 MP 差分", async () => { const mine = await ours(); const cases = ["movie", "tvshow", "season1", "s01e01", "s01e02"]; it.each(cases)("%s:结构与取值一致(已登记差异除外)", (c) => { const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag)); const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "")); const b = normalize(mine[c]!).filter((l) => !skip.has(tagOf(l) ?? "")); expect(b).toEqual(a); for (const tag of skip) { const ma = normalize(mp[c]!).filter((l) => tagOf(l) === tag); const mb = normalize(mine[c]!).filter((l) => tagOf(l) === tag); expect(mb, `登记的差异 ${c}.${tag} 已消失,删掉登记`).not.toEqual(ma); } }); it("多集文件:按登记的偏离写多个 块,每块与 MP 对应单集一致(MP 只写首集)", () => { expect(devFile.multiEpisode?.reason).toBeTruthy(); const xml = mine["s01e01-e02"]!; const [decl, ...body] = xml.split("\n"); expect(decl).toBe(mp.s01e01!.split("\n")[0]); const blocks = body .join("\n") .split(/(?=)/) .filter((b) => b.trim()); expect(blocks).toHaveLength(2); for (const [i, c] of ["s01e01", "s01e02"].entries()) { const skip = new Set(devs.filter((d) => d.case === c).map((d) => d.tag)); const a = normalize(mp[c]!).filter((l) => !skip.has(tagOf(l) ?? "") && !l.startsWith(" !skip.has(tagOf(l) ?? "")); expect(b).toEqual(a); } }); it("编码与声明:UTF-8、两空格缩进、末尾换行,和 MP 一致", () => { for (const c of cases) { expect(mine[c]!.split("\n")[0]).toBe(mp[c]!.split("\n")[0]); expect(mine[c]!.endsWith("\n")).toBe(true); } }); it("季海报命名与 MP get_season_poster 一致", () => { expect(seasonPosterName(1)).toBe(mp.seasonPosterName); expect(seasonPosterName(0)).toBe(mp.seasonSpecialsPosterName); }); it("文件命名:对照 MP scraping.py 的目标路径(差异见 nfo-deviations.json images)", () => { const t = planScrapeTargets({ kind: "tv", workDir: "人生切割术 (2022)", videos: [{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 }], images: { posterPath: "/p.jpg", backdropPath: "/b.jpg", seasonPosters: new Map([[1, "/s.jpg"]]), stills: new Map([["1:1", "/e.jpg"]]), }, options: { images: true, episodeThumbs: true }, }); // MP:tvshow.nfo(TV)、season.nfo(SEASON)、.nfo(EPISODE);季海报根目录 seasonXX-poster + 季目录 poster expect(t.map((x) => x.path)).toEqual([ "人生切割术 (2022)/tvshow.nfo", "人生切割术 (2022)/poster.jpg", "人生切割术 (2022)/fanart.jpg", "人生切割术 (2022)/Season 1/season.nfo", "人生切割术 (2022)/season01-poster.jpg", "人生切割术 (2022)/Season 1/poster.jpg", "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.nfo", "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集-thumb.jpg", ]); }); });