import { randomUUID } from "node:crypto"; import { mkdir, rename, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createQbClient } from "@umt/adapters"; import type { OrganizeSourceConfig } from "@umt/contracts"; import { createMockQb, type MockQb } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { deleteOrganizeSource, listOrganizeSources, saveOrganizeSource, } from "../../src/modules/file-manager/organize-sources.ts"; import { advanceSnapshot, scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts"; import { createFmHarness, type FmHarness, pgAvailable, seedDownload, seedEntity } from "./harness.ts"; const fact = { size: 100, mtimeMs: 1000 }; describe("source snapshot observations", () => { it("baselines historical files and requires two stable observations for new and changed files", () => { const baseline = advanceSnapshot(null, { "old.mkv": fact }); expect(baseline.eligible).toEqual([]); const added = advanceSnapshot(baseline.snapshot, { "old.mkv": fact, "new.mkv": fact }); expect(added.eligible).toEqual([]); const stable = advanceSnapshot(added.snapshot, { "old.mkv": fact, "new.mkv": fact }); expect(stable.eligible).toEqual(["new.mkv"]); const changed = advanceSnapshot(stable.snapshot, { "old.mkv": { ...fact, size: 200 }, "new.mkv": fact }); expect(changed.eligible).not.toContain("old.mkv"); expect(advanceSnapshot(changed.snapshot, { "old.mkv": { ...fact, size: 200 } }).eligible).toEqual(["old.mkv"]); }); }); const skip = !(await pgAvailable()); describe.skipIf(skip)("persistent source monitoring", () => { let h: FmHarness | undefined; let qb: MockQb | undefined; afterEach(async () => { await qb?.close(); qb = undefined; await h?.close(); h = undefined; }); const setup = async () => { qb = await createMockQb(); h = await createFmHarness("source_monitor", { ao: { downloader: async (inst) => createQbClient({ instanceId: inst.id, baseUrl: qb!.url, username: "admin", password: "adminadmin" }), }, }); const config: Omit = { name: "来源", enabled: true, source: { root: h.dlRoot, path: "" }, target: { root: h.libRoot, path: "" }, mediaType: "all", category: null, action: "copy", trigger: "snapshot", downloaderInstanceId: null, tag: null, intervalSec: 60, }; const id = randomUUID(); await saveOrganizeSource(h.module.deps(), id, config); return { h, id, config, qb }; }; it("first scan persists stock baseline without records; stock never changes baseline", async () => { const { h, id } = await setup(); await writeFile(join(h.dl, "old.mkv"), "old video content"); const result = await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(result).toMatchObject({ baseline: true, queued: 0 }); const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); expect(before.baseline_at).not.toBeNull(); await writeFile(join(h.dl, "new.mkv"), "new video content"); const stock = await scanStock(h.module.deps(), id); expect(stock.items.map((f) => f.path).sort()).toEqual(["new.mkv", "old.mkv"]); const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); expect(after).toEqual(before); expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]); expect((await listOrganizeSources(h.module.deps())).items[0]?.fileCount).toBe(1); }); it("unavailable source preserves baseline and retry does not import historical files", async () => { const { h, id } = await setup(); await writeFile(join(h.dl, "old.mkv"), "old video content"); await scanOrganizeSource(h.module.deps(), { sourceId: id }); const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); await rename(h.dl, `${h.dl}-offline`); await scanOrganizeSource(h.module.deps(), { sourceId: id }); const failed = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); expect(failed.snapshot).toEqual(before.snapshot); expect(failed.baseline_at).toEqual(before.baseline_at); expect(failed.last_error).not.toBeNull(); await rename(`${h.dl}-offline`, h.dl); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ baseline: false, eligible: 0, queued: 0, }); }); it("an identified stable new file plans and queues import once", async () => { const { h, id, config } = await setup(); await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" }); const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 }); await h.db .insertInto("external_identity") .values({ media_entity_id: entityId, provider: "tmdb", external_id: "123456", status: "accepted", entity_type: "movie", }) .execute(); await scanOrganizeSource(h.module.deps(), { sourceId: id }); await writeFile(join(h.dl, "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv"), "video content for snapshot import"); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 }); const stableResult = await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(stableResult).toMatchObject({ eligible: 1, queued: 1 }); const imports = h.engine.enqueued.filter((e) => e.task === "verifyAndImport"); expect(imports).toHaveLength(1); expect(imports[0]?.input).toMatchObject({ recordId: expect.any(String) }); await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toHaveLength(1); }); it("new stable files create one scoped decision and never reprocess baseline stock", async () => { const { h, id } = await setup(); await writeFile(join(h.dl, "historical.mkv"), "historical video content"); await scanOrganizeSource(h.module.deps(), { sourceId: id }); await writeFile(join(h.dl, "unidentified.mkv"), "unidentified video content"); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 }); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 }); const decisions = await h.db.selectFrom("decision").selectAll().execute(); expect(decisions).toHaveLength(1); expect(decisions[0]?.evidence).toMatchObject({ sourceConfigId: id, root: h.dlRoot, path: "unidentified.mkv" }); await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(await h.db.selectFrom("decision").select("id").execute()).toHaveLength(1); }); it("active and completed QB tasks remain under the downloader tag policy", async () => { const { h, id } = await setup(); await scanOrganizeSource(h.module.deps(), { sourceId: id }); await writeFile(join(h.dl, "active.mkv"), "active video content"); await writeFile(join(h.dl, "completed.mkv"), "completed video content"); await seedDownload(h.db, h, { name: "active.mkv", status: "downloading" }); await seedDownload(h.db, h, { name: "completed.mkv", status: "completed" }); await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 2, queued: 0 }); expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]); expect(await scanStock(h.module.deps(), id)).toMatchObject({ unavailable: 1, items: [{ root: h.dlRoot, path: "completed.mkv" }], }); }); it("untagged paused QB downloads without DB rows wait for completion before snapshot import", async () => { const { h, id, qb, config } = await setup(); await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" }); const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 }); await h.db .insertInto("external_identity") .values({ media_entity_id: entityId, provider: "tmdb", external_id: "123456", status: "accepted", entity_type: "movie", }) .execute(); await scanOrganizeSource(h.module.deps(), { sourceId: id }); const name = "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv"; await writeFile(join(h.dl, name), "stable incomplete video content"); qb.seed({ hash: "a".repeat(40), name, tags: [], state: "pausedDL", progress: 0.5, amount_left: 50, save_path: h.dl, content_path: join(h.dl, name), }); expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]); await scanOrganizeSource(h.module.deps(), { sourceId: id }); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 }); expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], unavailable: 1 }); expect(await h.db.selectFrom("decision").select("id").execute()).toEqual([]); expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toEqual([]); await qb.complete("a".repeat(40)); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 1 }); expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]); }); it("unreachable QB preserves the previous baseline and rejects stock", async () => { const { h, id, qb } = await setup(); await writeFile(join(h.dl, "baseline.mkv"), "baseline video content"); await scanOrganizeSource(h.module.deps(), { sourceId: id }); const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); qb.fault("torrents/info", { status: 503, times: 10 }); await writeFile(join(h.dl, "later.mkv"), "later video content"); expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ queued: 0 }); const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow(); expect(after.snapshot).toEqual(before.snapshot); expect(after.baseline_at).toEqual(before.baseline_at); expect(after.last_error).toContain("无法查询来源下载器"); await expect(scanStock(h.module.deps(), id)).rejects.toThrow("无法查询来源下载器"); }); it("partial records exclude successful files while leaving failed files available", async () => { const { h, id } = await setup(); await writeFile(join(h.dl, "success.mkv"), "successful video content"); await writeFile(join(h.dl, "failure.mkv"), "failed video content"); const record = await h.db .insertInto("organization_record") .values({ trigger: "manual", actor_type: "system", action: "copy", status: "partial" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_file_result") .values([ { organization_record_id: record.id, ordinal: 0, mode: "copy", source_path: join(h.dl, "success.mkv"), status: "succeeded", }, { organization_record_id: record.id, ordinal: 1, mode: "copy", source_path: join(h.dl, "failure.mkv"), status: "failed", }, ]) .execute(); expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [{ root: h.dlRoot, path: "failure.mkv" }], skipped: 1, }); }); it("historical success excludes only matching source facts, so replacement files remain candidates", async () => { const { h, id } = await setup(); const names = ["same.mkv", "size-changed.mkv", "mtime-changed.mkv"]; await Promise.all(names.map((n) => writeFile(join(h.dl, n), "video content"))); const facts = Object.fromEntries( await Promise.all( names.map(async (n) => { const st = await stat(join(h.dl, n)); return [join(h.dl, n), { size: st.size, mtimeMs: st.mtimeMs }]; }), ), ); facts[join(h.dl, "size-changed.mkv")]!.size -= 1; facts[join(h.dl, "mtime-changed.mkv")]!.mtimeMs -= 1; const record = await h.db .insertInto("organization_record") .values({ trigger: "manual", actor_type: "system", action: "copy", status: "succeeded", plan: JSON.stringify({ sourceFacts: facts }), }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_file_result") .values( names.map((n, ordinal) => ({ organization_record_id: record.id, ordinal, mode: "copy" as const, source_path: join(h.dl, n), source_size_bytes: facts[join(h.dl, n)]!.size, status: "succeeded" as const, })), ) .execute(); const stock = await scanStock(h.module.deps(), id); expect(stock.skipped).toBe(1); expect(stock.items.map((f) => f.path).sort()).toEqual(["mtime-changed.mkv", "size-changed.mkv"]); }); it("stock batches stay within the identify wizard limit", async () => { const { h, id } = await setup(); await Promise.all(Array.from({ length: 201 }, (_, n) => writeFile(join(h.dl, `stock-${n}.mkv`), "video content"))); const stock = await scanStock(h.module.deps(), id); expect(stock.items).toHaveLength(200); expect(stock.truncated).toBe(true); expect(await h.db.selectFrom("organize_source_snapshot").selectAll().execute()).toEqual([]); }); it("stock omits successfully organized files", async () => { const { h, id } = await setup(); const path = join(h.dl, "organized.mkv"); await writeFile(path, "organized video content"); const record = await h.db .insertInto("organization_record") .values({ trigger: "manual", actor_type: "system", action: "copy", status: "succeeded" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_file_result") .values({ organization_record_id: record.id, mode: "copy", source_path: path, status: "succeeded" }) .execute(); expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], skipped: 1 }); }); it("source edits reset baseline; identical categories coexist; deleting removes state", async () => { const { h, id, config } = await setup(); await mkdir(join(h.dl, "other")); await scanOrganizeSource(h.module.deps(), { sourceId: id }); await saveOrganizeSource(h.module.deps(), randomUUID(), { ...config, category: "电影" }); await saveOrganizeSource(h.module.deps(), id, { ...config, category: "电影", source: { ...config.source, path: "other" }, }); expect((await listOrganizeSources(h.module.deps())).items).toHaveLength(2); expect( await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(), ).toBeUndefined(); await scanOrganizeSource(h.module.deps(), { sourceId: id }); await deleteOrganizeSource(h.module.deps(), id); expect( await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(), ).toBeUndefined(); }); });