Files
UHD_Media_Tool/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts
T

259 lines
11 KiB
TypeScript

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<ReturnType<typeof seedBase>>;
let groupId: string;
const disk = fakeDisk(100_000_000_000);
const torrents = new Map<string, Uint8Array>();
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<ReturnType<typeof acquire>>) {
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<ReturnType<typeof acquire>>, 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);
});