import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts"; import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts"; import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts"; import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts"; const hasPg = await pgAvailable(); const SIZE = 300_000; describe.skipIf(!hasPg)("自动洗版物理替换(真实 PG、qB协议与临时文件)", () => { let h: Harness; let qb: MockQb; let lib: TempTree; let cache: TempTree; let base: Awaited>; let groupId: string; const disk = fakeDisk(100_000_000_000); const torrents = new Map(); let pieceSeed = 0; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); lib = await createTempTree({}, { sentinel: true, prefix: "umt-auto-up-lib-" }); cache = await createTempTree({}, { prefix: "umt-auto-up-cache-" }); disk.state.watch = [cache.root]; disk.setPct(10); h = await createHarness("auto_upgrade_replace", { diskUsage: disk.fn, torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }), }); base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url }); await h.setSettings("naming", { movie: "{{title}} ({{year}})/{{title}} ({{year}}) - {{videoFormat}}{{fileExt}}", tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}", }); const g = await h.api("POST", "/rules", { name: "自动洗版优先REMUX", ruleString: "REMUX > WEBDL" }); expect(g.status, JSON.stringify(g.body)).toBe(201); groupId = g.body.data.id; }, 60_000); afterAll(async () => { await h?.close(); await qb?.close(); await lib?.cleanup(); await cache?.cleanup(); }); async function tracked(kind: "movie" | "series", title: string, versions: { name: string; resolutions: string[] }[]) { const entityId = await seedEntity(h.db, { kind, title, year: 2024 }); const sub = await h.db .insertInto("subscription") .values({ media_entity_id: entityId, media_kind: kind, mode: "once", season_scope: "current_season", params: JSON.stringify({ autoFill: "on" }), }) .returning("id") .executeTakeFirstOrThrow(); const slotIds: string[] = []; for (const [position, v] of versions.entries()) { const slot = await h.db .insertInto("acquisition_slot") .values({ media_entity_id: entityId, subscription_id: sub.id, name: v.name, position, hard_conditions: JSON.stringify({ resolutions: v.resolutions }), soft_preferences: JSON.stringify({ groups: ["GOOD", "OTHER", "LOW"] }), upgrade_threshold: JSON.stringify({ enabled: true, basis: "priority", mode: kind === "movie" ? "movie" : "per_unit", ...(kind === "series" ? { consistency: "season_release" } : {}), }), rule_group_ids: JSON.stringify([groupId]), }) .returning("id") .executeTakeFirstOrThrow(); slotIds.push(slot.id); } if (kind === "series") await h.db .transaction() .execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() })); return { entityId, slotIds }; } async function release(entityId: string, title: string) { const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: ++pieceSeed }); const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash }); torrents.set(id, t.bytes); return { id, hash: t.hash }; } async function acquire(entityId: string, title: string, expectedSlot: string, purpose: "fill" | "upgrade") { const rel = await release(entityId, title); const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id }); expect(ev.plans, JSON.stringify(ev)).toHaveLength(1); const planId = ev.plans[0]!; const plan = await h.db .selectFrom("acquisition_plan") .selectAll() .where("id", "=", planId) .executeTakeFirstOrThrow(); expect(plan).toMatchObject({ slot_id: expectedSlot, purpose }); if (purpose === "upgrade") expect(plan.evaluation).toMatchObject({ automaticReplacement: true }); const submitted = await submitDownload(h.d, planId); expect(submitted.status, JSON.stringify(submitted)).toBe("submitted"); await qb.complete(rel.hash, { writeFiles: true }); expect((await syncDownloads(h.d)).completed).toContain(submitted.downloadTaskId); const imported = await planImport(h.d, { downloadTaskId: submitted.downloadTaskId!, trigger: "auto", actorType: "system", }); expect(imported.status, JSON.stringify(imported)).toBe("running"); expect((await executeRecord(h.d, imported.recordId!)).status).toBe("succeeded"); const result = await h.db .selectFrom("organization_file_result") .select("media_asset_id") .where("organization_record_id", "=", imported.recordId!) .where("role", "=", "main") .where("status", "=", "succeeded") .executeTakeFirstOrThrow(); const location = await h.db .selectFrom("asset_location") .select("relative_path") .where("media_asset_id", "=", result.media_asset_id!) .where("location_kind", "=", "library") .where("status", "=", "available") .executeTakeFirstOrThrow(); const bytes = await readFile(lib.path(location.relative_path)); expect(bytes).toHaveLength(SIZE); return { assetId: result.media_asset_id!, path: location.relative_path, bytes, recordId: imported.recordId! }; } async function expectKept(file: Awaited>) { const a = await h.db .selectFrom("media_asset") .select("status") .where("id", "=", file.assetId) .executeTakeFirstOrThrow(); expect(a.status).toBe("active"); expect((await readFile(lib.path(file.path))).equals(file.bytes)).toBe(true); expect( await h.db.selectFrom("trash_item").select("id").where("original_path", "=", file.path).execute(), ).toHaveLength(0); } async function expectTrashed(file: Awaited>, samePath = false) { const a = await h.db .selectFrom("media_asset") .select("status") .where("id", "=", file.assetId) .executeTakeFirstOrThrow(); expect(a.status).toBe("retired"); const oldLocation = await h.db .selectFrom("asset_location") .select("status") .where("media_asset_id", "=", file.assetId) .where("location_kind", "=", "library") .executeTakeFirstOrThrow(); expect(oldLocation.status).toBe("removed"); if (!samePath) expect(existsSync(lib.path(file.path))).toBe(false); const items = await h.db .selectFrom("trash_item") .select("trash_path") .where("original_path", "=", file.path) .execute(); expect(items).toHaveLength(1); expect((await readFile(items[0]!.trash_path)).equals(file.bytes)).toBe(true); } it("电影低档自动入库后升级替换同路径旧文件并回收,另一保留分辨率保持原字节", async () => { const f = await tracked("movie", "Auto Movie", [ { name: "HD", resolutions: ["1080p"] }, { name: "UHD", resolutions: ["2160p"] }, ]); const uhd = await acquire(f.entityId, "Auto.Movie.2024.2160p.BluRay.REMUX.HEVC-GOOD", f.slotIds[1]!, "fill"); const lower = await acquire(f.entityId, "Auto.Movie.2024.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill"); const higher = await acquire(f.entityId, "Auto.Movie.2024.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade"); expect(higher.path).toBe(lower.path); expect(higher.assetId).not.toBe(lower.assetId); expect(higher.bytes.equals(lower.bytes)).toBe(false); await expectTrashed(lower, true); await expectKept(uhd); expect((await readFile(lib.path(higher.path))).equals(higher.bytes)).toBe(true); await executeRecord(h.d, higher.recordId); await expectTrashed(lower, true); await expectKept(uhd); }, 60_000); it("旧单文件E1-E2在同套新E1入库时保留,新E2补齐后自动回收旧合集", async () => { const f = await tracked("series", "Auto Merged", [{ name: "单保留", resolutions: ["720p", "1080p"] }]); const old = await acquire(f.entityId, "Auto.Merged.S01E01-E02.720p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill"); const first = await acquire(f.entityId, "Auto.Merged.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade"); await expectKept(old); const second = await acquire( f.entityId, "Auto.Merged.S01E02.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade", ); await expectTrashed(old); await expectKept(first); await expectKept(second); await executeRecord(h.d, second.recordId); await expectTrashed(old); }, 60_000); it("不同组或另一保留分辨率的已入库E2不能凑齐新E1的合集覆盖", async () => { const f = await tracked("series", "Auto Isolated", [ { name: "HD", resolutions: ["1080p"] }, { name: "UHD", resolutions: ["2160p"] }, ]); const old = await acquire(f.entityId, "Auto.Isolated.S01E01-E02.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill"); const otherResolution = await acquire( f.entityId, "Auto.Isolated.S01E02.2160p.BluRay.REMUX.AVC-GOOD", f.slotIds[1]!, "fill", ); const otherGroup = await acquire( f.entityId, "Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-OTHER", f.slotIds[0]!, "upgrade", ); await expectKept(old); const first = await acquire( f.entityId, "Auto.Isolated.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade", ); await expectKept(old); await expectKept(otherGroup); await expectKept(otherResolution); const second = await acquire( f.entityId, "Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade", ); await expectTrashed(old); await expectTrashed(otherGroup, true); await expectKept(first); await expectKept(otherResolution); expect((await readFile(lib.path(second.path))).equals(second.bytes)).toBe(true); }, 60_000); });