import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { DEFAULT_SETTINGS } from "@umt/contracts"; import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts"; const hasPg = await pgAvailable(); describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => { let h: Harness; let lib: TempTree; let src: TempTree; let rootId: string; let instanceId: string; let siteId: string; let qb: MockQb; beforeAll(async () => { lib = await createTempTree({}, { sentinel: true }); src = await createTempTree({ "Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000, "Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000, ...Object.fromEntries( Array.from({ length: 6 }, (_, i) => [ `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`, 200_000, ]), ), }); qb = await createMockQb(); h = await createHarness("replacement"); ({ rootId, instanceId, siteId } = await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: qb.url, })); await h.setSettings("naming", { tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}", }); }, 60_000); afterAll(async () => { await h?.close(); await qb?.close(); await lib?.cleanup(); await src?.cleanup(); }); const old = async (entityId: string, path: string, parent?: string) => { await mkdir(dirname(lib.path(path)), { recursive: true }); await writeFile(lib.path(path), Buffer.alloc(200_001, 7)); const asset = await h.db .insertInto("media_asset") .values({ media_entity_id: entityId, kind: parent ? "subtitle" : "video", parent_asset_id: parent ?? null, status: "active", size_bytes: 200_001, }) .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", is_primary: true, }) .execute(); return asset.id; }; const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({ mediaEntityId: entityId, sourcePaths: paths.map((p) => src.path(p)), trigger: "manual" as const, actorType: "user" as const, action: "copy" as const, importMode: "replace" as const, replacementAssetIds: assetIds, }); const status = async (id: string) => (await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status; it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020, }); const path = "Movie (2020)/Movie (2020) - 720p.mkv"; const selected = await old(e, path); const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected); const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv"); await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared"); const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected])); expect(planned.status).toBe("running"); expect(existsSync(lib.path(path))).toBe(true); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded"); expect(await status(selected)).toBe("retired"); expect(await status(subtitle)).toBe("retired"); expect(await status(kept)).toBe("active"); expect(existsSync(lib.path(path))).toBe(false); expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true); expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true); expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2); await executeRecord(h.d, planned.recordId!); expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2); }); it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "Same", year: 2020, }); const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv"); const normal = await planImport(h.d, { ...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]), importMode: "normal", dryRun: true, }); expect(normal.plan?.organize.files[0]?.action).toBe("hold"); const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected])); expect(planned.status).toBe("running"); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded"); expect(await status(selected)).toBe("retired"); const active = await h.db .selectFrom("media_asset") .select("id") .where("media_entity_id", "=", e) .where("status", "=", "active") .execute(); expect(active).toHaveLength(1); expect(active[0]!.id).not.toBe(selected); }); it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "Protected", year: 2020, }); const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv"); const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv"); const blocked = await planImport(h.d, { ...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]), dryRun: true, }); expect(blocked.plan?.organize.files[0]?.action).toBe("hold"); expect(await status(occupied)).toBe("active"); const other = await seedEntity(h.db, { kind: "movie", title: "Stale", year: 2020, }); const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv"; const stale = await old(other, oldPath); const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale])); await writeFile(lib.path(oldPath), "changed-after-plan"); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded"); expect(await status(stale)).toBe("active"); expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan"); }); it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => { const e = await seedEntity(h.db, { kind: "movie", title: "Same", year: 2021, }); const path = "Same (2021)/Same (2021) - 1080p.mkv"; const selected = await old(e, path); const savedProbe = h.d.ports.probe; const savedConfig = h.ctx.config; h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" }; h.d.ports.probe = async () => ({ status: "ok", video: [], audio: [], subtitles: [], }); try { const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected])); expect(planned.status).toBe("running"); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed"); expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true); expect(await status(selected)).toBe("active"); expect( ( await h.db .selectFrom("asset_location") .select("status") .where("media_asset_id", "=", selected) .executeTakeFirstOrThrow() ).status, ).toBe("available"); await executeRecord(h.d, planned.recordId!); expect((await readFile(lib.path(path))).length).toBe(200_001); } finally { h.d.ports.probe = savedProbe; h.ctx.config = savedConfig; } }); it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => { const e = await seedEntity(h.db, { kind: "series", title: "Show", year: 2020, }); const ids: string[] = []; for (let i = 1; i <= 10; i++) { ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`)); } const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv"); const saved = h.d.ports.probe; h.d.ports.probe = async (path) => path.includes("E04") ? { status: "ok", video: [], audio: [], subtitles: [] } : { status: "unknown", reason: "ffprobe_unavailable" }; try { const paths = Array.from( { length: 6 }, (_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`, ); const planned = await planImport(h.d, request(e, paths, [...ids, merged])); expect(planned.status).toBe("running"); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial"); for (let i = 1; i <= 10; i++) { expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active"); } expect(await status(merged)).toBe("active"); } finally { h.d.ports.probe = saved; } }); it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => { const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv"; const hash = "a".repeat(40); qb.seed({ hash, name, tags: ["umt"], save_path: src.root }); await qb.complete(hash, { writeFiles: true, files: [{ path: name, size: 200_000 }], }); const release = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: randomUUID(), title: name, size_bytes: 200_000, raw: JSON.stringify({}), resolution_status: "unresolved", }) .returning("id") .executeTakeFirstOrThrow(); const makePlan = async (evaluation: object) => ( await h.db .insertInto("acquisition_plan") .values({ slot_id: null, source_release_id: release.id, status: "submitted", payload_hash: randomUUID(), idempotency_key: randomUUID(), evaluation: JSON.stringify(evaluation), }) .returning("id") .executeTakeFirstOrThrow() ).id; const initialPlan = await makePlan({ manual: true, mediaEntityId: null, importMode: "normal", replacementAssetIds: [], }); const dt = await h.db .insertInto("download_task") .values({ instance_id: instanceId, torrent_hash: hash, name, save_path: src.root, content_path: src.path(name), managed: true, tags: ["umt"], status: "completed", source_release_id: release.id, acquisition_plan_id: initialPlan, import_plan_id: initialPlan, }) .returning("id") .executeTakeFirstOrThrow(); const local = await seedEntity(h.db, { kind: "movie", title: "本地作品", year: 2022, }); await h.db .updateTable("media_entity") .set({ metadata: JSON.stringify({ title: { value: "本地作品", source: "web", evidence: { sourceUrl: "https://example.test/detail" }, }, originalLanguage: { value: "zh", source: "web" }, genres: { value: ["剧情"], source: "web" }, }), }) .where("id", "=", local) .execute(); const sourceId = randomUUID(); await h.setSettings("organize", { ...DEFAULT_SETTINGS.organize, minSizeBytes: 100_000, scrapeAfterImport: false, sources: [ { id: sourceId, name: "Local来源", enabled: true, source: { root: `dl:${instanceId}:0`, path: "" }, target: { root: `lib:${rootId}`, path: "" }, mediaType: "movie", category: null, action: "copy", trigger: "manual", downloaderInstanceId: null, tag: null, intervalSec: 300, }, ], }); const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true); const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({ mediaEntityId: local, mode: "local", enrichmentId: randomUUID(), reason: null, }); try { const planned = await planImport(h.d, { downloadTaskId: dt.id, sourceConfigId: sourceId, trigger: "auto", actorType: "system", }); expect(planned.status).toBe("running"); expect( ( await h.db .selectFrom("organization_record") .select("plan") .where("id", "=", planned.recordId!) .executeTakeFirstOrThrow() ).plan, ).toMatchObject({ metadataMode: "local" }); expect(invoke).toHaveBeenCalledWith( "metadata.resolve_organize", { sourceReleaseId: release.id }, expect.objectContaining({ source: "task" }), ); expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded"); expect( await h.db .selectFrom("media_asset") .select("id") .where("media_entity_id", "=", local) .where("status", "=", "active") .execute(), ).toHaveLength(1); expect( ( await h.db .selectFrom("path_binding") .select("relative_path") .where("media_entity_id", "=", local) .executeTakeFirstOrThrow() ).relative_path, ).toBe("华语电影/本地作品 (2022)"); expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true); const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv"); const secondPlan = await makePlan({ manual: true, mediaEntityId: local, importMode: "replace", replacementAssetIds: [selected], }); await h.db .updateTable("download_task") .set({ import_plan_id: secondPlan, status: "completed" }) .where("id", "=", dt.id) .execute(); const rerun = await planImport(h.d, { downloadTaskId: dt.id, sourceConfigId: sourceId, trigger: "auto", actorType: "system", }); expect(rerun.status).toBe("running"); expect( ( await h.db .selectFrom("organization_record") .select("plan") .where("id", "=", rerun.recordId!) .executeTakeFirstOrThrow() ).plan, ).toMatchObject({ metadataMode: "local" }); expect(invoke).toHaveBeenCalledTimes(1); await executeRecord(h.d, rerun.recordId!); expect(await status(selected)).toBe("retired"); expect( ( await h.db .selectFrom("acquisition_plan") .select("status") .where("id", "=", secondPlan) .executeTakeFirstOrThrow() ).status, ).toBe("completed"); expect( ( await h.db .selectFrom("download_task") .select("acquisition_plan_id") .where("id", "=", dt.id) .executeTakeFirstOrThrow() ).acquisition_plan_id, ).toBe(initialPlan); } finally { has.mockRestore(); invoke.mockRestore(); } }); });