Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
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)),
|
|
},
|
|
// 4. 作品级标志 clearlogo.png(TMDB logos;MP 只在接 fanart.tv 时写 logo,我们不接 fanart.tv):
|
|
// 写在作品海报旁边——电影影片目录 / 剧集根目录 clearlogo.png,库根的电影 <视频名>-clearlogo.png;季目录不写
|
|
{
|
|
id: "tmdb_clearlogo",
|
|
apply: (fs) => [
|
|
...fs,
|
|
...fs.flatMap((f) => {
|
|
const m = /^(.*?)(\/|^|-)poster\.jpg$/.exec(f);
|
|
if (!m || /(^|\/)Season \d+$/.test(m[1]!) || /(^|\/)season(\d+|-specials)$/.test(m[1]!)) return [];
|
|
return [`${m[1]}${m[2]}clearlogo.png`];
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
|
|
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,
|
|
taglineLanguage: 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",
|
|
logoPath: "/logo.png",
|
|
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<Set<string>> {
|
|
const out = new Set<string>();
|
|
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 (p) =>
|
|
p.endsWith(".png")
|
|
? { content: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1]), mime: "image/png" }
|
|
: { 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);
|
|
});
|
|
}
|
|
});
|