Files
UHD_Media_Tool/apps/server/test/acquisition-organize/scrape-accuracy.test.ts
T

341 lines
15 KiB
TypeScript

import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises";
import type { TmdbRichSeasonDetail } from "@umt/adapters";
import type { ScrapeFileResult, ScrapeMatch } from "@umt/contracts";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
// 刮削准确度:
// - 「更新」模式(缺省):不存在的写入;本工具上次写入且没被改过的按新内容覆盖(内容没变不重写);被人改过的跳过
// (scrape_user_modified);不是本工具写的不动;旧版本没有 hash 的记录按修改时间兜底;
// - 集 / 季缺元数据:先按 TMDB id 现取季详情补齐;取不到跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO;
// - 合集 / 其他类作品按电影刮,NFO 根元素是 <movie>;
// - 兜底异常记 scrape_internal_error。
const hasPg = await pgAvailable();
const at = new Date().toISOString();
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
const enc = (s: string) => new TextEncoder().encode(s);
function season1(names: Record<number, string>): TmdbRichSeasonDetail {
return {
tvId: "95396",
seasonNumber: 1,
name: "第 1 季",
nameLanguage: "zh-CN",
overview: null,
overviewLanguage: null,
airDate: "2022-02-17",
posterPath: null,
fetchedLanguages: ["zh-CN"],
episodes: Object.entries(names).map(([n, name]) => ({
seasonNumber: 1,
episodeNumber: Number(n),
name,
nameLanguage: "zh-CN",
overview: `${name}的简介`,
overviewLanguage: "zh-CN",
airDate: "2022-02-25",
runtime: 50,
stillPath: null,
voteAverage: null,
voteCount: null,
})),
};
}
describe.skipIf(!hasPg)("刮削准确度:更新模式、缺元数据不写占位、作品类型", () => {
let h: Harness;
let lib: TempTree;
let rootId = "";
let seasonDetail: (n: number) => Promise<TmdbRichSeasonDetail | null> = async () => null;
const seasonCalls: number[] = [];
beforeAll(async () => {
lib = await createTempTree(
{
"Movies/星际穿越 (2014)/Interstellar.2014.mkv": 100,
"Movies/星际穿越 (2014)/fanart.jpg": "别的工具放的背景图",
"TV/人生切割术 (2022)/Season 1/Severance.S01E01.mkv": 100,
"TV/人生切割术 (2022)/Season 1/Severance.S01E02.mkv": 100,
"Movies/合集 (2020)/Collection.2020.mkv": 100,
"Movies/其他 (2021)/Other.2021.mkv": 100,
},
{ sentinel: true, prefix: "umt-scracc-" },
);
h = await createHarness("scrape_acc", {
scrapeImage: async (p) => ({ content: enc(p), mime: "image/jpeg" }),
scrapeTmdbSeason: async (_tv, n) => {
seasonCalls.push(n);
return seasonDetail(n);
},
});
rootId = (await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" })).rootId;
}, 60_000);
afterAll(async () => {
await h?.close();
await lib?.cleanup();
});
async function entity(kind: "movie" | "series" | "collection" | "other", title: string, meta: object) {
return (
await h.db
.insertInto("media_entity")
.values({ kind, title, year: 2020, status: "active", metadata: JSON.stringify(meta) })
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
async function scrape(
entityId: string,
kind: "movie" | "tv",
path: string,
mode?: "missing" | "overwrite" | "update",
externalId: string | null = null,
) {
const match: ScrapeMatch = {
mediaEntityId: entityId,
provider: externalId ? "tmdb" : null,
externalId,
kind,
title: "x",
year: null,
source: "library",
};
const q = await queueScrape(h.d, {
rootId: `lib:${rootId}`,
rootPath: lib.root,
libraryRootId: rootId,
path,
match,
mode,
trigger: "manual",
});
return runScrapeRecord(h.d, q.recordId);
}
const fileOf = (files: ScrapeFileResult[], role: string) => files.find((x) => x.role === role);
const setTitle = (id: string, title: string) =>
h.db
.updateTable("media_entity")
.set({ metadata: JSON.stringify({ title: f(title), posterPath: f("/inter.jpg") }) })
.where("id", "=", id)
.execute();
const MOVIE_DIR = "Movies/星际穿越 (2014)";
const MOVIE_NFO = `${MOVIE_DIR}/Interstellar.2014.nfo`;
let movie = "";
it("更新模式:不存在的写入并记 hash;不是本工具写的不动", async () => {
movie = await entity("movie", "星际穿越", { title: f("星际穿越"), posterPath: f("/inter.jpg") });
const r = await scrape(movie, "movie", MOVIE_DIR);
expect(r).toMatchObject({ status: "succeeded", mode: "update" });
expect(fileOf(r.files, "movie_nfo")).toMatchObject({
action: "written",
hash: expect.stringMatching(/^[0-9a-f]{64}$/),
});
expect(fileOf(r.files, "poster")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越</title>");
// fanart.jpg 是别的工具放的:元数据没有背景图,不在目标里;加上背景图后更新模式也不覆盖它
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({ title: f("星际穿越"), posterPath: f("/inter.jpg"), backdropPath: f("/bg.jpg") }),
})
.where("id", "=", movie)
.execute();
const again = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(again.files, "fanart")).toMatchObject({ action: "skipped", reason: "scrape_exists" });
expect(await readFile(lib.path(`${MOVIE_DIR}/fanart.jpg`), "utf8")).toBe("别的工具放的背景图");
// 内容没变的不重写
expect(fileOf(again.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
expect(fileOf(again.files, "poster")).toMatchObject({ action: "skipped", reason: "scrape_unchanged" });
expect(again.status).toBe("succeeded");
});
it("更新模式:本工具写的、没被改过的按新内容覆盖;被人改过的跳过并记原因", async () => {
await setTitle(movie, "星际穿越(新译名)");
const upd = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(upd.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(新译名)</title>");
await writeFile(lib.path(MOVIE_NFO), "<movie><title>我手改的</title></movie>");
await setTitle(movie, "星际穿越(再改)");
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toBe("<movie><title>我手改的</title></movie>");
expect(kept.status).toBe("succeeded");
// 之后每次更新都还认得出是人改过的(跳过的结果不带 hash,不会把人改的内容当成本工具写的)
const still = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(still.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
// 全部覆盖照旧改写
const over = await scrape(movie, "movie", MOVIE_DIR, "overwrite");
expect(fileOf(over.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(再改)</title>");
});
it("更新模式:旧版本记录没有 hash 时按修改时间判断", async () => {
// 模拟旧版本:去掉所有记录里的 hash
const rows = await h.db.selectFrom("scrape_record").select(["id", "files", "finished_at"]).execute();
for (const r of rows)
await h.db
.updateTable("scrape_record")
.set({
files: JSON.stringify((r.files as ScrapeFileResult[]).map(({ hash: _h, ...x }) => x)),
})
.where("id", "=", r.id)
.execute();
const last = rows
.filter((r) => r.finished_at)
.sort((a, b) => +new Date(b.finished_at!) - +new Date(a.finished_at!))[0]!;
const past = new Date(+new Date(last.finished_at!) - 60_000);
await utimes(lib.path(MOVIE_NFO), past, past);
await setTitle(movie, "星际穿越(旧记录)");
const r = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(r.files, "movie_nfo")).toMatchObject({ action: "written" });
expect(await readFile(lib.path(MOVIE_NFO), "utf8")).toContain("<title>星际穿越(旧记录)</title>");
// 改动时间晚于那次刮削:当作人改过
const rows2 = await h.db.selectFrom("scrape_record").select(["id", "files"]).execute();
for (const x of rows2)
await h.db
.updateTable("scrape_record")
.set({ files: JSON.stringify((x.files as ScrapeFileResult[]).map(({ hash: _h, ...y }) => y)) })
.where("id", "=", x.id)
.execute();
const future = new Date(Date.now() + 60_000);
await utimes(lib.path(MOVIE_NFO), future, future);
expect((await stat(lib.path(MOVIE_NFO))).mtimeMs).toBeGreaterThan(Date.now());
await setTitle(movie, "星际穿越(不该写进去)");
const kept = await scrape(movie, "movie", MOVIE_DIR, "update");
expect(fileOf(kept.files, "movie_nfo")).toMatchObject({ action: "skipped", reason: "scrape_user_modified" });
});
const SHOW = "TV/人生切割术 (2022)";
let series = "";
it("集单元没有元数据:按 TMDB id 现取季详情补齐,写出正确的集 NFO", async () => {
series = await entity("series", "人生切割术", {
title: f("人生切割术"),
overview: f("马克领导着一个办公室团队。"),
});
const season = await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
kind: "season",
season_number: 1,
status: "confirmed",
title: "第 1 季",
metadata: JSON.stringify({ name: f("第 1 季") }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: 1,
status: "confirmed",
title: "有关地狱的好消息",
metadata: JSON.stringify({ name: f("有关地狱的好消息"), overview: f("第一集简介") }),
})
.execute();
// 第 2 集是整理懒建的空单元
await h.db
.insertInto("content_unit")
.values({
media_entity_id: series,
parent_unit_id: season.id,
kind: "episode",
season_number: 1,
episode_number: 2,
status: "provisional",
})
.execute();
seasonDetail = async (n) => (n === 1 ? season1({ 1: "TMDB 第一集", 2: "半循环" }) : null);
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(seasonCalls).toEqual([1]);
expect(r.status).toBe("succeeded");
const ep2 = await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E02.nfo`), "utf8");
expect(ep2).toContain("<title>半循环</title>");
expect(ep2).toContain("<plot><![CDATA[半循环的简介]]></plot>");
// 本地已有的集字段不被 TMDB 现取的值替换
expect(await readFile(lib.path(`${SHOW}/Season 1/Severance.S01E01.nfo`), "utf8")).toContain(
"<title>有关地狱的好消息</title>",
);
});
it("TMDB 季详情失败:缺元数据的集跳过(scrape_metadata_missing)、记录 partial,不写占位 NFO", async () => {
await writeFile(lib.path(`${SHOW}/Season 1/Severance.S01E03.mkv`), "video");
seasonDetail = async () => {
throw new Error("TMDB 超时");
};
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(r).toMatchObject({ status: "partial", reason: "scrape_metadata_missing" });
expect(r.files.find((x) => x.path.endsWith("S01E03.nfo"))).toMatchObject({
action: "skipped",
reason: "scrape_metadata_missing",
});
await expect(stat(lib.path(`${SHOW}/Season 1/Severance.S01E03.nfo`))).rejects.toThrow();
// 季单元也没有元数据时同样不写「季 N」占位
await h.db
.updateTable("content_unit")
.set({ title: null, metadata: JSON.stringify({}) })
.where("media_entity_id", "=", series)
.where("kind", "=", "season")
.execute();
const r2 = await scrape(series, "tv", SHOW, "overwrite", "95396");
expect(fileOf(r2.files, "season_nfo")).toMatchObject({ action: "skipped", reason: "scrape_metadata_missing" });
expect(r2.status).toBe("partial");
});
it("没有 TMDB id 的作品(Local)季 / 集缺元数据时照旧写占位", async () => {
const local = await entity("series", "本地剧", { title: f("本地剧") });
const dir = "TV/本地剧 (2020)";
await mkdir(lib.path(`${dir}/Season 1`), { recursive: true });
await writeFile(lib.path(`${dir}/Season 1/Local.S01E01.mkv`), "video");
const calls = seasonCalls.length;
const r = await scrape(local, "tv", dir, "update", null);
expect(seasonCalls.length).toBe(calls);
expect(r.files.find((x) => x.path.endsWith("S01E01.nfo"))).toMatchObject({ action: "written" });
expect(await readFile(lib.path(`${dir}/Season 1/Local.S01E01.nfo`), "utf8")).toContain("第 1 集");
expect(r.files.some((x) => x.reason === "scrape_metadata_missing")).toBe(false);
});
it("合集 / 其他类作品按电影刮削,NFO 根元素是 <movie>", async () => {
expect(nfoWork({ kind: "collection", title: "x", original_title: null, year: null, metadata: {} }, {}).kind).toBe(
"movie",
);
for (const [kind, dir, nfo] of [
["collection", "Movies/合集 (2020)", "Collection.2020.nfo"],
["other", "Movies/其他 (2021)", "Other.2021.nfo"],
] as const) {
const id = await entity(kind, `${kind} 作品`, { title: f(`${kind} 作品`) });
const r = await scrape(id, "movie", dir);
expect(r.status).toBe("succeeded");
const xml = await readFile(lib.path(`${dir}/${nfo}`), "utf8");
expect(xml).toContain("\n<movie>");
expect(xml).not.toContain("<tvshow>");
}
});
it("兜底异常记 scrape_internal_error(不是写文件失败)", async () => {
h.d.ports.scrapeTmdbSeason = (() => {
throw new Error("意外错误");
}) as never;
await h.db
.updateTable("content_unit")
.set({ title: null, metadata: JSON.stringify({}) })
.where("media_entity_id", "=", series)
.execute();
const r = await scrape(series, "tv", SHOW, "update", "95396");
expect(r).toMatchObject({ status: "failed", reason: "scrape_internal_error" });
});
});