fix(extraction): repair missing archives before manual retry

Persist selected manual extraction repair requests, re-download only missing multipart members, and resume extraction after successful repairs across Stop, failures, and application restarts.

Allow complete archive sets and missing sets from the same package to progress together, preserve existing parts, and keep retry generations and run contexts isolated after failed repair downloads.

Expose direct child retries for persisted repair items and validate repair state through storage normalization and renderer deltas.
This commit is contained in:
Sucukdeluxe
2026-08-23 11:37:21 +02:00
parent 808c2a432c
commit e52957a983
10 changed files with 659 additions and 67 deletions
+257
View File
@@ -17434,6 +17434,263 @@ describe("post-processing lifecycle audit", () => {
expect(session.items["retry-no-archive-item"].fullStatus).toBe("Entpack-Fehler: vorheriger Fehler");
});
it("repairs selected failed packages by downloading missing archive files before extraction", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-missing-batch-"));
tempDirs.push(root);
const zipA = new AdmZip();
zipA.addFile("Episode.A.mkv", Buffer.from("episode-a"));
const zipB = new AdmZip();
zipB.addFile("Episode.B.mkv", Buffer.from("episode-b"));
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "repair-a", itemId: "repair-a-item", fileName: "Episode.A.zip", content: zipA.toBuffer() },
{ packageId: "repair-b", itemId: "repair-b-item", fileName: "Episode.B.zip", content: zipB.toBuffer() }
]);
const internal = manager as any;
internal.ensureScheduler = vi.fn(async () => {});
for (const item of Object.values(session.items)) {
fs.rmSync(item.targetPath, { force: true });
item.fullStatus = "Entpack-Fehler: Quelldatei fehlt";
item.lastError = "Quelldatei fehlt";
}
await manager.extractNow({ packageIds: ["repair-a", "repair-b"], itemIds: [] });
expect(session.running).toBe(true);
expect((internal.runScopeKind as string)).toBe("selected");
expect([...internal.runItemIds].sort()).toEqual(["repair-a-item", "repair-b-item"]);
expect((session.packages["repair-a"] as any).manualExtractionPending).toBe(true);
expect((session.packages["repair-b"] as any).manualExtractionPending).toBe(true);
expect((session.packages["repair-a"] as any).manualExtractionRepairItemIds).toEqual(["repair-a-item"]);
expect((session.packages["repair-b"] as any).manualExtractionRepairItemIds).toEqual(["repair-b-item"]);
for (const item of Object.values(session.items)) {
expect(item).toEqual(expect.objectContaining({
status: "queued",
downloadedBytes: 0,
progressPercent: 0,
targetPath: "",
fullStatus: "Wartet auf erneuten Download"
}));
}
for (const [itemId, content] of [["repair-a-item", zipA.toBuffer()], ["repair-b-item", zipB.toBuffer()]] as const) {
const item = session.items[itemId];
const pkg = session.packages[item.packageId];
const targetPath = path.join(pkg.outputDir, item.fileName);
fs.writeFileSync(targetPath, content);
item.status = "completed";
item.targetPath = targetPath;
item.downloadedBytes = content.length;
item.totalBytes = content.length;
item.progressPercent = 100;
item.fullStatus = "Entpacken - Ausstehend";
item.updatedAt = Date.now();
}
await Promise.all([
internal.runPackagePostProcessing("repair-a"),
internal.runPackagePostProcessing("repair-b")
]);
await waitFor(() => fs.existsSync(path.join(session.packages["repair-a"].extractDir, "Episode.A.mkv")), 10_000);
await waitFor(() => fs.existsSync(path.join(session.packages["repair-b"].extractDir, "Episode.B.mkv")), 10_000);
expect((session.packages["repair-a"] as any).manualExtractionPending).toBe(false);
expect((session.packages["repair-b"] as any).manualExtractionPending).toBe(false);
expect(session.items["repair-a-item"].fullStatus).toMatch(/^Entpackt/);
expect(session.items["repair-b-item"].fullStatus).toMatch(/^Entpackt/);
});
it("keeps present archive files while repairing only missing siblings", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-partial-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "repair-partial", itemId: "repair-present", fileName: "show.part1.rar" },
{ packageId: "repair-partial", itemId: "repair-missing", fileName: "show.part2.rar" },
{ packageId: "repair-partial", itemId: "repair-video", fileName: "bonus.mkv" }
]);
const internal = manager as any;
internal.ensureScheduler = vi.fn(async () => {});
const presentPath = session.items["repair-present"].targetPath;
fs.rmSync(session.items["repair-missing"].targetPath, { force: true });
for (const item of Object.values(session.items)) {
item.fullStatus = "Entpack-Fehler: Quelldatei fehlt";
item.lastError = "Quelldatei fehlt";
}
session.items["repair-video"].fullStatus = "Fertig (256 B)";
session.items["repair-video"].lastError = "";
await manager.extractNow({ packageIds: [], itemIds: ["repair-present"] });
expect(fs.existsSync(presentPath)).toBe(true);
expect(session.items["repair-present"]).toEqual(expect.objectContaining({
status: "completed",
targetPath: presentPath,
fullStatus: "Entpacken - Ausstehend"
}));
expect(session.items["repair-missing"]).toEqual(expect.objectContaining({
status: "queued",
targetPath: "",
fullStatus: "Wartet auf erneuten Download"
}));
expect(session.items["repair-video"].fullStatus).toBe("Fertig (256 B)");
expect([...internal.runItemIds]).toEqual(["repair-missing"]);
expect((session.packages["repair-partial"] as any).manualExtractionRepairItemIds).toEqual(["repair-missing"]);
});
it("repairs missing archive sets even when another set in the same package is already ready", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-ready-and-repair-"));
tempDirs.push(root);
const readyZip = new AdmZip();
readyZip.addFile("Ready.mkv", Buffer.from("ready"));
const missingZip = new AdmZip();
missingZip.addFile("Missing.mkv", Buffer.from("missing"));
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "ready-repair", itemId: "ready-item", fileName: "Ready.zip", content: readyZip.toBuffer() },
{ packageId: "ready-repair", itemId: "missing-item", fileName: "Missing.zip" }
]);
const internal = manager as any;
internal.ensureScheduler = vi.fn(async () => {});
fs.rmSync(session.items["missing-item"].targetPath, { force: true });
for (const item of Object.values(session.items)) {
item.fullStatus = "Entpack-Fehler: vorheriger Fehler";
item.lastError = "vorheriger Fehler";
}
await manager.extractNow("ready-repair");
await waitFor(() => fs.existsSync(path.join(session.packages["ready-repair"].extractDir, "Ready.mkv")), 10_000);
expect(session.items["ready-item"].fullStatus).toMatch(/^Entpackt/);
expect(session.items["missing-item"].status).toBe("queued");
expect((session.packages["ready-repair"] as any).manualExtractionPending).toBe(true);
expect((session.packages["ready-repair"] as any).manualExtractionRepairItemIds).toEqual(["missing-item"]);
const missingItem = session.items["missing-item"];
const missingPath = path.join(session.packages["ready-repair"].outputDir, missingItem.fileName);
const missingContent = missingZip.toBuffer();
fs.writeFileSync(missingPath, missingContent);
missingItem.status = "completed";
missingItem.targetPath = missingPath;
missingItem.downloadedBytes = missingContent.length;
missingItem.totalBytes = missingContent.length;
missingItem.progressPercent = 100;
missingItem.fullStatus = "Entpacken - Ausstehend";
missingItem.updatedAt = Date.now();
await internal.runPackagePostProcessing("ready-repair");
await waitFor(() => fs.existsSync(path.join(session.packages["ready-repair"].extractDir, "Missing.mkv")), 10_000);
expect(session.items["missing-item"].fullStatus).toMatch(/^Entpackt/);
expect(session.packages["ready-repair"].manualExtractionPending).toBe(false);
expect(session.packages["ready-repair"].manualExtractionRepairItemIds).toEqual([]);
}, 15_000);
it("includes queued and failed missing parts in the same repair run", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-noncompleted-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "repair-noncompleted", itemId: "selected-part", fileName: "show.part1.rar" },
{ packageId: "repair-noncompleted", itemId: "queued-part", fileName: "show.part2.rar" },
{ packageId: "repair-noncompleted", itemId: "failed-part", fileName: "show.part3.rar" }
]);
const internal = manager as any;
internal.ensureScheduler = vi.fn(async () => {});
for (const item of Object.values(session.items)) {
fs.rmSync(item.targetPath, { force: true });
item.fullStatus = "Entpack-Fehler: Quelldatei fehlt";
item.lastError = "Quelldatei fehlt";
}
session.items["queued-part"].status = "queued";
session.items["failed-part"].status = "failed";
await manager.extractNow({ packageIds: [], itemIds: ["selected-part"] });
expect([...internal.runItemIds].sort()).toEqual(["failed-part", "queued-part", "selected-part"]);
expect((session.packages["repair-noncompleted"] as any).manualExtractionRepairItemIds.sort()).toEqual(["failed-part", "queued-part", "selected-part"]);
expect(session.items["failed-part"].status).toBe("queued");
});
it("keeps the persisted extraction request when a repaired download fails", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-failed-download-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "repair-failure", itemId: "repair-failure-item", fileName: "Episode.zip" }
]);
const internal = manager as any;
internal.ensureScheduler = vi.fn(async () => {});
fs.rmSync(session.items["repair-failure-item"].targetPath, { force: true });
session.items["repair-failure-item"].fullStatus = "Entpack-Fehler: Quelldatei fehlt";
await manager.extractNow({ packageIds: [], itemIds: ["repair-failure-item"] });
session.items["repair-failure-item"].status = "failed";
session.items["repair-failure-item"].fullStatus = "Download fehlgeschlagen";
session.items["repair-failure-item"].lastError = "Download fehlgeschlagen";
session.items["repair-failure-item"].updatedAt = Date.now();
await internal.runPackagePostProcessing("repair-failure");
expect(session.packages["repair-failure"].status).toBe("failed");
expect(session.packages["repair-failure"].manualExtractionPending).toBe(true);
expect(session.packages["repair-failure"].manualExtractionRepairItemIds).toEqual(["repair-failure-item"]);
expect((internal.finalizedPackageResults as Map<string, unknown>).size).toBe(1);
const failedGeneration = session.packages["repair-failure"].resultGeneration || 0;
internal.finishRun();
expect((internal.runContexts as Map<string, unknown>).size).toBe(0);
await manager.extractNow({ packageIds: [], itemIds: ["repair-failure-item"] });
expect(session.items["repair-failure-item"].status).toBe("queued");
expect(session.packages["repair-failure"].manualExtractionPending).toBe(true);
expect(session.packages["repair-failure"].resultGeneration).toBeGreaterThan(failedGeneration);
});
it("resumes a persisted manual extraction after restart when repaired downloads are complete", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-restart-"));
tempDirs.push(root);
const zip = new AdmZip();
zip.addFile("Restarted.mkv", Buffer.from("restarted"));
const { session } = createCompletedFileManager(root, [
{ packageId: "repair-restart", itemId: "repair-restart-item", fileName: "Restarted.zip", content: zip.toBuffer() }
]);
session.packages["repair-restart"].manualExtractionPending = true;
session.packages["repair-restart"].manualExtractionRepairItemIds = ["repair-restart-item"];
session.packages["repair-restart"].status = "queued";
session.items["repair-restart-item"].fullStatus = "Wartet auf erneuten Download";
const restarted = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
cleanupMode: "none",
autoRename4sf4sj: false,
collectMkvToLibrary: false,
enableIntegrityCheck: false
},
session,
createStoragePaths(path.join(root, "restart-state"))
);
await waitFor(() => fs.existsSync(path.join(session.packages["repair-restart"].extractDir, "Restarted.mkv")), 10_000);
await waitFor(() => !(restarted as any).packagePostProcessTasks.has("repair-restart"), 10_000);
expect(session.items["repair-restart-item"].fullStatus).toMatch(/^Entpackt/);
expect(session.packages["repair-restart"].manualExtractionPending).toBe(false);
expect(session.packages["repair-restart"].manualExtractionRepairItemIds).toEqual([]);
}, 15_000);
it("emits a package delta when only the persisted manual extraction request changes", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-repair-delta-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "repair-delta", itemId: "repair-delta-item", fileName: "Episode.zip" }
]);
manager.getSnapshotForEmit(true);
session.packages["repair-delta"].manualExtractionPending = true;
session.packages["repair-delta"].manualExtractionRepairItemIds = ["repair-delta-item"];
const snapshot = manager.getSnapshotForEmit();
expect(snapshot.payloadKind).toBe("delta");
expect(snapshot.session.packages["repair-delta"]).toEqual(expect.objectContaining({
manualExtractionPending: true,
manualExtractionRepairItemIds: ["repair-delta-item"]
}));
});
it("recognizes and arms an opaque archive file by signature", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-opaque-rar-"));
tempDirs.push(root);
+21
View File
@@ -98,4 +98,25 @@ describe("extract now context action", () => {
items
})).toBeNull();
});
it("keeps retrying a failed child that belongs to a persisted manual extraction repair", () => {
const failed = item("failed", "pkg-1", "failed", "Download fehlgeschlagen");
const packageEntry = {
...pkg("pkg-1", ["failed"]),
manualExtractionPending: true,
manualExtractionRepairItemIds: ["failed"]
};
expect(buildExtractNowContextAction({
contextItemId: "failed",
selectedPackageIds: [],
selectedItemIds: ["failed"],
packages: { "pkg-1": packageEntry },
items: { failed }
})).toEqual({
label: "Jetzt entpacken",
request: { packageIds: [], itemIds: ["failed"] },
targetCount: 1
});
});
});
+8 -4
View File
@@ -1498,8 +1498,10 @@ describe("settings storage", () => {
status: "downloading",
itemIds: ["item1", "item2", "item3", "item4"],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
enabled: true,
manualExtractionPending: true,
manualExtractionRepairItemIds: ["item1", "missing-item", "item1"],
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: Date.now(),
updatedAt: Date.now()
@@ -1595,8 +1597,10 @@ describe("settings storage", () => {
expect(loaded.items["item3"].status).toBe("completed");
expect(loaded.items["item4"].status).toBe("queued");
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
expect(loaded.packages["pkg1"].name).toBe("Test Package");
});
expect(loaded.packages["pkg1"].name).toBe("Test Package");
expect(loaded.packages["pkg1"].manualExtractionPending).toBe(true);
expect(loaded.packages["pkg1"].manualExtractionRepairItemIds).toEqual(["item1"]);
});
it("preserves cleaned package progress aggregates while normalizing a session", () => {
const session = emptySession();