Harden hybrid extract readiness for partial archives

Require near-complete file size checks in Item-Recovery and hybrid ready-set detection so partially downloaded RAR parts are not marked completed and extracted prematurely.
This commit is contained in:
Sucukdeluxe
2026-03-03 16:52:16 +01:00
parent 87e0a986e6
commit 30ac5bf9db
2 changed files with 299 additions and 27 deletions
+236
View File
@@ -3636,6 +3636,242 @@ describe("download manager", () => {
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)");
});
it("does not recover partial archive files as completed", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "partial-recovery");
const extractDir = path.join(root, "extract", "partial-recovery");
fs.mkdirSync(outputDir, { recursive: true });
const archivePath = path.join(outputDir, "partial.repack.part1.rar");
const totalBytes = 1_000_000;
fs.writeFileSync(archivePath, Buffer.alloc(860_000, 1));
const session = emptySession();
const packageId = "partial-recovery-pkg";
const itemId = "partial-recovery-item";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "partial-recovery",
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/partial-recovery",
provider: "megadebrid",
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes,
progressPercent: 0,
fileName: path.basename(archivePath),
targetPath: archivePath,
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false
},
session,
createStoragePaths(path.join(root, "state"))
);
const internal = manager as unknown as {
handlePackagePostProcessing: (packageId: string) => Promise<void>;
};
await internal.handlePackagePostProcessing(packageId);
const item = manager.getSnapshot().session.items[itemId];
expect(item?.status).toBe("queued");
expect(item?.fullStatus).toBe("Wartet");
});
it("recovers near-complete archive files with known size", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "near-complete-recovery");
const extractDir = path.join(root, "extract", "near-complete-recovery");
fs.mkdirSync(outputDir, { recursive: true });
const archivePath = path.join(outputDir, "near.complete.part1.rar");
const totalBytes = 1_000_000;
const fileSize = 996_000;
fs.writeFileSync(archivePath, Buffer.alloc(fileSize, 2));
const session = emptySession();
const packageId = "near-complete-recovery-pkg";
const itemId = "near-complete-recovery-item";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "near-complete-recovery",
outputDir,
extractDir,
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/near-complete-recovery",
provider: "megadebrid",
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes,
progressPercent: 0,
fileName: path.basename(archivePath),
targetPath: archivePath,
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false
},
session,
createStoragePaths(path.join(root, "state"))
);
const internal = manager as unknown as {
handlePackagePostProcessing: (packageId: string) => Promise<void>;
};
await internal.handlePackagePostProcessing(packageId);
const item = manager.getSnapshot().session.items[itemId];
expect(item?.status).toBe("completed");
expect(item?.downloadedBytes).toBe(fileSize);
});
it("skips hybrid-ready multipart archives when a completed part is still too small", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "hybrid-size-guard");
const extractDir = path.join(root, "extract", "hybrid-size-guard");
fs.mkdirSync(outputDir, { recursive: true });
const part1 = path.join(outputDir, "show.s01e01.part1.rar");
const part2 = path.join(outputDir, "show.s01e01.part2.rar");
fs.writeFileSync(part1, Buffer.alloc(900_000, 3));
fs.writeFileSync(part2, Buffer.alloc(700_000, 4));
const session = emptySession();
const packageId = "hybrid-size-guard-pkg";
const createdAt = Date.now() - 20_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "hybrid-size-guard",
outputDir,
extractDir,
status: "downloading",
itemIds: ["hybrid-size-guard-item-1", "hybrid-size-guard-item-2"],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
session.items["hybrid-size-guard-item-1"] = {
id: "hybrid-size-guard-item-1",
packageId,
url: "https://dummy/hybrid-size-guard/1",
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 900_000,
totalBytes: 1_000_000,
progressPercent: 100,
fileName: path.basename(part1),
targetPath: part1,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpacken - Ausstehend",
createdAt,
updatedAt: createdAt
};
session.items["hybrid-size-guard-item-2"] = {
id: "hybrid-size-guard-item-2",
packageId,
url: "https://dummy/hybrid-size-guard/2",
provider: "megadebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 700_000,
totalBytes: 700_000,
progressPercent: 100,
fileName: path.basename(part2),
targetPath: part2,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Entpacken - Ausstehend",
createdAt,
updatedAt: createdAt
};
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
hybridExtract: true
},
session,
createStoragePaths(path.join(root, "state"))
);
const internal = manager as unknown as {
session: ReturnType<typeof emptySession>;
findReadyArchiveSets: (pkg: ReturnType<typeof emptySession>["packages"][string]) => Promise<Set<string>>;
};
const ready = await internal.findReadyArchiveSets(internal.session.packages[packageId]);
expect(ready.size).toBe(0);
});
it("recovers pending extraction on startup for completed package", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root);