import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity } from "./harness.ts"; // identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、 // probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。 const skip = !(await pgAvailable()); describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => { let h: FmHarness; let show: string; let film: string; beforeAll(async () => { h = await createFmHarness( "ident", { ao: { probe: async () => ({ status: "ok", container: "matroska", durationSec: 60, video: [{ codec: "hevc", width: 3840, height: 2160, hdr: "HDR10" }], audio: [{ codec: "truehd" }], subtitles: [], }), }, }, { catalog: true }, ); show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 }); film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 }); await mkdir(join(h.dl, "Some.Show.S01.1080p.WEB-DL")); await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E02.1080p.WEB-DL-GRP.mkv"), "ep2"); await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"), "ep3"); await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "film-bytes"); await writeFile(join(h.dl, "Remote.Film.2019.1080p.mkv"), "remote"); }); afterAll(async () => h?.close()); it("identify:目录展开为视频,本地候选命中,季集解析,不写库", async () => { const before = await h.db.selectFrom("media_entity").select(["id"]).execute(); const r = await h.api( "POST", "/files/identify", { items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }] }, READ_KEY, ); expect(r.status).toBe(200); expect(r.body.data.items).toHaveLength(2); const it0 = r.body.data.items[0]; expect(it0.parsed).toMatchObject({ season: 1, episodes: [2], resolution: "1080p" }); expect(it0.candidates[0]).toMatchObject({ mediaEntityId: show, provider: "local", title: "Some Show" }); expect(it0.candidates.length).toBeLessThanOrEqual(5); expect(it0.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S01E02" }); const after = await h.db.selectFrom("media_entity").select(["id"]).execute(); expect(after).toHaveLength(before.length); const pool = await h.db.selectFrom("identity_candidate").select(["id"]).execute(); expect(pool).toHaveLength(0); }); it("identify:远端候选(catalog.lookup)", async () => { const r = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }], }); expect(r.status).toBe(200); const c = r.body.data.items[0].candidates; expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" }); }); it("organize:dryRun 返回 planId 与前后路径;源变更 → 该组 plan_stale;重新预览后执行入队 verifyAndImport", async () => { const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }]; const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true }); expect(dry.status).toBe(200); const g = dry.body.data.groups[0]; expect(g.error).toBeNull(); expect(g.planId).toBeTruthy(); expect(g.files[0].source).toBe(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv")); expect(g.files[0].target).toContain(join(h.lib, "Blue Film (2021)")); await appendFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "+"); const stale = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: false, planIds: [g.planId], }); expect(stale.status).toBe(200); expect(stale.body.data.groups[0].error.code).toBe("plan_stale"); expect(stale.body.data.recordIds).toEqual([]); const dry2 = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true }); const exec = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: false, planIds: [dry2.body.data.groups[0].planId], }); expect(exec.status).toBe(200); expect(exec.body.data.groups[0].error).toBeNull(); expect(exec.body.data.recordIds).toHaveLength(1); expect(exec.body.data.groups[0].files.map((f: { target: string }) => f.target)).toEqual( dry2.body.data.groups[0].files.map((f: { target: string }) => f.target), ); expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true); const rec = await h.db .selectFrom("organization_record") .select(["trigger", "action", "media_entity_id"]) .where("id", "=", exec.body.data.recordIds[0]) .executeTakeFirstOrThrow(); expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film }); }); it("organize:路径逃逸同样拒绝", async () => { const r = await h.api("POST", "/files/organize", { idempotencyKey: key(), items: [{ root: h.dlRoot, path: "../outside/secret.txt", mediaEntityId: film }], action: "copy", dryRun: true, }); expect(r.status).toBeGreaterThanOrEqual(400); expect(r.status).toBeLessThan(500); }); it("probe:已登记资产写回 media_info;scrape 仅已登记作品", async () => { await mkdir(join(h.lib, "Blue Film probe")); await writeFile(join(h.lib, "Blue Film probe", "Blue.Film.2021.mkv"), "x"); const a = await seedAsset(h.db, h, { entityId: film, rel: "Blue Film probe/Blue.Film.2021.mkv" }); const r = await h.api("POST", "/files/probe", { items: [{ root: h.libRoot, path: "Blue Film probe/Blue.Film.2021.mkv" }], }); expect(r.status).toBe(200); expect(r.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: true, summary: { resolution: "3840x2160", hdr: "HDR10", videoCodec: "hevc", audioTracks: 1 }, }); const row = await h.db .selectFrom("media_asset") .select(["media_info"]) .where("id", "=", a.assetId) .executeTakeFirstOrThrow(); expect((row.media_info as { probe?: { status: string } }).probe?.status).toBe("ok"); // 未登记:只缓存 const u = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] }); expect(u.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: false, cached: false }); const u2 = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] }); expect(u2.body.data.items[0].cached).toBe(true); const s = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: film, writeNfo: true }); expect(s.status).toBe(200); expect(s.body.data).toMatchObject({ mediaEntityId: film, nfo: "not_implemented", images: "not_requested" }); expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true); const none = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: show }); expect(none.status).toBe(400); }); });