- 文件道任务 scrapeMedia:NFO 原子写入、图片经图片缓存下载(并发 4,失败不阻塞 NFO)、只补缺失 / 全部覆盖
- 设置 organize.scrapeAfterImport / scrapeMode / scrapeImages / scrapeEpisodeThumbs
- POST /library/{id}/scrape、详情 scrape 状态;files/scrape writeNfo 与 scrape-batch write 走同一任务
- 迁移 0057_media_scrape、原因码、审计 organize.scrape
- 与 MP 输出的 NFO 差分测试(apps/server/test/scrape)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { fetchRichDetail, fetchRichSeason } from "@umt/adapters";
|
|
import { episodeNfo, 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<string, unknown>;
|
|
const mp = read("./scrape-mp.json") as Record<string, string>;
|
|
const devs = read("./nfo-deviations.json").deviations as { case: string; tag: string }[];
|
|
|
|
const LANGS = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"];
|
|
const client = {
|
|
async getJson<T>(path: string): Promise<T> {
|
|
const r = raw[path];
|
|
if (!r) throw new Error(`fixture 缺 ${path}`);
|
|
return structuredClone(r) as T;
|
|
},
|
|
};
|
|
const final = (fields: Record<string, { value: unknown }>) =>
|
|
Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, { value: v.value }]));
|
|
|
|
async function ours(): Promise<Record<string, string>> {
|
|
const out: Record<string, string> = {};
|
|
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 } },
|
|
}),
|
|
);
|
|
for (const e of sd.episodes) {
|
|
out[`s01e${String(e.episodeNumber).padStart(2, "0")}`] = episodeNfo(
|
|
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 },
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 去空元素(MP 缺值写 <x/>、空 CDATA、评分 0) */
|
|
const normalize = (xml: string) =>
|
|
xml
|
|
.split("\n")
|
|
.filter((l) => l.trim() !== "")
|
|
.filter((l) => !/^\s*<[\w]+(\s[^>]*)?\/>$/.test(l))
|
|
.filter((l) => !/<!\[CDATA\[\]\]>/.test(l))
|
|
.filter((l) => !/^\s*<rating>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("编码与声明: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)、<stem>.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",
|
|
]);
|
|
});
|
|
});
|