fix(extraction): block stale multipart runs in v2.0.66

Resolve complete child multipart selections as a single extraction set and report missing or incomplete local parts precisely. Stop manual, hybrid, and normal automatic extraction before invoking a backend when persisted completion no longer matches disk state, while retaining the explicit startup recovery path for intentionally cleaned sources. Keep never-started parent package statuses empty without hiding real retry or lifecycle activity, and stabilize the visual pointer test against its existing readiness budget.
This commit is contained in:
Sucukdeluxe
2026-08-23 17:15:33 +02:00
parent c46f9a1d7a
commit 50d7748986
6 changed files with 445 additions and 28 deletions
+230
View File
@@ -2689,6 +2689,91 @@ describe("download manager", () => {
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]);
});
it("extractNow accepts every selected child of one complete multipart archive", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-multipart-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "extract-selected-multipart";
const outputDir = path.join(root, "downloads", "Switched at Birth");
const extractDir = path.join(root, "extract", "Switched at Birth");
fs.mkdirSync(outputDir, { recursive: true });
const createdAt = Date.now();
const specs = [
["part-1", "tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar", 128],
["part-2", "tvs-sab-dd51-dl-7p-azhd-avc-405.part2.rar", 128]
] as const;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Switched.at.Birth.S04.German.DD+51.DL.720p.AmazonHD.AVC-TVS",
outputDir,
extractDir,
status: "failed",
itemIds: specs.map(([id]) => id),
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, fileName, totalBytes] of specs) {
const targetPath = path.join(outputDir, fileName);
fs.writeFileSync(targetPath, Buffer.alloc(128, 3));
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${fileName}`,
provider: "alldebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: totalBytes,
totalBytes,
progressPercent: 100,
fileName,
targetPath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: false, hybridExtract: true },
session,
createStoragePaths(path.join(root, "state"))
);
const postProcess = vi.fn(async () => {});
const internal = manager as any;
internal.runPackagePostProcessing = postProcess;
internal.ensureScheduler = vi.fn(async () => {});
session.running = true;
session.paused = false;
const candidates = [...await internal.findReadyArchiveSets(session.packages[packageId])];
expect(candidates.map((filePath) => path.basename(filePath))).toEqual([
"tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar"
]);
const resolved = resolveSelectedArchiveSetsFromCandidates(
candidates,
specs.map(([id]) => session.items[id]),
new Set(["part-1", "part-2"])
);
expect([...resolved.itemIds].sort()).toEqual(["part-1", "part-2"]);
await manager.extractNow({ packageIds: [], itemIds: ["part-1", "part-2"] });
expect(postProcess).toHaveBeenCalledWith(packageId);
expect(internal.ensureScheduler).not.toHaveBeenCalled();
expect(session.items["part-1"].fullStatus).toBe("Entpacken - Ausstehend");
expect(session.items["part-2"].fullStatus).toBe("Entpacken - Ausstehend");
const filter = internal.manualExtractArchiveFilters.get(packageId) as Set<string>;
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual([
"tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar"
]);
});
it("extractNow item selection runs only the selected archive through real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-"));
tempDirs.push(root);
@@ -17161,6 +17246,151 @@ describe("post-processing lifecycle audit", () => {
return { manager, session };
}
it("extractNow reports the missing file behind a stale completed multipart selection", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-multipart-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "stale-multipart", itemId: "stale-part-1", fileName: "show.part1.rar" },
{ packageId: "stale-multipart", itemId: "stale-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["stale-part-2"].targetPath);
await expect(manager.extractNow({
packageIds: [],
itemIds: ["stale-part-1", "stale-part-2"]
})).rejects.toThrow("Archivdatei fehlt: show.part2.rar");
});
it("extractNow reports a pathless missing sibling behind one selected multipart child", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-pathless-multipart-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "stale-pathless", itemId: "pathless-part-1", fileName: "show.part1.rar" },
{ packageId: "stale-pathless", itemId: "pathless-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["pathless-part-2"].targetPath);
session.items["pathless-part-2"].targetPath = "";
await expect(manager.extractNow({
packageIds: [],
itemIds: ["pathless-part-1"]
})).rejects.toThrow("Archivdatei fehlt: show.part2.rar");
});
it("stops before extraction when a completed archive file never stabilizes on disk", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "settle-missing", itemId: "settle-part-1", fileName: "show.part1.rar" },
{ packageId: "settle-missing", itemId: "settle-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["settle-part-2"].targetPath);
await expect((manager as any).waitForCompletedArchiveFilesToSettle(
session.packages["settle-missing"],
[session.items["settle-part-1"], session.items["settle-part-2"]],
undefined,
"full"
)).rejects.toThrow("Archivdateien nicht bereit: show.part2.rar:missing_file");
}, 8_000);
it("does not invoke the extractor after a multipart source disappears during post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "postprocess-missing", itemId: "postprocess-part-1", fileName: "show.part1.rar" },
{ packageId: "postprocess-missing", itemId: "postprocess-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["postprocess-part-2"].targetPath);
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.manualExtractPackages.add("postprocess-missing");
await internal.handlePackagePostProcessing("postprocess-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["postprocess-missing"].status).toBe("failed");
expect(session.items["postprocess-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["postprocess-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("does not treat a fully vanished manual multipart source as a successful extraction", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-all-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "postprocess-all-missing", itemId: "all-missing-part-1", fileName: "show.part1.rar" },
{ packageId: "postprocess-all-missing", itemId: "all-missing-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["all-missing-part-1"].targetPath);
fs.rmSync(session.items["all-missing-part-2"].targetPath);
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.manualExtractPackages.add("postprocess-all-missing");
await internal.handlePackagePostProcessing("postprocess-all-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["postprocess-all-missing"].status).toBe("failed");
expect(session.items["all-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["all-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("does not treat a fully vanished automatic runtime source as startup recovery", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-runtime-all-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "runtime-all-missing", itemId: "runtime-missing-part-1", fileName: "show.part1.rar" },
{ packageId: "runtime-all-missing", itemId: "runtime-missing-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["runtime-missing-part-1"].targetPath);
fs.rmSync(session.items["runtime-missing-part-2"].targetPath);
fs.rmSync(session.packages["runtime-all-missing"].outputDir, { recursive: true });
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.settings.autoExtract = true;
await internal.handlePackagePostProcessing("runtime-all-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["runtime-all-missing"].status).toBe("failed");
expect(session.items["runtime-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["runtime-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("allows startup recovery after every source archive was intentionally cleaned", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-cleaned-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "settle-cleaned", itemId: "cleaned-part-1", fileName: "show.part1.rar" },
{ packageId: "settle-cleaned", itemId: "cleaned-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["cleaned-part-1"].targetPath);
fs.rmSync(session.items["cleaned-part-2"].targetPath);
fs.writeFileSync(path.join(session.packages["settle-cleaned"].outputDir, "release.nfo"), "release");
const extractedPath = path.join(session.packages["settle-cleaned"].extractDir, "episode.mkv");
fs.mkdirSync(path.dirname(extractedPath), { recursive: true });
fs.writeFileSync(extractedPath, "video");
(manager as any).getPackageOutputScope(session.packages["settle-cleaned"]).add({
version: 1,
archivePath: session.items["cleaned-part-1"].targetPath,
entryPath: "episode.mkv",
outputPath: extractedPath,
state: "complete",
disposition: "written"
});
await expect((manager as any).waitForCompletedArchiveFilesToSettle(
session.packages["settle-cleaned"],
[session.items["cleaned-part-1"], session.items["cleaned-part-2"]],
undefined,
"full",
true
)).resolves.toBeUndefined();
}, 8_000);
it("requeues an interrupted integrity check and clears transient package progress on restart", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-"));
tempDirs.push(root);
+64
View File
@@ -46,6 +46,70 @@ function row(items: DownloadItem[], overrides: Partial<PackageEntry> = {}): Down
}
describe("download package presentation", () => {
it("keeps the status empty for an over-package that has never started", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Wartet", {
status: "queued",
attempts: 0,
retries: 0,
downloadedBytes: 0,
progressPercent: 0
})
], {
status: "queued",
downloadStartedAt: 0,
downloadCompletedAt: 0,
downloadEndedAt: 0,
postProcessQueuedAt: 0,
postProcessStartedAt: 0,
postProcessCompletedAt: 0,
terminalAt: 0
}));
expect(presentation.status).toBe("");
});
it("keeps the status empty for a disabled never-started over-package", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Paket gestoppt", {
status: "queued",
attempts: 0,
retries: 0,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "paused", enabled: false }));
expect(presentation.status).toBe("");
});
it("keeps retry activity visible when an older queued package lacks lifecycle timestamps", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Link-Umwandlung erneut, Versuch 1/...", {
status: "queued",
attempts: 1,
retries: 1,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "queued", downloadStartedAt: 0 }));
expect(presentation.status).toBe("Link-Umwandlung erneut");
});
it("keeps a queued status visible after the over-package has started once", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Link-Umwandlung erneut, Versuch 1/...", {
status: "queued",
retries: 1,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "queued", downloadStartedAt: 1 }));
expect(presentation.status).toBe("Link-Umwandlung erneut");
});
it("reserves 90 percent for completed downloads and 10 percent for extraction", () => {
expect(buildPackagePresentation(row([
item("a", "Entpack-Fehler: Passwort"),
@@ -301,7 +301,7 @@ describe("download disclosure in the headless visual harness", () => {
expect(ascending.ariaSort).toBe("ascending");
expect(descending.names.length).toBeGreaterThan(1);
expect(ascending.names).toEqual([...descending.names].reverse());
});
}, 25_000);
async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> {
if (!client) throw new Error("Chrome DevTools client is missing");