Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
341 lines
15 KiB
TypeScript
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" });
|
|
});
|
|
});
|