import { randomUUID } from "node:crypto"; import { access, readFile, 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)("manual organize history and per-import scraping", () => { let h: FmHarness; beforeAll(async () => { h = await createFmHarness("manual_options", {}, { runTransfers: false }); }); afterAll(async () => { await h?.close(); }); async function source(label: string, local = false) { const title = `${label} ${randomUUID().slice(0, 8)}`; const entity = await seedEntity(h.db, { kind: "movie", title, year: 2024 }); if (local) { await h.db .updateTable("media_entity") .set({ metadata: JSON.stringify({ overview: { value: "Local synopsis", source: "web", sources: [], locked: false }, }), }) .where("id", "=", entity) .execute(); } else { await h.db .insertInto("external_identity") .values({ media_entity_id: entity, provider: "tmdb", entity_type: "movie", external_id: String(Math.floor(Math.random() * 1_000_000)), status: "accepted", }) .execute(); } const path = `${title.replaceAll(" ", ".")}.2024.1080p.mkv`; await writeFile(join(h.dl, path), `video-${title}`); return { entity, path, items: [{ root: h.dlRoot, path, mediaEntityId: entity }] }; } async function organize(items: unknown[], extra: Record = {}) { const payload = { action: "copy", items, minSizeBytes: 0, ...extra }; const dry = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true }); expect(dry.status).toBe(200); expect(dry.body.data.groups[0].error).toBeNull(); const exec = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: false, planIds: dry.body.data.groups.map((g: { planId: string }) => g.planId), }); expect(exec.status).toBe(200); expect(exec.body.data.groups[0].error).toBeNull(); return { dry: dry.body.data, exec: exec.body.data }; } it("successful move reorganizes the target; failed move and copy retain their source", async () => { const s = await source("Moved"); const r = await organize(s.items, { action: "move", scrapeAfterImport: false }); const rid = r.exec.recordIds[0]; expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded"); await expect(access(join(h.dl, s.path))).rejects.toThrow(); const refs = await h.api("GET", `/files/record-sources?recordId=${rid}`); expect(refs.status).toBe(200); expect(refs.body.data.unresolved).toEqual([]); expect(refs.body.data.items).toEqual([ { root: h.libRoot, path: r.dry.groups[0].files[0].target.slice(h.lib.length + 1) }, ]); expect(await readFile(r.dry.groups[0].files[0].target, "utf8")).toContain("video-Moved"); const c = await source("Copied"); const copy = await organize(c.items, { scrapeAfterImport: false }); const crid = copy.exec.recordIds[0]; await executeRecord(h.module.deps().ao, crid); expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([ { root: h.dlRoot, path: c.path }, ]); await h.db .updateTable("organization_record") .set({ action: "move", status: "failed" }) .where("id", "=", crid) .execute(); expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([ { root: h.dlRoot, path: c.path }, ]); }); it("successful-history skip includes matched sidecars and does not enqueue work; latest failure does not hide success", async () => { const s = await source("History"); const sidecar = s.path.replace(/\.mkv$/, ".srt"); await writeFile(join(h.dl, sidecar), "subtitle"); const first = await organize(s.items, { scrapeAfterImport: false }); await executeRecord(h.module.deps().ao, first.exec.recordIds[0]); const old = await h.db .selectFrom("organization_record") .selectAll() .where("id", "=", first.exec.recordIds[0]) .executeTakeFirstOrThrow(); const laterId = randomUUID(); await h.db .insertInto("organization_record") .values({ ...old, id: laterId, change_seq: undefined, status: "failed", reason: "later_failure", created_at: new Date(Date.now() + 1000), }) .execute(); const main = await h.db .selectFrom("organization_file_result") .selectAll() .where("organization_record_id", "=", old.id) .where("role", "=", "main") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_file_result") .values({ ...main, id: randomUUID(), organization_record_id: laterId, status: "failed" }) .execute(); const identify = await h.api("POST", "/files/identify", { items: [{ root: h.dlRoot, path: s.path }], minSizeBytes: 0, }); expect(identify.body.data.items[0].records).toMatchObject({ successfulCount: 1, lastStatus: "failed" }); const before = h.engine.enqueued.filter((x) => x.task === "verifyAndImport").length; const second = await organize(s.items, { skipSuccessfulRecords: true, scrapeAfterImport: false }); expect(second.dry.groups[0].files).toHaveLength(2); expect( second.dry.groups[0].files.every( (f: { action: string; skipReason: string }) => f.action === "skip" && f.skipReason === "successful_record", ), ).toBe(true); expect(second.exec.recordIds).toEqual([]); expect(h.engine.enqueued.filter((x) => x.task === "verifyAndImport")).toHaveLength(before); const normal = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", items: s.items, minSizeBytes: 0, dryRun: true, }); expect( normal.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"), ).toBe(false); }); it.each(["failed", "partial"])("%s records do not count as successful histories", async (status) => { const s = await source(status); const first = await organize(s.items, { scrapeAfterImport: false }); await executeRecord(h.module.deps().ao, first.exec.recordIds[0]); await h.db .updateTable("organization_record") .set({ status: status as "failed" | "partial" }) .where("id", "=", first.exec.recordIds[0]) .execute(); const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", items: s.items, minSizeBytes: 0, dryRun: true, skipSuccessfulRecords: true, }); expect( dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"), ).toBe(false); }); it("mixed selection skips successful files while organizing the remaining video", async () => { const s = await source("Mixed"); const first = await organize(s.items, { scrapeAfterImport: false }); await executeRecord(h.module.deps().ao, first.exec.recordIds[0]); const newPath = s.path.replace("1080p", "2160p"); await writeFile(join(h.dl, newPath), "different new video version"); const result = await organize([...s.items, { root: h.dlRoot, path: newPath, mediaEntityId: s.entity }], { skipSuccessfulRecords: true, scrapeAfterImport: false, }); expect( result.dry.groups[0].files.filter((f: { skipReason: string }) => f.skipReason === "successful_record"), ).toHaveLength(1); expect(result.exec.recordIds).toHaveLength(1); expect((await executeRecord(h.module.deps().ao, result.exec.recordIds[0])).status).toBe("succeeded"); const files = await h.db .selectFrom("organization_file_result") .select("source_path") .where("organization_record_id", "=", result.exec.recordIds[0]) .execute(); expect(files).toEqual([{ source_path: join(h.dl, newPath) }]); }); it("a succeeded record with an unsuccessful main file does not count as successful", async () => { const s = await source("MainFailed"); const first = await organize(s.items, { scrapeAfterImport: false }); await executeRecord(h.module.deps().ao, first.exec.recordIds[0]); await h.db .updateTable("organization_file_result") .set({ status: "failed" }) .where("organization_record_id", "=", first.exec.recordIds[0]) .where("role", "=", "main") .execute(); const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", items: s.items, minSizeBytes: 0, dryRun: true, skipSuccessfulRecords: true, }); expect( dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"), ).toBe(false); }); it.each([false, true])( "TMDB per-import scrape=%s overrides the opposite global setting and survives replay", async (enabled) => { await h.db .insertInto("settings") .values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: !enabled }) }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: !enabled }) })) .execute(); const s = await source("TMDB"); const r = await organize(s.items, { scrapeAfterImport: enabled }); const rid = r.exec.recordIds[0]; expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded"); const records = () => h.db.selectFrom("scrape_record").selectAll().where("media_entity_id", "=", s.entity).execute(); const scraped = await records(); expect(scraped).toHaveLength(enabled ? 1 : 0); const byJob = await h.api("GET", `/files/scrape-records?organizationRecordId=${rid}`); expect(byJob.status).toBe(200); expect(byJob.body.data.records.map((r: { id: string }) => r.id)).toEqual(scraped.map((r) => r.id)); if (enabled) { const id = scraped[0]!.id; const oldQuery = await h.api("GET", `/files/scrape-records?ids=${id}`); expect(oldQuery.body.data.records[0].id).toBe(id); const both = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${rid}`); expect(both.body.data.records[0].id).toBe(id); const differentJob = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${randomUUID()}`); expect(differentJob.body.data.records).toEqual([]); } await executeRecord(h.module.deps().ao, rid); expect(await records()).toHaveLength(enabled ? 1 : 0); }, ); it.each([false, true])("Local per-import scrape=%s is respected", async (enabled) => { await h.db .insertInto("settings") .values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: false }) }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: false }) })) .execute(); const s = await source("Local", true); const r = await organize(s.items, { scrapeAfterImport: enabled }); const rid = r.exec.recordIds[0]; const record = await h.db .selectFrom("organization_record") .select("plan") .where("id", "=", rid) .executeTakeFirstOrThrow(); expect(record.plan).toMatchObject({ metadataMode: "local", scrapeAfterImport: enabled }); await executeRecord(h.module.deps().ao, rid); expect( await h.db.selectFrom("scrape_record").select("id").where("media_entity_id", "=", s.entity).execute(), ).toHaveLength(enabled ? 1 : 0); }); it("scrape-records requires an ID list or an organization record", async () => { expect((await h.api("GET", "/files/scrape-records")).status).toBe(422); }); });