- 作品重刮对电影传入该作品已入库的视频,不给同目录别的电影写本作品 NFO; - 电影目录里有别的作品的视频时海报 / 背景图写 <视频名>-poster / -fanart,不写目录共用的 poster.jpg; - 图片扩展名按下载到的 MIME,URL 认不出或与内容不符时以内容为准; - 任务被取消时记录收尾为失败 scrape_canceled,保留已写文件结果。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { readFile, stat } 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, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedBase } from "./harness.ts";
|
|
|
|
// 刮削的集与布局:
|
|
// - 多集文件(文件名 E01-E02 / 覆盖多个集单元)按 Kodi 约定在一个 NFO 里写多个 <episodedetails>;
|
|
// - 综艺按播出日期命名的期数按日期对上集(本地集单元或 TMDB 季详情)写分集 NFO;
|
|
// - 作品重刮只刮该作品的视频;同一目录里有别的作品时电影图片按 <视频名>-poster 命名;
|
|
// - 认不出集号的跳过算部分完成;图片扩展名按实际类型;取消时记录不停在进行中。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const at = new Date().toISOString();
|
|
const f = (value: unknown) => ({ value, source: "tmdb", language: "zh-CN", at, locked: false });
|
|
const enc = (s: string) => new TextEncoder().encode(s);
|
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
|
function seasonOf(n: number, eps: { ep: number; name: string; airDate: string }[]): TmdbRichSeasonDetail {
|
|
return {
|
|
tvId: "777",
|
|
seasonNumber: n,
|
|
name: `第 ${n} 季`,
|
|
nameLanguage: "zh-CN",
|
|
overview: null,
|
|
overviewLanguage: null,
|
|
airDate: eps[0]?.airDate ?? null,
|
|
posterPath: null,
|
|
fetchedLanguages: ["zh-CN"],
|
|
episodes: eps.map((e) => ({
|
|
seasonNumber: n,
|
|
episodeNumber: e.ep,
|
|
name: e.name,
|
|
nameLanguage: "zh-CN",
|
|
overview: `${e.name}简介`,
|
|
overviewLanguage: "zh-CN",
|
|
airDate: e.airDate,
|
|
runtime: null,
|
|
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;
|
|
let imageMime = "image/jpeg";
|
|
|
|
beforeAll(async () => {
|
|
lib = await createTempTree(
|
|
{
|
|
"TV/双集剧 (2020)/Season 1/Double.S01E01-E02.mkv": 100,
|
|
"TV/双集剧 (2020)/Season 1/Double.S01E03.mkv": 100,
|
|
"TV/综艺 (2024)/Season 1/综艺 - 2024-05-03.mkv": 100,
|
|
"TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.mkv": 100,
|
|
"TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.mkv": 100,
|
|
"Movies/盗梦空间 (2010).mkv": 100,
|
|
"Movies/星际穿越 (2014).mkv": 100,
|
|
"Single/阿凡达 (2009)/阿凡达 (2009).mkv": 100,
|
|
},
|
|
{ sentinel: true, prefix: "umt-screps-" },
|
|
);
|
|
h = await createHarness("scrape_eps", {
|
|
scrapeImage: async (p) => ({ content: imageMime === "image/png" ? PNG : enc(p), mime: imageMime }),
|
|
scrapeTmdbSeason: async (_tv, n) => 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: string, title: string, meta: object, tmdb?: { id: string; type: "movie" | "tv" }) {
|
|
const id = (
|
|
await h.db
|
|
.insertInto("media_entity")
|
|
.values({ kind: kind as never, title, year: 2020, status: "active", metadata: JSON.stringify(meta) })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
if (tmdb)
|
|
await h.db
|
|
.insertInto("external_identity")
|
|
.values({
|
|
media_entity_id: id,
|
|
provider: "tmdb",
|
|
entity_type: tmdb.type,
|
|
external_id: tmdb.id,
|
|
status: "accepted",
|
|
})
|
|
.execute();
|
|
return id;
|
|
}
|
|
async function unit(entityId: string, season: number, ep: number | null, meta: object, airDate?: string) {
|
|
return (
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: entityId,
|
|
kind: ep === null ? "season" : "episode",
|
|
season_number: season,
|
|
episode_number: ep,
|
|
status: "confirmed",
|
|
title: (meta as { name?: { value?: string } }).name?.value ?? null,
|
|
air_date: airDate ?? null,
|
|
metadata: JSON.stringify(meta),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id;
|
|
}
|
|
async function scrape(entityId: string | null, kind: "movie" | "tv", path: string, externalId: string | null = null) {
|
|
const match: ScrapeMatch = {
|
|
mediaEntityId: entityId,
|
|
provider: externalId ? "tmdb" : null,
|
|
externalId,
|
|
kind,
|
|
title: "x",
|
|
year: null,
|
|
source: entityId ? "library" : "chosen",
|
|
};
|
|
const q = await queueScrape(h.d, {
|
|
rootId: `lib:${rootId}`,
|
|
rootPath: lib.root,
|
|
libraryRootId: rootId,
|
|
path,
|
|
match,
|
|
mode: "overwrite",
|
|
trigger: "manual",
|
|
});
|
|
return runScrapeRecord(h.d, q.recordId);
|
|
}
|
|
const byPath = (files: ScrapeFileResult[], suffix: string) => files.find((x) => x.path.endsWith(suffix));
|
|
const count = (xml: string, re: RegExp) => xml.match(re)?.length ?? 0;
|
|
|
|
it("多集文件(文件名 E01-E02):一个 NFO 里每集一个 <episodedetails>,按集号顺序", async () => {
|
|
const id = await entity("series", "双集剧", { title: f("双集剧") }, { id: "500", type: "tv" });
|
|
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
|
for (const [ep, name] of [
|
|
[1, "开端"],
|
|
[2, "发展"],
|
|
[3, "结局"],
|
|
] as const)
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: id,
|
|
parent_unit_id: s,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: ep,
|
|
status: "confirmed",
|
|
title: name,
|
|
metadata: JSON.stringify({ name: f(name), overview: f(`${name}简介`) }),
|
|
})
|
|
.execute();
|
|
const r = await scrape(id, "tv", "TV/双集剧 (2020)", "500");
|
|
expect(r.status).toBe("succeeded");
|
|
expect(byPath(r.files, "S01E01-E02.nfo")).toMatchObject({ action: "written" });
|
|
const xml = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E01-E02.nfo"), "utf8");
|
|
expect(count(xml, /<\?xml /g)).toBe(1);
|
|
expect(count(xml, /<episodedetails>/g)).toBe(2);
|
|
expect(xml.indexOf("<title>开端</title>")).toBeLessThan(xml.indexOf("<title>发展</title>"));
|
|
const single = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E03.nfo"), "utf8");
|
|
expect(count(single, /<episodedetails>/g)).toBe(1);
|
|
});
|
|
|
|
it("多集文件(已登记、覆盖两个集单元):按覆盖单元写两块", async () => {
|
|
const id = await entity("series", "覆盖剧", { title: f("覆盖剧") }, { id: "501", type: "tv" });
|
|
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
|
const eps: string[] = [];
|
|
for (const [ep, name] of [
|
|
[1, "甲"],
|
|
[2, "乙"],
|
|
] as const)
|
|
eps.push(
|
|
(
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: id,
|
|
parent_unit_id: s,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: ep,
|
|
status: "confirmed",
|
|
title: name,
|
|
metadata: JSON.stringify({ name: f(name) }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow()
|
|
).id,
|
|
);
|
|
// 文件名没有集号区间,覆盖单元是 E01 + E02
|
|
const path = "TV/覆盖剧 (2020)/Season 1/覆盖剧 - S01E01.mkv";
|
|
await (await import("node:fs/promises")).mkdir(lib.path("TV/覆盖剧 (2020)/Season 1"), { recursive: true });
|
|
await (await import("node:fs/promises")).writeFile(lib.path(path), "video");
|
|
const asset = await h.db
|
|
.insertInto("media_asset")
|
|
.values({ media_entity_id: id, kind: "video", status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("asset_location")
|
|
.values({
|
|
media_asset_id: asset.id,
|
|
location_kind: "library",
|
|
library_root_id: rootId,
|
|
relative_path: path,
|
|
status: "available",
|
|
})
|
|
.execute();
|
|
for (const u of eps)
|
|
await h.db
|
|
.insertInto("asset_unit_coverage")
|
|
.values({ media_asset_id: asset.id, content_unit_id: u, coverage: "full" })
|
|
.execute();
|
|
const r = await scrape(id, "tv", "TV/覆盖剧 (2020)", "501");
|
|
expect(r.status).toBe("succeeded");
|
|
const xml = await readFile(lib.path(path.replace(/\.mkv$/, ".nfo")), "utf8");
|
|
expect(count(xml, /<episodedetails>/g)).toBe(2);
|
|
expect(xml).toContain("<title>乙</title>");
|
|
});
|
|
|
|
let variety = "";
|
|
it("综艺按日期命名:按集单元的播出日期对上集,写 <aired> 与集号;对不上的跳过", async () => {
|
|
const id = await entity("variety", "综艺", { title: f("综艺") }, { id: "777", type: "tv" });
|
|
variety = id;
|
|
const s = await unit(id, 1, null, { name: f("第 1 季") });
|
|
for (const [ep, name, day] of [
|
|
[1, "第一期", "2024-05-03"],
|
|
[2, "第二期", "2024-05-10"],
|
|
] as const)
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: id,
|
|
parent_unit_id: s,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: ep,
|
|
status: "confirmed",
|
|
title: name,
|
|
air_date: day,
|
|
metadata: JSON.stringify({ name: f(name), airDate: f(day) }),
|
|
})
|
|
.execute();
|
|
seasonDetail = async () => null;
|
|
const r = await scrape(id, "tv", "TV/综艺 (2024)", "777");
|
|
const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-05-10.nfo"), "utf8");
|
|
expect(nfo).toContain("<title>第二期</title>");
|
|
expect(nfo).toContain("<aired>2024-05-10</aired>");
|
|
expect(nfo).toContain("<episode>2</episode>");
|
|
expect(byPath(r.files, "2024-05-03.nfo")).toMatchObject({ action: "written" });
|
|
// 6 月 1 日这期季表里没有(TMDB 也取不到):跳过,算部分完成
|
|
expect(byPath(r.files, "2024-06-01.mkv")).toMatchObject({ action: "skipped", reason: "scrape_no_episode" });
|
|
expect(r.status).toBe("partial");
|
|
});
|
|
|
|
it("综艺按日期命名:本地没有这一期时按 TMDB 季详情的播出日期对集", async () => {
|
|
seasonDetail = async (n) =>
|
|
n === 1
|
|
? seasonOf(1, [
|
|
{ ep: 1, name: "第一期", airDate: "2024-05-03" },
|
|
{ ep: 2, name: "第二期", airDate: "2024-05-10" },
|
|
{ ep: 5, name: "六月特辑", airDate: "2024-06-01" },
|
|
])
|
|
: null;
|
|
const r = await scrape(variety, "tv", "TV/综艺 (2024)", "777");
|
|
expect(byPath(r.files, "2024-06-01.nfo")).toMatchObject({ action: "written" });
|
|
const nfo = await readFile(lib.path("TV/综艺 (2024)/Season 1/综艺 - 2024-06-01.nfo"), "utf8");
|
|
expect(nfo).toContain("<title>六月特辑</title>");
|
|
expect(nfo).toContain("<episode>5</episode>");
|
|
expect(r.status).toBe("succeeded");
|
|
});
|
|
|
|
async function registerVideo(entityId: string, path: string) {
|
|
const a = await h.db
|
|
.insertInto("media_asset")
|
|
.values({ media_entity_id: entityId, kind: "video", status: "active" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("asset_location")
|
|
.values({
|
|
media_asset_id: a.id,
|
|
location_kind: "library",
|
|
library_root_id: rootId,
|
|
relative_path: path,
|
|
status: "available",
|
|
})
|
|
.execute();
|
|
}
|
|
|
|
it("平铺模板:作品重刮只刮本作品的视频,图片按 <视频名>-poster 命名,不写目录共用的 poster", async () => {
|
|
const meta = (t: string) => ({ title: f(t), posterPath: f(`/${t}.jpg`), backdropPath: f(`/${t}-bg.jpg`) });
|
|
const inception = await entity("movie", "盗梦空间", meta("盗梦空间"), { id: "27205", type: "movie" });
|
|
const interstellar = await entity("movie", "星际穿越", meta("星际穿越"), { id: "157336", type: "movie" });
|
|
await registerVideo(inception, "Movies/盗梦空间 (2010).mkv");
|
|
await registerVideo(interstellar, "Movies/星际穿越 (2014).mkv");
|
|
const q = await scrapeEntity(h.d, inception, { trigger: "manual", mode: "overwrite" });
|
|
expect(q).toHaveLength(1);
|
|
const r = await runScrapeRecord(h.d, q[0]!.recordId);
|
|
expect(r.status).toBe("succeeded");
|
|
expect(r.videos).toEqual(["Movies/盗梦空间 (2010).mkv"]);
|
|
expect(r.files.map((x) => x.path).sort()).toEqual([
|
|
"Movies/盗梦空间 (2010)-fanart.jpg",
|
|
"Movies/盗梦空间 (2010)-poster.jpg",
|
|
"Movies/盗梦空间 (2010).nfo",
|
|
]);
|
|
await expect(stat(lib.path("Movies/星际穿越 (2014).nfo"))).rejects.toThrow();
|
|
await expect(stat(lib.path("Movies/poster.jpg"))).rejects.toThrow();
|
|
expect(await readFile(lib.path("Movies/盗梦空间 (2010).nfo"), "utf8")).toContain("<title>盗梦空间</title>");
|
|
});
|
|
|
|
it("单部电影的目录照旧写 poster / fanart;图片扩展名按实际类型", async () => {
|
|
const avatar = await entity(
|
|
"movie",
|
|
"阿凡达",
|
|
{ title: f("阿凡达"), posterPath: f("https://img.example.test/avatar.webp") },
|
|
{ id: "19995", type: "movie" },
|
|
);
|
|
await registerVideo(avatar, "Single/阿凡达 (2009)/阿凡达 (2009).mkv");
|
|
imageMime = "image/png";
|
|
try {
|
|
const q = await scrapeEntity(h.d, avatar, { trigger: "manual", mode: "overwrite" });
|
|
const r = await runScrapeRecord(h.d, q[0]!.recordId);
|
|
expect(r.status).toBe("succeeded");
|
|
expect(r.files.find((x) => x.role === "poster")).toMatchObject({
|
|
path: "Single/阿凡达 (2009)/poster.png",
|
|
action: "written",
|
|
});
|
|
await expect(stat(lib.path("Single/阿凡达 (2009)/poster.webp"))).rejects.toThrow();
|
|
expect(new Uint8Array(await readFile(lib.path("Single/阿凡达 (2009)/poster.png")))).toEqual(PNG);
|
|
} finally {
|
|
imageMime = "image/jpeg";
|
|
}
|
|
});
|
|
|
|
it("任务被取消:记录收尾为失败(scrape_canceled),不停在进行中", async () => {
|
|
const id = await entity("movie", "取消", { title: f("取消") }, { id: "1", type: "movie" });
|
|
const match: ScrapeMatch = {
|
|
mediaEntityId: id,
|
|
provider: "tmdb",
|
|
externalId: "1",
|
|
kind: "movie",
|
|
title: "取消",
|
|
year: null,
|
|
source: "library",
|
|
};
|
|
const q = await queueScrape(h.d, {
|
|
rootId: `lib:${rootId}`,
|
|
rootPath: lib.root,
|
|
libraryRootId: rootId,
|
|
path: "Single/阿凡达 (2009)",
|
|
match,
|
|
trigger: "manual",
|
|
});
|
|
const ac = new AbortController();
|
|
ac.abort();
|
|
await expect(runScrapeRecord(h.d, q.recordId, ac.signal)).rejects.toThrow();
|
|
const row = await h.db
|
|
.selectFrom("scrape_record")
|
|
.select(["status", "reason", "finished_at"])
|
|
.where("id", "=", q.recordId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(row).toMatchObject({ status: "failed", reason: "scrape_canceled" });
|
|
expect(row.finished_at).not.toBeNull();
|
|
});
|
|
});
|