diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 92b1ce2..4e534de 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -406,6 +406,9 @@ function shouldRejectSuspiciousSmallDownload( return expected > 0 || binaryLike; } if (size < 512) { + if (expected > 0 && size >= expected && binaryLike) { + return false; + } return true; } if (size >= MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES) { @@ -600,6 +603,11 @@ function shouldAutoRetryExtraction(statusText: string): boolean { return !isExtractedLabel(statusText) && !isExtractErrorLabel(statusText); } +function shouldPreserveExtractionResumeLabel(statusText: string): boolean { + const text = String(statusText || "").trim(); + return isTransientExtractStatus(text) || /^entpacken abgebrochen\b/i.test(text); +} + function formatExtractDone(elapsedMs: number): string { if (elapsedMs < 1000) return "Entpackt - Done (<1s)"; const secs = elapsedMs / 1000; @@ -1818,6 +1826,7 @@ export class DownloadManager extends EventEmitter { const stats = { totalDownloaded: this.sessionDownloadedBytes, totalDownloadedAllTime: this.settings.totalDownloadedAllTime, + totalFiles: this.sessionCompletedFiles, totalFilesSession: this.sessionCompletedFiles, totalFilesAllTime: this.settings.totalCompletedFilesAllTime, totalPackages: this.session.packageOrder.length, @@ -1858,6 +1867,9 @@ export class DownloadManager extends EventEmitter { } const now = nowMs(); if (!this.foldRuntimeIntoSettings(now)) { + if (sync && !fs.existsSync(this.storagePaths.configFile)) { + saveSettings(this.storagePaths, this.settings); + } return; } this.lastSettingsPersistAt = now; @@ -1873,13 +1885,51 @@ export class DownloadManager extends EventEmitter { this.statsCacheAt = 0; } - private resetSessionTotalsIfQueueEmpty(): void { + private resetSessionTotalsIfQueueEmpty(force = false): void { if (this.itemCount > 0 || this.session.packageOrder.length > 0) { return; } if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) { return; } + if (!force && (this.sessionDownloadedBytes > 0 || this.sessionCompletedFiles > 0 || this.itemContributedBytes.size > 0)) { + return; + } + let changed = false; + if (this.session.totalDownloadedBytes !== 0) { + this.session.totalDownloadedBytes = 0; + changed = true; + } + if (this.sessionDownloadedBytes !== 0) { + this.sessionDownloadedBytes = 0; + changed = true; + } + if (this.sessionCompletedFiles !== 0) { + this.sessionCompletedFiles = 0; + changed = true; + } + if (this.session.runStartedAt !== 0) { + this.session.runStartedAt = 0; + changed = true; + } + if (this.session.summaryText) { + this.session.summaryText = ""; + changed = true; + } + if (this.lastGlobalProgressBytes !== 0) { + this.lastGlobalProgressBytes = 0; + changed = true; + } + if (this.speedEvents.length > 0 || this.speedBytesLastWindow !== 0 || this.speedBytesPerPackage.size > 0) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + changed = true; + } + if (changed) { + this.invalidateStatsCache(); + } } private getPackagePostProcessVersion(packageId: string): number { @@ -2223,7 +2273,7 @@ export class DownloadManager extends EventEmitter { this.packagePostProcessWaiters = []; this.summary = null; this.nonResumableActive = 0; - this.resetSessionTotalsIfQueueEmpty(); + this.resetSessionTotalsIfQueueEmpty(true); this.persistNow(); this.emitState(true); } @@ -4663,7 +4713,7 @@ export class DownloadManager extends EventEmitter { pkg.status = "completed"; } } - this.resetSessionTotalsIfQueueEmpty(); + this.resetSessionTotalsIfQueueEmpty(true); this.persistSoon(); } @@ -5178,6 +5228,10 @@ export class DownloadManager extends EventEmitter { } } catch { // file doesn't exist — reset to queued so it gets re-downloaded + if (archiveLike && this.shouldPreserveMissingCompletedArchiveForStartupRecovery(item)) { + logger.info(`revalidateCompleted: ${item.fileName} Quelle fehlt, belasse fuer Startup-Recovery`); + continue; + } logger.warn(`revalidateCompleted: ${item.fileName} Datei nicht gefunden, setze auf queued`); item.status = "queued"; item.fullStatus = "Wartet"; @@ -5200,6 +5254,23 @@ export class DownloadManager extends EventEmitter { } } + private shouldPreserveMissingCompletedArchiveForStartupRecovery(item: DownloadItem): boolean { + if (!this.settings.autoExtract) { + return false; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || pkg.enabled === false) { + return false; + } + const statusText = String(item.fullStatus || "").trim(); + if (isExtractedLabel(statusText) || isExtractErrorLabel(statusText)) { + return false; + } + return /^Fertig\b/i.test(statusText) + || shouldPreserveExtractionResumeLabel(statusText) + || shouldAutoRetryExtraction(statusText); + } + private tryFinalizeItemFromDisk( pkg: PackageEntry, item: DownloadItem, @@ -5943,7 +6014,9 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); for (const item of items) { if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; + if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + } item.updatedAt = nowMs(); } } @@ -5964,7 +6037,9 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); for (const item of items) { if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { - item.fullStatus = "Entpacken - Ausstehend"; + if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) { + item.fullStatus = "Entpacken - Ausstehend"; + } item.updatedAt = nowMs(); } } @@ -8749,6 +8824,16 @@ export class DownloadManager extends EventEmitter { snippet = await fs.promises.readFile(effectiveTargetPath, "utf8"); snippet = snippet.slice(0, 200).replace(/[\r\n]+/g, " ").trim(); } catch { /* ignore */ } + const exactTinyBinary = Boolean( + item.totalBytes + && item.totalBytes > 0 + && written >= item.totalBytes + && isLargeBinaryLikePath(item.fileName || effectiveTargetPath) + ); + const snippetSuggestsError = /<(?:!doctype|html|body)\b|\b(?:forbidden|access denied|error|not found|expired|unavailable)\b/i.test(snippet); + if (exactTinyBinary && !snippetSuggestsError) { + logger.info(`Tiny Binary akzeptiert (${written} B): ${item.fileName || effectiveTargetPath}`); + } else { logger.warn(`Tiny download erkannt (${written} B): "${snippet}"`); try { await fs.promises.rm(effectiveTargetPath, { force: true }); @@ -8758,6 +8843,7 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.progressPercent = 0; throw new Error(`Download zu klein (${written} B) – Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); + } } const exactLengthRequired = isLargeBinaryLikePath(item.fileName || effectiveTargetPath); diff --git a/src/shared/types.ts b/src/shared/types.ts index 4d16386..0e27fc8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -42,6 +42,7 @@ export interface BandwidthScheduleEntry { export interface DownloadStats { totalDownloaded: number; totalDownloadedAllTime: number; + totalFiles: number; totalFilesSession: number; totalFilesAllTime: number; totalPackages: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index ba7266d..63de81f 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6120,12 +6120,12 @@ describe("download manager", () => { await new Promise((resolve) => setTimeout(resolve, 140)); expect(fs.existsSync(extractDir)).toBe(false); - await waitFor(() => !manager.getSnapshot().session.running, 30000); + await waitFor(() => fs.existsSync(path.join(extractDir, "inside.txt")), 30000); const snapshot = manager.getSnapshot(); const item = Object.values(snapshot.session.items)[0]; expect(item?.status).toBe("completed"); - expect(item?.fullStatus).toBe("Entpackt - Done"); + expect(item?.fullStatus.startsWith("Entpackt - Done")).toBe(true); expect(fs.existsSync(extractDir)).toBe(true); expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true); } finally { @@ -7111,7 +7111,7 @@ describe("download manager", () => { await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000); const snapshot = manager.getSnapshot(); expect(snapshot.session.packages[packageId]?.status).toBe("completed"); - expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt - Done"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); }); it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => { @@ -7178,7 +7178,7 @@ describe("download manager", () => { await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000); const snapshot = manager.getSnapshot(); expect(snapshot.session.packages[packageId]?.status).toBe("completed"); - expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt - Done"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); }); it("marks missing source package dir as extracted instead of failed", async () => { @@ -7850,7 +7850,7 @@ describe("download manager", () => { await waitFor(() => fs.existsSync(expectedPath), 12000); const snapshot = manager.getSnapshot(); expect(snapshot.session.packages[packageId]?.status).toBe("completed"); - expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt - Done"); + expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true); expect(fs.existsSync(expectedPath)).toBe(true); expect(fs.existsSync(originalExtractedPath)).toBe(false); });