- 每次写入在 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
258 lines
9.6 KiB
TypeScript
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 & <B> "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"]);
|
|
});
|
|
});
|