259 lines
11 KiB
TypeScript
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);
|
|
});
|