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 { 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('\n')).toBe(true); expect(nfo).toContain('tt11280740'); expect(nfo).toContain("美国"); const epNfo = await readFile(join(lib.root, `${stem1}.nfo`), "utf8"); expect(epNfo).toContain("有关地狱的好消息"); expect(epNfo).toMatch(/1<\/season>\n {2}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, "别的工具写的"); const again = await rescrape(series, "missing"); expect(await readFile(show, "utf8")).toBe("别的工具写的"); 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("人生切割术"); }, 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('克里斯托弗·诺兰'); expect(nfo).toContain("169"); }, 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); });