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:
@@ -25,6 +25,17 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
- Treat invalid, missing, blocked, banned, server-restricted, and IP-restricted credentials as terminal authentication failures without provider cooldowns.
|
||||
- Preserve official AllDebrid error codes alongside their messages so retry and cooldown decisions remain precise.
|
||||
|
||||
### Archive extraction reliability
|
||||
|
||||
- Resolve multiple selected child rows from the same complete multipart archive as one extraction set.
|
||||
- Stop before launching an extractor when a completed archive part is missing, incomplete, or no longer matches the persisted download state.
|
||||
- Report the affected archive file instead of returning a generic no-extractable-set error for stale completed selections.
|
||||
- Preserve startup recovery for packages whose source archives were intentionally cleaned after a completed extraction.
|
||||
|
||||
### Download list clarity
|
||||
|
||||
- Keep the status column empty for parent packages that have never started while retaining meaningful queued, active, and terminal statuses after the first run.
|
||||
|
||||
## [2.0.65] - 2026-08-23
|
||||
|
||||
### Extract now behavior
|
||||
|
||||
+116
-27
@@ -9098,11 +9098,12 @@ export class DownloadManager extends EventEmitter {
|
||||
return fixedCount;
|
||||
}
|
||||
|
||||
private async waitForCompletedArchiveFilesToSettle(
|
||||
pkg: PackageEntry,
|
||||
items: DownloadItem[],
|
||||
signal: AbortSignal | undefined,
|
||||
scope: "hybrid" | "full"
|
||||
private async waitForCompletedArchiveFilesToSettle(
|
||||
pkg: PackageEntry,
|
||||
items: DownloadItem[],
|
||||
signal: AbortSignal | undefined,
|
||||
scope: "hybrid" | "full",
|
||||
allowMissingSourceRecovery = false
|
||||
): Promise<void> {
|
||||
const archiveItems = items.filter((item) =>
|
||||
item.status === "completed" && isArchiveLikePath(item.targetPath || item.fileName || "")
|
||||
@@ -9209,13 +9210,24 @@ export class DownloadManager extends EventEmitter {
|
||||
`Extract-Settle (${scope}) Timeout: pkg=${pkg.name}, archiveItems=${archiveItems.length}, ` +
|
||||
`waitMs=${settleMs}, pending=${lastPending || "none"}`
|
||||
);
|
||||
this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", {
|
||||
scope,
|
||||
archiveItems: archiveItems.length,
|
||||
waitMs: settleMs,
|
||||
pending: lastPending || "none"
|
||||
});
|
||||
}
|
||||
this.logPackageForPackage(pkg, "WARN", "Archiv-Stabilisierung Timeout", {
|
||||
scope,
|
||||
archiveItems: archiveItems.length,
|
||||
waitMs: settleMs,
|
||||
pending: lastPending || "none"
|
||||
});
|
||||
const hasAnySourceArchive = archiveItems.some((item) => inspectPackageItemDiskState(pkg, item).exists);
|
||||
if (!hasAnySourceArchive && allowMissingSourceRecovery) {
|
||||
const sourceDirectoryExists = await fs.promises.stat(pkg.outputDir)
|
||||
.then((stat) => stat.isDirectory(), () => false);
|
||||
const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles()
|
||||
.some((filePath) => isPathInsideDir(filePath, pkg.extractDir) && fs.existsSync(filePath));
|
||||
if (!sourceDirectoryExists || hasExtractedOutput) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Archivdateien nicht bereit: ${lastPending || "unbekannt"}`);
|
||||
}
|
||||
|
||||
private fixDuplicateSuffixFiles(): void {
|
||||
const SUFFIX_RE = /^(.+) \(\d+\)(\.[^.]+)$/;
|
||||
@@ -9382,7 +9394,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState(true);
|
||||
}
|
||||
|
||||
private runPackagePostProcessing(packageId: string): Promise<void> {
|
||||
private runPackagePostProcessing(
|
||||
packageId: string,
|
||||
options: { allowMissingSourceRecovery?: boolean } = {}
|
||||
): Promise<void> {
|
||||
this.trackPackagePostProcessResult(packageId);
|
||||
const existing = this.packagePostProcessTasks.get(packageId);
|
||||
if (existing) {
|
||||
@@ -9416,7 +9431,11 @@ export class DownloadManager extends EventEmitter {
|
||||
this.hybridExtractRequeue.delete(packageId);
|
||||
const roundStart = nowMs();
|
||||
try {
|
||||
await this.handlePackagePostProcessing(packageId, abortController.signal);
|
||||
await this.handlePackagePostProcessing(
|
||||
packageId,
|
||||
abortController.signal,
|
||||
options.allowMissingSourceRecovery === true
|
||||
);
|
||||
} catch (error) {
|
||||
if (this.isExpectedPostProcessAbort(error, abortController.signal)) {
|
||||
logger.info(`Post-Processing für Paket abgebrochen: ${compactErrorText(error)}`);
|
||||
@@ -9564,7 +9583,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`));
|
||||
void this.runPackagePostProcessing(packageId, { allowMissingSourceRecovery: true }).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`));
|
||||
} else if (pkg.status !== "completed") {
|
||||
pkg.status = "completed";
|
||||
pkg.updatedAt = nowMs();
|
||||
@@ -9754,6 +9773,62 @@ export class DownloadManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
private describeManualExtractionDiskFailure(
|
||||
packageId: string,
|
||||
selectedItemIds?: ReadonlySet<string>
|
||||
): string | null {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
return null;
|
||||
}
|
||||
const packageItems = pkg.itemIds
|
||||
.map((itemId) => this.session.items[itemId])
|
||||
.filter((item): item is DownloadItem => Boolean(item));
|
||||
const seeds = selectedItemIds
|
||||
? packageItems.filter((item) => selectedItemIds.has(item.id))
|
||||
: packageItems;
|
||||
const pathlessItems = packageItems.filter((item) => !String(item.targetPath || "").trim());
|
||||
const pathlessNameCounts = new Map<string, number>();
|
||||
for (const item of pathlessItems) {
|
||||
const key = path.basename(item.fileName || "").toLocaleLowerCase("en-US");
|
||||
pathlessNameCounts.set(key, (pathlessNameCounts.get(key) || 0) + 1);
|
||||
}
|
||||
const relatedItems = new Map<string, DownloadItem>();
|
||||
for (const seed of seeds) {
|
||||
const filePath = String(seed.targetPath || seed.fileName || "").trim();
|
||||
if (!isArchiveLikePath(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const qualified = resolveArchiveItemsFromList(path.basename(filePath), packageItems, seed.targetPath || "");
|
||||
const legacy = resolveArchiveItemsFromList(path.basename(filePath), pathlessItems)
|
||||
.filter((item) => (pathlessNameCounts.get(path.basename(item.fileName || "").toLocaleLowerCase("en-US")) || 0) === 1);
|
||||
const resolved = [...new Map([...qualified, ...legacy].map((item) => [item.id, item])).values()];
|
||||
for (const item of resolved.length > 0 ? resolved : [seed]) {
|
||||
relatedItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
for (const item of relatedItems.values()) {
|
||||
if (item.status !== "completed") {
|
||||
continue;
|
||||
}
|
||||
const state = inspectPackageItemDiskState(pkg, item);
|
||||
if (state.reason === "ok") {
|
||||
continue;
|
||||
}
|
||||
const fileName = path.basename(item.targetPath || item.fileName || item.id);
|
||||
if (state.reason === "missing_file" || state.reason === "missing_path") {
|
||||
return `Archivdatei fehlt: ${fileName}`;
|
||||
}
|
||||
if (state.reason === "too_small") {
|
||||
return `Archivdatei unvollständig: ${fileName} (${humanSize(state.size)} von mindestens ${humanSize(state.minBytes)})`;
|
||||
}
|
||||
if (state.reason === "persisted_shortfall") {
|
||||
return `Archivstatus unvollständig: ${fileName}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean {
|
||||
const pkg = this.session.packages[plan.packageId];
|
||||
if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) {
|
||||
@@ -9861,18 +9936,27 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
const plans: ManualExtractionPlan[] = [];
|
||||
const rejectionDetails: string[] = [];
|
||||
for (const packageId of packageIds) {
|
||||
const plan = await this.resolveManualExtractionPlan(packageId);
|
||||
if (plan) plans.push(plan);
|
||||
else rejected += 1;
|
||||
else {
|
||||
rejected += 1;
|
||||
const detail = this.describeManualExtractionDiskFailure(packageId);
|
||||
if (detail) rejectionDetails.push(detail);
|
||||
}
|
||||
}
|
||||
for (const [packageId, itemIds] of itemIdsByPackage) {
|
||||
const plan = await this.resolveManualExtractionPlan(packageId, itemIds);
|
||||
if (plan) plans.push(plan);
|
||||
else rejected += 1;
|
||||
else {
|
||||
rejected += 1;
|
||||
const detail = this.describeManualExtractionDiskFailure(packageId, itemIds);
|
||||
if (detail) rejectionDetails.push(detail);
|
||||
}
|
||||
}
|
||||
if (plans.length === 0) {
|
||||
throw new Error("Kein entpackbarer Archivsatz ausgewählt");
|
||||
throw new Error(rejectionDetails[0] || "Kein entpackbarer Archivsatz ausgewählt");
|
||||
}
|
||||
if (rejected > 0) {
|
||||
logger.info(`Jetzt entpacken: ${plans.length} Entpackvorgang/Vorgänge bereit, ${rejected} Auswahl(en) ohne vollständigen Archivsatz übersprungen`);
|
||||
@@ -13994,8 +14078,8 @@ export class DownloadManager extends EventEmitter {
|
||||
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
||||
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
|
||||
const candidateStem = path.basename(candidate).toLowerCase();
|
||||
const hasUnreadyPendingPart = packageItems.some((item) => item.status !== "completed"
|
||||
&& this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem)
|
||||
const hasUnreadyPendingPart = packageItems.some((item) =>
|
||||
this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem)
|
||||
&& inspectPackageItemDiskState(pkg, item).reason !== "ok");
|
||||
if (hasUnreadyPendingPart) {
|
||||
continue;
|
||||
@@ -14509,7 +14593,11 @@ export class DownloadManager extends EventEmitter {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
|
||||
private async handlePackagePostProcessing(
|
||||
packageId: string,
|
||||
signal?: AbortSignal,
|
||||
allowMissingSourceRecovery = false
|
||||
): Promise<void> {
|
||||
const handleStart = nowMs();
|
||||
const postProcessVersion = this.getPackagePostProcessVersion(packageId);
|
||||
const pkg = this.session.packages[packageId];
|
||||
@@ -14748,12 +14836,13 @@ export class DownloadManager extends EventEmitter {
|
||||
const fullStartTimes = new Map<string, number>();
|
||||
let fullLastProgressCurrent: number | null = null;
|
||||
|
||||
await this.waitForCompletedArchiveFilesToSettle(
|
||||
pkg,
|
||||
completedItems,
|
||||
extractAbortController.signal,
|
||||
"full"
|
||||
);
|
||||
await this.waitForCompletedArchiveFilesToSettle(
|
||||
pkg,
|
||||
completedItems,
|
||||
extractAbortController.signal,
|
||||
"full",
|
||||
allowMissingSourceRecovery && !manualExtraction
|
||||
);
|
||||
if (extractAbortController.signal.aborted) {
|
||||
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
|
||||
}
|
||||
|
||||
@@ -166,6 +166,29 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
|
||||
status = "Entpacken";
|
||||
}
|
||||
|
||||
const hasLifecycleTimestamp = [
|
||||
row.package.downloadStartedAt,
|
||||
row.package.downloadCompletedAt,
|
||||
row.package.downloadEndedAt,
|
||||
row.package.postProcessQueuedAt,
|
||||
row.package.postProcessStartedAt,
|
||||
row.package.postProcessCompletedAt,
|
||||
row.package.terminalAt
|
||||
].some((value) => Number(value || 0) > 0);
|
||||
const hasItemActivity = row.allItems.some((item) =>
|
||||
item.status !== "queued"
|
||||
|| Number(item.attempts || 0) > 0
|
||||
|| Number(item.retries || 0) > 0
|
||||
|| Number(item.downloadedBytes || 0) > 0
|
||||
|| Number(item.progressPercent || 0) > 0
|
||||
);
|
||||
const neverStarted = (row.package.status === "queued" || row.package.status === "paused")
|
||||
&& !hasLifecycleTimestamp
|
||||
&& !hasItemActivity;
|
||||
if (neverStarted) {
|
||||
status = "";
|
||||
}
|
||||
|
||||
return {
|
||||
progress: { done, failed, cancelled, total, value },
|
||||
status,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user