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 里写多个 ; // - 综艺按播出日期命名的期数按日期对上集(本地集单元或 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 = 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 里每集一个 ,按集号顺序", 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, //g)).toBe(2); expect(xml.indexOf("开端")).toBeLessThan(xml.indexOf("发展")); const single = await readFile(lib.path("TV/双集剧 (2020)/Season 1/Double.S01E03.nfo"), "utf8"); expect(count(single, //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, //g)).toBe(2); expect(xml).toContain("乙"); }); let variety = ""; it("综艺按日期命名:按集单元的播出日期对上集,写 与集号;对不上的跳过", 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("第二期"); expect(nfo).toContain("2024-05-10"); expect(nfo).toContain("2"); 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("六月特辑"); expect(nfo).toContain("5"); 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("盗梦空间"); }); 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(); }); });