原地入库逐个文件排刮削,单文件刮削只写该集,剧集根目录(tvshow.nfo、海报、季海报)与季目录 (season.nfo)没有元数据。照 MP ScrapingChain.scrape_metadata_event:按作品目录分组,目录元数据照常 初始化,集只刮本次文件;复制 / 移动同样改为只刮本次文件(此前刮整个作品目录)。 - 迁移 0062:scrape_record.videos(null = 目录下全部) - queueScrape 支持 videos;同目录排队中还没开始的刮削合成一批(有一方刮全部则刮全部) - 作品详情「重新刮削」与文件管理刮削不变(目录下全部) Co-Authored-By: Claude Opus 5.5 <[email protected]>
343 lines
14 KiB
TypeScript
343 lines
14 KiB
TypeScript
import { chmod, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import { join, relative } from "node:path";
|
|
import { createTempTree, type TempTree } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
|
import {
|
|
entityScrapeLocations,
|
|
runScrapeRecord,
|
|
SCRAPE_TASK,
|
|
scrapeEntity,
|
|
} from "../../src/modules/acquisition-organize/services/scrape.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
|
|
|
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
|
// 图片失败不阻塞 NFO;只登记库根不写;作品详情「重新刮削」接口与刮削状态(最近一条刮削记录)。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const SIZE = 200_000;
|
|
/** 最小 JPEG 头(按文件头认图) */
|
|
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
|
|
const at = new Date().toISOString();
|
|
const f = (value: unknown, language: string | null = null) => ({ value, source: "tmdb", language, at, locked: false });
|
|
|
|
async function tree(root: string): Promise<string[]> {
|
|
const out: string[] = [];
|
|
const walk = async (d: string) => {
|
|
for (const n of (await readdir(d)).sort()) {
|
|
const p = join(d, n);
|
|
if ((await stat(p)).isDirectory()) await walk(p);
|
|
else out.push(relative(root, p));
|
|
}
|
|
};
|
|
await walk(root);
|
|
return out.filter((p) => !p.startsWith(".")).sort();
|
|
}
|
|
|
|
describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
|
let h: Harness;
|
|
let lib: TempTree;
|
|
let src: TempTree;
|
|
const fetched: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
lib = await createTempTree({}, { sentinel: true, prefix: "umt-scrlib-" });
|
|
src = await createTempTree(
|
|
{
|
|
"Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv": SIZE,
|
|
"Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv": SIZE,
|
|
"Interstellar.2014.1080p.BluRay.x264-GRP.mkv": SIZE,
|
|
},
|
|
{ prefix: "umt-scrsrc-" },
|
|
);
|
|
h = await createHarness("scrape", {
|
|
async scrapeImage(p) {
|
|
fetched.push(p);
|
|
if (p === "/broken.jpg") throw new Error("TMDB 图片返回 HTTP 404");
|
|
return { content: JPEG, mime: "image/jpeg" };
|
|
},
|
|
});
|
|
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
|
await h.setSettings("organize", { scrapeAfterImport: true });
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
for (const t of [lib, src]) await t?.cleanup();
|
|
});
|
|
|
|
async function organize(entity: string, file: string) {
|
|
const plan = await planImport(h.d, {
|
|
sourcePaths: [src.path(file)],
|
|
mediaEntityId: entity,
|
|
action: "copy",
|
|
trigger: "manual",
|
|
actorType: "user",
|
|
});
|
|
expect(plan.status).toBe("running");
|
|
const ex = await executeRecord(h.d, plan.recordId!);
|
|
expect(ex.status).toBe("succeeded");
|
|
return plan.recordId!;
|
|
}
|
|
|
|
/** 按引擎记录的入队项跑刮削(假引擎不执行):该作品最近一条刮削记录 */
|
|
async function runQueued(entity: string) {
|
|
const recs = await h.db
|
|
.selectFrom("scrape_record")
|
|
.select(["id"])
|
|
.where("media_entity_id", "=", entity)
|
|
.where("status", "=", "queued")
|
|
.orderBy("requested_at", "desc")
|
|
.execute();
|
|
expect(recs.length).toBeGreaterThan(0);
|
|
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
|
expect(q.some((x) => (x.input as { recordId: string }).recordId === recs[0]!.id)).toBe(true);
|
|
return runScrapeRecord(h.d, recs[0]!.id);
|
|
}
|
|
|
|
/** 作品详情「重新刮削」的服务端做法:对作品目录排刮削并执行 */
|
|
async function rescrape(entity: string, mode?: "missing" | "overwrite") {
|
|
const q = await scrapeEntity(h.d, entity, { trigger: "manual", mode });
|
|
expect(q.length).toBe(1);
|
|
return runScrapeRecord(h.d, q[0]!.recordId);
|
|
}
|
|
|
|
let series = "";
|
|
let movie = "";
|
|
|
|
it("剧集:入库后自动排刮削,写 tvshow / 季 / 集 NFO、海报、季海报、剧照", async () => {
|
|
series = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
|
|
await h.db
|
|
.updateTable("media_entity")
|
|
.set({
|
|
original_title: "Severance",
|
|
metadata: JSON.stringify({
|
|
title: f("人生切割术", "zh-CN"),
|
|
originalTitle: f("Severance", "en"),
|
|
year: f(2022),
|
|
releaseDate: f("2022-02-17"),
|
|
overview: f("马克领导着一个办公室团队。", "zh-CN"),
|
|
genres: f(["剧情", "悬疑"]),
|
|
voteAverage: f(8.4),
|
|
originCountry: f(["US"]),
|
|
platform: f("Apple TV+"),
|
|
posterPath: f("/sev.jpg"),
|
|
backdropPath: f("/broken.jpg"),
|
|
cast: f([
|
|
{ id: 4495, name: "亚当·斯科特", originalName: null, role: "Mark Scout", profilePath: "/adam.jpg" },
|
|
]),
|
|
crew: [],
|
|
externalIds: f({ imdb: "tt11280740", tvdb: "371980", wikidata: null }),
|
|
}),
|
|
})
|
|
.where("id", "=", series)
|
|
.execute();
|
|
await h.db
|
|
.insertInto("external_identity")
|
|
.values({
|
|
media_entity_id: series,
|
|
provider: "tmdb",
|
|
entity_type: "tv",
|
|
external_id: "95396",
|
|
status: "accepted",
|
|
})
|
|
.execute();
|
|
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 季", "zh-CN"), airDate: f("2022-02-17"), posterPath: f("/s1.jpg") }),
|
|
})
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
for (const [n, name, still] of [
|
|
[1, "有关地狱的好消息", "/e1.jpg"],
|
|
[2, "半循环", null],
|
|
] as const)
|
|
await h.db
|
|
.insertInto("content_unit")
|
|
.values({
|
|
media_entity_id: series,
|
|
parent_unit_id: season.id,
|
|
kind: "episode",
|
|
season_number: 1,
|
|
episode_number: n,
|
|
status: "confirmed",
|
|
title: name,
|
|
air_date: "2022-02-17",
|
|
metadata: JSON.stringify({
|
|
name: f(name, "zh-CN"),
|
|
overview: f(`第 ${n} 集简介`, "zh-CN"),
|
|
airDate: f("2022-02-17"),
|
|
runtime: f(55),
|
|
voteAverage: f(8),
|
|
...(still ? { stillPath: f(still) } : {}),
|
|
}),
|
|
})
|
|
.execute();
|
|
|
|
const rec = await organize(series, "Severance.S01E01.1080p.WEB-DL.H264-GRP.mkv");
|
|
await organize(series, "Severance.S01E02.1080p.WEB-DL.H264-GRP.mkv");
|
|
const q = h.engine.enqueued.filter((x) => x.task === SCRAPE_TASK);
|
|
// 照 MP:对作品目录刮,集只刮本次整理的文件;两批还没开始时合成一条
|
|
expect(q[0]!.key.startsWith(`${SCRAPE_TASK}:record:${rec}:`)).toBe(true);
|
|
const first = await h.db
|
|
.selectFrom("scrape_record")
|
|
.selectAll()
|
|
.where("id", "=", (q[0]!.input as { recordId: string }).recordId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(first).toMatchObject({
|
|
media_entity_id: series,
|
|
trigger: "auto",
|
|
mode: "missing",
|
|
organization_record_id: rec,
|
|
});
|
|
|
|
const r = await runQueued(series);
|
|
expect(r.status).toBe("partial"); // 背景图下载失败
|
|
const files = await tree(lib.root);
|
|
const show = files.find((p) => p.endsWith("tvshow.nfo"))!;
|
|
const dir = show.slice(0, -"/tvshow.nfo".length);
|
|
const ep1 = files.find((p) => p.includes("S01E01") && p.endsWith(".mkv"))!;
|
|
const stem1 = ep1.slice(0, -4);
|
|
expect(files).toEqual(
|
|
expect.arrayContaining([
|
|
`${dir}/tvshow.nfo`,
|
|
`${dir}/poster.jpg`,
|
|
`${dir}/season01-poster.jpg`,
|
|
`${dir}/Season 1/season.nfo`,
|
|
`${dir}/Season 1/poster.jpg`,
|
|
`${stem1}.nfo`,
|
|
`${stem1}-thumb.jpg`,
|
|
]),
|
|
);
|
|
expect(files.some((p) => p.endsWith("fanart.jpg"))).toBe(false);
|
|
expect(files.filter((p) => p.endsWith(".nfo"))).toHaveLength(4);
|
|
expect(files.some((p) => p.includes(".umt-scrape.tmp"))).toBe(false);
|
|
const nfo = await readFile(join(lib.root, show), "utf8");
|
|
expect(nfo.startsWith('<?xml version="1.0" encoding="utf-8"?>\n<tvshow>')).toBe(true);
|
|
expect(nfo).toContain('<uniqueid type="imdb" default="true">tt11280740</uniqueid>');
|
|
expect(nfo).toContain("<country>美国</country>");
|
|
const epNfo = await readFile(join(lib.root, `${stem1}.nfo`), "utf8");
|
|
expect(epNfo).toContain("<title>有关地狱的好消息</title>");
|
|
expect(epNfo).toMatch(/<season>1<\/season>\n {2}<episode>1<\/episode>/);
|
|
// 同一张季海报只取一次
|
|
expect(fetched.filter((p) => p === "/s1.jpg")).toHaveLength(1);
|
|
|
|
const st = await h.api("GET", `/library/${series}`);
|
|
expect(st.status).toBe(200);
|
|
expect(st.body.data.scrape).toMatchObject({
|
|
status: "partial",
|
|
trigger: "auto",
|
|
failed: 1,
|
|
reason: "scrape_image_failed",
|
|
});
|
|
expect(st.body.data.scrape.files.find((x: { role: string }) => x.role === "fanart")).toMatchObject({
|
|
action: "failed",
|
|
reason: "scrape_image_failed",
|
|
});
|
|
const audit = await h.db
|
|
.selectFrom("audit_event")
|
|
.select(["action", "target_id", "target_type"])
|
|
.where("action", "=", "organize.scrape")
|
|
.execute();
|
|
expect(audit.some((a) => a.target_type === "scrape_record" && a.target_id === st.body.data.scrape.id)).toBe(true);
|
|
}, 60_000);
|
|
|
|
it("只补缺失:已有文件(包括别人写的)不动;全部覆盖:改写", async () => {
|
|
const files = await tree(lib.root);
|
|
const show = join(lib.root, files.find((p) => p.endsWith("tvshow.nfo"))!);
|
|
await writeFile(show, "<tvshow>别的工具写的</tvshow>");
|
|
const again = await rescrape(series, "missing");
|
|
expect(await readFile(show, "utf8")).toBe("<tvshow>别的工具写的</tvshow>");
|
|
expect(again.files.find((x) => x.role === "tvshow_nfo")).toMatchObject({
|
|
action: "skipped",
|
|
reason: "scrape_exists",
|
|
});
|
|
const over = await rescrape(series, "overwrite");
|
|
expect(over.written).toBeGreaterThan(0);
|
|
expect(await readFile(show, "utf8")).toContain("<title>人生切割术</title>");
|
|
}, 60_000);
|
|
|
|
it("电影:<视频名>.nfo + 作品目录 poster / fanart;手动「重新刮削」接口排任务", async () => {
|
|
await h.setSettings("organize", { scrapeAfterImport: false });
|
|
movie = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
|
|
await h.db
|
|
.updateTable("media_entity")
|
|
.set({
|
|
metadata: JSON.stringify({
|
|
title: f("星际穿越", "zh-CN"),
|
|
overview: f("穿越虫洞。", "zh-CN"),
|
|
releaseDate: f("2014-11-05"),
|
|
runtime: f(169),
|
|
posterPath: f("/inter.jpg"),
|
|
backdropPath: f("/inter-bg.jpg"),
|
|
crew: f([{ id: 525, name: "克里斯托弗·诺兰", originalName: null, role: "Director", profilePath: null }]),
|
|
}),
|
|
})
|
|
.where("id", "=", movie)
|
|
.execute();
|
|
const before = h.engine.enqueued.length;
|
|
await organize(movie, "Interstellar.2014.1080p.BluRay.x264-GRP.mkv");
|
|
expect(h.engine.enqueued.slice(before).some((x) => x.task === SCRAPE_TASK)).toBe(false); // 关了自动刮削
|
|
|
|
const res = await h.api("POST", `/library/${movie}/scrape`, {});
|
|
expect(res.status).toBe(202);
|
|
expect(res.body.data).toMatchObject({ mediaEntityId: movie, deduplicated: false });
|
|
const dup = await h.api("POST", `/library/${movie}/scrape`, {});
|
|
expect(dup.body.data.deduplicated).toBe(true);
|
|
expect((await h.api("GET", `/library/${movie}`)).body.data.scrape.status).toBe("queued");
|
|
|
|
const r = await runQueued(movie);
|
|
expect(r.status).toBe("succeeded");
|
|
const files = await tree(lib.root);
|
|
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
|
const dir = video.slice(0, video.lastIndexOf("/"));
|
|
expect(files.filter((p) => p.startsWith(`${dir}/`))).toEqual(
|
|
[video, `${video.slice(0, -4)}.nfo`, `${dir}/fanart.jpg`, `${dir}/poster.jpg`].sort(),
|
|
);
|
|
const nfo = await readFile(join(lib.root, `${video.slice(0, -4)}.nfo`), "utf8");
|
|
expect(nfo).toContain('<director tmdbid="525">克里斯托弗·诺兰</director>');
|
|
expect(nfo).toContain("<runtime>169</runtime>");
|
|
}, 60_000);
|
|
|
|
it("蓝光原盘资产:原盘目录本身就是刮削目录,不取上一级", async () => {
|
|
const disc = await seedEntity(h.db, { kind: "movie", title: "原盘", year: 2020 });
|
|
const root = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
|
|
const asset = await h.db
|
|
.insertInto("media_asset")
|
|
.values({ media_entity_id: disc, kind: "bluray_dir" })
|
|
.returning("id")
|
|
.executeTakeFirstOrThrow();
|
|
await h.db
|
|
.insertInto("asset_location")
|
|
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: "Movies/原盘 (2020)" })
|
|
.execute();
|
|
expect((await entityScrapeLocations(h.d, disc, "movie")).map((l) => l.path)).toEqual(["Movies/原盘 (2020)"]);
|
|
});
|
|
|
|
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
|
|
const files = await tree(lib.root);
|
|
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
|
const dirAbs = join(lib.root, video.slice(0, video.lastIndexOf("/")));
|
|
if (process.getuid?.() !== 0) {
|
|
await chmod(dirAbs, 0o555);
|
|
const r = await rescrape(movie, "overwrite");
|
|
await chmod(dirAbs, 0o755);
|
|
expect(r.files.some((x) => x.reason === "scrape_write_failed")).toBe(true);
|
|
}
|
|
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).execute();
|
|
const ro = await rescrape(movie, "overwrite");
|
|
expect(ro).toMatchObject({ status: "failed", reason: "scrape_root_read_only", written: 0 });
|
|
await h.db.updateTable("library_root").set({ management_mode: "managed" }).execute();
|
|
|
|
const empty = await seedEntity(h.db, { kind: "movie", title: "没入库", year: 2020 });
|
|
expect((await h.api("POST", `/library/${empty}/scrape`, {})).status).toBe(412);
|
|
expect(await scrapeEntity(h.d, empty, { trigger: "manual" })).toEqual([]);
|
|
}, 60_000);
|
|
});
|