Files
UHD_Media_Tool/packages/domain/test/scrape.test.ts
T
rootandClaude Opus 5.5 dbbf39eda4 feat(scrape): 覆盖方式加「更新」并设为缺省,季集缺元数据不写占位,作品类型判定统一
- 每次写入在 scrape_record.files[].hash 记 sha256;update 模式只覆盖本工具写过且没被改过的文件,
  被改过的跳过(scrape_user_modified),内容没变不重写(scrape_unchanged),旧记录无 hash 按修改时间兜底;
  迁移 0070 给 scrape_record.mode 加 update,设置缺省改为 update(旧值照旧有效)
- 季 / 集单元缺元数据时按 TMDB id 现取季详情补齐;仍缺跳过(scrape_metadata_missing)并记 partial,不写占位 NFO
- scrapeKindOf 统一作品类型判定,合集 / 其他类作品 NFO 根元素为 <movie>;TMDB 映射按作品类型过滤 entity_type
- 兜底异常记 scrape_internal_error 并保留已写文件结果
- 新增 scheduleEntityRescrape(刮削过的作品延后排 update 重刮,排队记录合批防抖)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 21:04:57 +08:00

258 lines
9.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
episodeNfo,
inferWorkDir,
isSeasonDirName,
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("剧集视频在库根:不写 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"]);
});
});