import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts"; import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts"; const skip = !(await pgAvailable()); describe.skipIf(skip)("file manager in-place import", () => { let h: FmHarness; beforeAll(async () => { h = await createFmHarness("in_place", {}, { runTransfers: false }); }); afterAll(async () => { await h?.close(); }); async function source(label: string, kind: "movie" | "series" = "movie") { const title = `${label}-${randomUUID().slice(0, 8)}`; const entity = await seedEntity(h.db, { title, kind, year: 2024 }); await mkdir(join(h.lib, title), { recursive: true }); const path = `${title}/${kind === "movie" ? "original.name.mkv" : "original.name.S03E04.mkv"}`; await writeFile(join(h.lib, path), "untouched video"); return { entity, path, items: [{ root: h.libRoot, path, mediaEntityId: entity }] }; } async function preview(items: unknown[], extra: Record = {}) { const payload = { items, action: "in_place", minSizeBytes: 0, scrapeAfterImport: false, ...extra }; const r = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true }); expect(r.status).toBe(200); return { payload, data: r.body.data }; } async function queue(items: unknown[], extra: Record = {}) { const r = await preview(items, extra); expect(r.data.groups[0].error).toBeNull(); const accepted = await h.api("POST", "/files/organize", { ...r.payload, idempotencyKey: key(), dryRun: false, planIds: r.data.groups.map((g: { planId: string }) => g.planId), }); expect(accepted.status).toBe(200); expect(accepted.body.data.groups[0].error).toBeNull(); return { ...r, recordId: accepted.body.data.recordIds[0] as string }; } it("preserves movie and matched subtitle bytes, paths and mtimes; registers once and never transfers", async () => { const s = await source("Movie"); const subtitle = s.path.replace(/\.mkv$/, ".chs.srt"); await writeFile(join(h.lib, subtitle), "untouched subtitle"); const before = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p)))); const r = await queue([{ root: h.libRoot, path: s.path.split("/")[0]!, mediaEntityId: s.entity }], { naming: "template", onExisting: "replace_with_verify", }); expect(r.data.groups[0].files).toHaveLength(2); expect(r.data.groups[0].files.every((f: { source: string; target: string }) => f.source === f.target)).toBe(true); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded"); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded"); expect(await readFile(join(h.lib, s.path), "utf8")).toBe("untouched video"); expect(await readFile(join(h.lib, subtitle), "utf8")).toBe("untouched subtitle"); const after = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p)))); expect(after.map((f) => [f.size, f.mtimeMs, f.ino])).toEqual(before.map((f) => [f.size, f.mtimeMs, f.ino])); expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.chs.srt", "original.name.mkv"]); const assets = await h.db.selectFrom("media_asset").selectAll().where("media_entity_id", "=", s.entity).execute(); expect(assets).toHaveLength(2); expect(assets.find((a) => a.kind === "subtitle")?.parent_asset_id).toBe(assets.find((a) => a.kind === "video")?.id); const ops = await h.db .selectFrom("asset_operation") .select(["kind", "phase"]) .where("organization_record_id", "=", r.recordId) .orderBy("ordinal") .execute(); expect(ops).toEqual([ { kind: "register", phase: "verify" }, { kind: "register", phase: "verify" }, ]); expect( await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", s.entity).execute(), ).toEqual([]); const again = await queue(s.items); await executeRecord(h.module.deps().ao, again.recordId); expect( await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute(), ).toHaveLength(2); // 文件管理装配不挂整理记录路由:直接查记录表 const history = await h.db .selectFrom("organization_record") .select("action") .where("media_entity_id", "=", s.entity) .execute(); expect(history.some((item) => item.action === "in_place")).toBe(true); }); it("registers TV episodes in their existing directory and honors manual season/episode", async () => { const s = await source("TV", "series"); const r = await queue(s.items.map((i) => ({ ...i, season: 2, episodes: [7] }))); expect(r.data.groups[0].files[0].target).toBe(join(h.lib, s.path)); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded"); const units = await h.db .selectFrom("content_unit") .select(["season_number", "episode_number"]) .where("media_entity_id", "=", s.entity) .execute(); expect(units.some((u) => u.season_number === 2 && u.episode_number === 7)).toBe(true); }); it("scrapes only the newly registered file, retaining its organization association on resume", async () => { const s = await source("Scrape"); await h.db .insertInto("external_identity") .values({ media_entity_id: s.entity, provider: "tmdb", entity_type: "movie", external_id: "123456", status: "accepted", }) .execute(); await writeFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "unrelated video"); const r = await queue(s.items, { scrapeAfterImport: true }); await executeRecord(h.module.deps().ao, r.recordId); await executeRecord(h.module.deps().ao, r.recordId); const scrapes = await h.db .selectFrom("scrape_record") .select(["path", "organization_record_id"]) .where("organization_record_id", "=", r.recordId) .execute(); expect(scrapes).toEqual([{ path: s.path, organization_record_id: r.recordId }]); expect(await readFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "utf8")).toBe("unrelated video"); }); it("rejects an unregistered download directory without submitting a task", async () => { const entity = await seedEntity(h.db, { kind: "movie", title: "Outside", year: 2024 }); await writeFile(join(h.dl, "outside.mkv"), "video"); const r = await h.api("POST", "/files/organize", { items: [{ root: h.dlRoot, path: "outside.mkv", mediaEntityId: entity }], action: "in_place", minSizeBytes: 0, idempotencyKey: key(), dryRun: true, }); expect(r.status).toBe(400); expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual( [], ); }); it("does not bind a file to another work or import changed files after queue acceptance", async () => { const s = await source("Changed"); const r = await queue(s.items); await writeFile(join(h.lib, s.path), "changed video longer"); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("failed"); expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute()).toEqual( [], ); const original = await source("Owned"); const a = await queue(original.items); await executeRecord(h.module.deps().ao, a.recordId); const other = await seedEntity(h.db, { kind: "movie", title: "Other", year: 2024 }); const conflict = await preview([{ root: h.libRoot, path: original.path, mediaEntityId: other }]); expect(conflict.data.groups[0].error).not.toBeNull(); expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", other).execute()).toEqual( [], ); }); it("registers read-only library roots without writing metadata", async () => { const s = await source("ReadOnly"); const rootId = h.libRoot.slice(4); await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute(); try { const r = await queue(s.items, { scrapeAfterImport: true }); expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded"); expect( await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(), ).toEqual([]); expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]); } finally { await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute(); } }); });