Files
UHD_Media_Tool/packages/domain/test/scrape.test.ts
T
rootandClaude Opus 5.5 680419bb64 feat(scrape): TMDB 标志写 clearlogo.png
- 富详情请求加 images(include_image_language=zh,en,null),作品原语言不是中英文时另取 /images;
  选一张 PNG 标志(原语言 → 中文 → 英文 → 无文字,同语言按评分)存作品字段 logoPath,变化时触发重刮
- 刮削布局新角色 clearlogo:电影写影片目录 clearlogo.png、剧集写剧集根目录,库根 / 平铺电影 <视频名>-clearlogo.png;
  覆盖方式与 hash 规则同其它图片
- 布局金样差分登记偏离 tmdb_clearlogo;mock TMDB 支持 logos 与 /images

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 19:37:34 +08:00

347 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
episodeNfo,
inferWorkDir,
isSeasonDirName,
multiEpisodeNfo,
planScrapeTargets,
scrapeKindOf,
seasonNfo,
seasonOfDirName,
seasonPosterName,
workNfo,
} from "../src/index.ts";
describe("NFO 生成", () => {
it("电影:MP 元素顺序,缺值不写,IMDb 为 default", () => {
const xml = workNfo({
kind: "movie",
title: "星际穿越",
originalTitle: "Interstellar",
year: 2014,
premiered: "2014-11-05",
overview: "穿越虫洞。",
rating: 8.46,
genres: ["科幻", "剧情", "科幻"],
countries: ["美国"],
runtime: 169,
directors: [{ tmdbId: 525, name: "克里斯托弗·诺兰" }],
actors: [{ tmdbId: 10297, name: "马修·麦康纳", role: "Cooper", profilePath: "/mm.jpg" }],
ids: { tmdb: "157336", imdb: "tt0816692" },
});
expect(xml).toBe(
[
'<?xml version="1.0" encoding="utf-8"?>',
"<movie>",
" <tmdbid>157336</tmdbid>",
' <uniqueid type="tmdb" default="false">157336</uniqueid>',
" <imdbid>tt0816692</imdbid>",
' <uniqueid type="imdb" default="true">tt0816692</uniqueid>',
" <plot><![CDATA[穿越虫洞。]]></plot>",
" <outline><![CDATA[穿越虫洞。]]></outline>",
' <director tmdbid="525">克里斯托弗·诺兰</director>',
" <actor>",
" <name>马修·麦康纳</name>",
" <type>Actor</type>",
" <role>Cooper</role>",
" <tmdbid>10297</tmdbid>",
" <thumb>https://image.tmdb.org/t/p/original/mm.jpg</thumb>",
" <profile>https://www.themoviedb.org/person/10297</profile>",
" </actor>",
" <genre>科幻</genre>",
" <genre>剧情</genre>",
" <rating>8.5</rating>",
" <country>美国</country>",
" <runtime>169</runtime>",
" <title>星际穿越</title>",
" <originaltitle>Interstellar</originaltitle>",
" <premiered>2014-11-05</premiered>",
" <year>2014</year>",
"</movie>",
"",
].join("\n"),
);
});
it("剧集 tvshow.nfo:season/episode = -1,豆瓣 id、标语、平台", () => {
const xml = workNfo({
kind: "tvshow",
title: "人生切割术",
premiered: "2022-02-17",
tagline: "工作与生活,彻底分开",
studios: ["Apple TV+"],
ids: { tmdb: "95396", tvdb: "371980", douban: "30448896" },
});
expect(xml).toContain('<uniqueid type="tmdb" default="true">95396</uniqueid>');
expect(xml).toContain('<uniqueid type="tvdb">371980</uniqueid>');
expect(xml).toContain('<uniqueid type="douban">30448896</uniqueid>');
expect(xml).toContain("<studio>Apple TV+</studio>");
expect(xml).toMatch(/<year>2022<\/year>\n {2}<season>-1<\/season>\n {2}<episode>-1<\/episode>\n {2}<tagline>/);
expect(xml).not.toContain("<plot>");
expect(xml).not.toContain("<rating>");
});
it("转义与 CDATA:& < > 转义,]]> 拆段,控制字符删除", () => {
const xml = workNfo({
kind: "movie",
title: 'A & <B> "c"\u0001',
overview: "x]]>y",
ids: { tmdb: "1" },
});
expect(xml).toContain('<title>A &amp; &lt;B&gt; "c"</title>');
expect(xml).toContain("<plot><![CDATA[x]]]]><![CDATA[>y]]></plot>");
});
it("季与集", () => {
expect(seasonNfo({ season: 1, title: "第 1 季", airDate: "2022-02-17" })).toBe(
[
'<?xml version="1.0" encoding="utf-8"?>',
"<season>",
" <title>第 1 季</title>",
" <premiered>2022-02-17</premiered>",
" <releasedate>2022-02-17</releasedate>",
" <year>2022</year>",
" <seasonnumber>1</seasonnumber>",
"</season>",
"",
].join("\n"),
);
const ep = episodeNfo({ season: 1, episode: 2, overview: "参观。", airDate: "2022-02-18", rating: 8, runtime: 53 });
expect(ep).toContain("<title>第 2 集</title>");
expect(ep).toContain("<rating>8.0</rating>");
expect(ep).toMatch(/<season>1<\/season>\n {2}<episode>2<\/episode>/);
expect(ep).toContain("<runtime>53</runtime>");
});
});
describe("刮削目标布局", () => {
const images = {
posterPath: "/p.jpg",
backdropPath: "/b.png",
seasonPosters: new Map([[1, "/s1.jpg"]]),
stills: new Map([
["1:1", "/e1.jpg"],
["1:2", null],
]),
};
it("作品目录推断", () => {
expect(inferWorkDir("tv", "人生切割术 (2022)/Season 1/a.mkv")).toBe("人生切割术 (2022)");
expect(inferWorkDir("tv", "剧集/人生切割术 (2022)/S01/a.mkv")).toBe("剧集/人生切割术 (2022)");
expect(inferWorkDir("movie", "星际穿越 (2014)/a.mkv")).toBe("星际穿越 (2014)");
expect(inferWorkDir("movie", "a.mkv")).toBeNull();
expect(seasonPosterName(0)).toBe("season-specials-poster.jpg");
});
it("剧集:tvshow / 季 / 集,季海报写两处,缺剧照不写", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos: [
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.mkv", season: 1, episode: 2 },
{ path: "人生切割术 (2022)/Season 1/人生切割术 - S01E01 - 第 1 集.mkv", season: 1, episode: 1 },
],
images,
options: { images: true, episodeThumbs: true },
});
expect(t.map((x) => x.path)).toEqual([
"人生切割术 (2022)/tvshow.nfo",
"人生切割术 (2022)/poster.jpg",
"人生切割术 (2022)/fanart.png",
"人生切割术 (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",
"人生切割术 (2022)/Season 1/人生切割术 - S01E02 - 第 2 集.nfo",
]);
});
it("关图片 / 关剧照", () => {
const base = {
kind: "tv" as const,
workDir: "S",
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
images,
};
expect(planScrapeTargets({ ...base, options: { images: false, episodeThumbs: true } }).map((x) => x.role)).toEqual([
"tvshow_nfo",
"season_nfo",
"episode_nfo",
]);
expect(
planScrapeTargets({ ...base, options: { images: true, episodeThumbs: false } }).some(
(x) => x.role === "episode_thumb",
),
).toBe(false);
});
it("电影:作品目录里写 poster/fanart;在库根时用 <视频名>-poster", () => {
const inDir = planScrapeTargets({
kind: "movie",
workDir: "星际穿越 (2014)",
videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }],
images,
options: { images: true, episodeThumbs: true },
});
expect(inDir.map((x) => x.path)).toEqual([
"星际穿越 (2014)/星际穿越 (2014) - 1080p.nfo",
"星际穿越 (2014)/poster.jpg",
"星际穿越 (2014)/fanart.png",
]);
const atRoot = planScrapeTargets({
kind: "movie",
workDir: null,
videos: [{ path: "x.mkv" }],
images,
options: { images: true, episodeThumbs: true },
});
expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png"]);
});
it("标志 clearlogo.png:电影写影片目录、剧集写剧集根目录;库根 / 平铺的电影用 <视频名>-clearlogo.png;选中季 / 集不写", () => {
const withLogo = { ...images, logoPath: "/logo.png" };
const movie = planScrapeTargets({
kind: "movie",
workDir: "星际穿越 (2014)",
videos: [{ path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv" }],
images: withLogo,
options: { images: true, episodeThumbs: true },
});
expect(movie.filter((x) => x.role === "clearlogo").map((x) => [x.path, x.imagePath])).toEqual([
["星际穿越 (2014)/clearlogo.png", "/logo.png"],
]);
const atRoot = planScrapeTargets({
kind: "movie",
workDir: null,
videos: [{ path: "x.mkv" }],
images: withLogo,
options: { images: true, episodeThumbs: true },
});
expect(atRoot.map((x) => x.path)).toEqual(["x.nfo", "x-poster.jpg", "x-fanart.png", "x-clearlogo.png"]);
const tv = planScrapeTargets({
kind: "tv",
workDir: "S",
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
images: withLogo,
options: { images: true, episodeThumbs: true },
});
expect(tv.slice(0, 4).map((x) => x.path)).toEqual([
"S/tvshow.nfo",
"S/poster.jpg",
"S/fanart.png",
"S/clearlogo.png",
]);
const seasonOnly = planScrapeTargets({
kind: "tv",
workDir: "S",
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
images: withLogo,
options: { images: true, episodeThumbs: true },
parts: { work: false },
});
expect(seasonOnly.some((x) => x.role === "clearlogo")).toBe(false);
// 关图片不写;没有标志不写
const noImages = planScrapeTargets({
kind: "tv",
workDir: "S",
videos: [{ path: "S/Season 1/a.mkv", season: 1, episode: 1 }],
images: withLogo,
options: { images: false, episodeThumbs: false },
});
expect(noImages.some((x) => x.role === "clearlogo")).toBe(false);
expect(movie.length).toBe(4);
});
it("剧集视频在库根:不写 tvshow / 季文件,只写集 NFO", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: null,
videos: [{ path: "a.S01E01.mkv", season: 1, episode: 1 }],
images,
options: { images: true, episodeThumbs: true },
});
expect(t.map((x) => x.path)).toEqual(["a.S01E01.nfo", "a.S01E01-thumb.jpg"]);
});
it("SCR-2 季目录名:Season 1 / S01 / Specials / SPs / 第 1 季", () => {
for (const n of ["Season 1", "season 12", "S01", "Specials", "SPs", "特别篇", "第 2 季"])
expect(isSeasonDirName(n)).toBe(true);
for (const n of ["人生切割术 (2022)", "Extras", "Season"]) expect(isSeasonDirName(n)).toBe(false);
expect(seasonOfDirName("Season 3")).toBe(3);
expect(seasonOfDirName("S01")).toBe(1);
expect(seasonOfDirName("Specials")).toBe(0);
expect(seasonOfDirName("第 2 季")).toBe(2);
expect(seasonOfDirName("Extras")).toBeNull();
});
it("SCR-2 范围:选中季目录只写季级与各集(季海报仍写上级),选中单集只写该集", () => {
const videos = [{ path: "人生切割术 (2022)/Season 1/a.S01E01.mkv", season: 1, episode: 1 }];
const season = planScrapeTargets({
kind: "tv",
workDir: "人生切割术 (2022)",
videos,
images,
options: { images: true, episodeThumbs: true },
parts: { work: false },
});
expect(season.map((x) => `${x.role}:${x.path}`)).toEqual([
"season_nfo:人生切割术 (2022)/Season 1/season.nfo",
"season_poster:人生切割术 (2022)/season01-poster.jpg",
"season_poster:人生切割术 (2022)/Season 1/poster.jpg",
"episode_nfo:人生切割术 (2022)/Season 1/a.S01E01.nfo",
"episode_thumb:人生切割术 (2022)/Season 1/a.S01E01-thumb.jpg",
]);
const ep = planScrapeTargets({
kind: "tv",
workDir: null,
videos,
images,
options: { images: true, episodeThumbs: true },
parts: { work: false, season: false },
});
expect(ep.map((x) => x.role)).toEqual(["episode_nfo", "episode_thumb"]);
});
});
describe("作品类型 → 刮削类型", () => {
it("剧集 / 综艺按剧集,电影 / 合集 / 其他按电影", () => {
expect(["series", "variety", "tv"].map(scrapeKindOf)).toEqual(["tv", "tv", "tv"]);
expect(["movie", "collection", "other"].map(scrapeKindOf)).toEqual(["movie", "movie", "movie"]);
});
});
describe("多集文件", () => {
it("集 NFO 每集一个 <episodedetails> 块,只有一个 XML 声明;一集时与 episodeNfo 相同", () => {
const xml = multiEpisodeNfo([
{ season: 1, episode: 1, title: "上", airDate: "2022-02-17" },
{ season: 1, episode: 2, title: "下", airDate: "2022-02-17" },
]);
expect(xml.match(/<\?xml /g)).toHaveLength(1);
expect(xml.match(/<episodedetails>/g)).toHaveLength(2);
expect(xml).toContain(" <title>上</title>\n <aired>2022-02-17</aired>");
expect(xml.indexOf("<episode>1</episode>")).toBeLessThan(xml.indexOf("<episode>2</episode>"));
const one = { season: 1, episode: 3, title: "单集" };
expect(multiEpisodeNfo([one])).toBe(episodeNfo(one));
});
it("布局:多集文件只写一个 NFO(按视频名)与首集剧照,目标带全部集号", () => {
const t = planScrapeTargets({
kind: "tv",
workDir: "剧 (2020)",
videos: [{ path: "剧 (2020)/Season 1/剧 - S01E01-E02.mkv", season: 1, episode: 1, episodes: [2, 1] }],
images: { stills: new Map([["1:1", "/e1.jpg"]]) },
options: { images: true, episodeThumbs: true },
parts: { work: false, season: false },
});
expect(t).toEqual([
expect.objectContaining({
role: "episode_nfo",
path: "剧 (2020)/Season 1/剧 - S01E01-E02.nfo",
episodes: [1, 2],
}),
expect.objectContaining({ role: "episode_thumb", path: "剧 (2020)/Season 1/剧 - S01E01-E02-thumb.jpg" }),
]);
});
});