Harden startup recovery and stats edge cases

This commit is contained in:
Sucukdeluxe 2026-03-10 01:50:16 +01:00
parent 484819325e
commit 17604910b5
3 changed files with 97 additions and 10 deletions

View File

@ -406,6 +406,9 @@ function shouldRejectSuspiciousSmallDownload(
return expected > 0 || binaryLike; return expected > 0 || binaryLike;
} }
if (size < 512) { if (size < 512) {
if (expected > 0 && size >= expected && binaryLike) {
return false;
}
return true; return true;
} }
if (size >= MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES) { if (size >= MINI_DOWNLOAD_RETRY_THRESHOLD_BYTES) {
@ -600,6 +603,11 @@ function shouldAutoRetryExtraction(statusText: string): boolean {
return !isExtractedLabel(statusText) && !isExtractErrorLabel(statusText); 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 { function formatExtractDone(elapsedMs: number): string {
if (elapsedMs < 1000) return "Entpackt - Done (<1s)"; if (elapsedMs < 1000) return "Entpackt - Done (<1s)";
const secs = elapsedMs / 1000; const secs = elapsedMs / 1000;
@ -1818,6 +1826,7 @@ export class DownloadManager extends EventEmitter {
const stats = { const stats = {
totalDownloaded: this.sessionDownloadedBytes, totalDownloaded: this.sessionDownloadedBytes,
totalDownloadedAllTime: this.settings.totalDownloadedAllTime, totalDownloadedAllTime: this.settings.totalDownloadedAllTime,
totalFiles: this.sessionCompletedFiles,
totalFilesSession: this.sessionCompletedFiles, totalFilesSession: this.sessionCompletedFiles,
totalFilesAllTime: this.settings.totalCompletedFilesAllTime, totalFilesAllTime: this.settings.totalCompletedFilesAllTime,
totalPackages: this.session.packageOrder.length, totalPackages: this.session.packageOrder.length,
@ -1858,6 +1867,9 @@ export class DownloadManager extends EventEmitter {
} }
const now = nowMs(); const now = nowMs();
if (!this.foldRuntimeIntoSettings(now)) { if (!this.foldRuntimeIntoSettings(now)) {
if (sync && !fs.existsSync(this.storagePaths.configFile)) {
saveSettings(this.storagePaths, this.settings);
}
return; return;
} }
this.lastSettingsPersistAt = now; this.lastSettingsPersistAt = now;
@ -1873,13 +1885,51 @@ export class DownloadManager extends EventEmitter {
this.statsCacheAt = 0; this.statsCacheAt = 0;
} }
private resetSessionTotalsIfQueueEmpty(): void { private resetSessionTotalsIfQueueEmpty(force = false): void {
if (this.itemCount > 0 || this.session.packageOrder.length > 0) { if (this.itemCount > 0 || this.session.packageOrder.length > 0) {
return; return;
} }
if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) { if (Object.keys(this.session.items).length > 0 || Object.keys(this.session.packages).length > 0) {
return; 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 { private getPackagePostProcessVersion(packageId: string): number {
@ -2223,7 +2273,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessWaiters = []; this.packagePostProcessWaiters = [];
this.summary = null; this.summary = null;
this.nonResumableActive = 0; this.nonResumableActive = 0;
this.resetSessionTotalsIfQueueEmpty(); this.resetSessionTotalsIfQueueEmpty(true);
this.persistNow(); this.persistNow();
this.emitState(true); this.emitState(true);
} }
@ -4663,7 +4713,7 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
} }
this.resetSessionTotalsIfQueueEmpty(); this.resetSessionTotalsIfQueueEmpty(true);
this.persistSoon(); this.persistSoon();
} }
@ -5178,6 +5228,10 @@ export class DownloadManager extends EventEmitter {
} }
} catch { } catch {
// file doesn't exist — reset to queued so it gets re-downloaded // 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`); logger.warn(`revalidateCompleted: ${item.fileName} Datei nicht gefunden, setze auf queued`);
item.status = "queued"; item.status = "queued";
item.fullStatus = "Wartet"; 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( private tryFinalizeItemFromDisk(
pkg: PackageEntry, pkg: PackageEntry,
item: DownloadItem, item: DownloadItem,
@ -5943,7 +6014,9 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
for (const item of items) { for (const item of items) {
if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) {
item.fullStatus = "Entpacken - Ausstehend"; if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) {
item.fullStatus = "Entpacken - Ausstehend";
}
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
} }
@ -5964,7 +6037,9 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
for (const item of items) { for (const item of items) {
if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) { if (item.status === "completed" && shouldAutoRetryExtraction(item.fullStatus)) {
item.fullStatus = "Entpacken - Ausstehend"; if (!shouldPreserveExtractionResumeLabel(item.fullStatus)) {
item.fullStatus = "Entpacken - Ausstehend";
}
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
} }
@ -8749,6 +8824,16 @@ export class DownloadManager extends EventEmitter {
snippet = await fs.promises.readFile(effectiveTargetPath, "utf8"); snippet = await fs.promises.readFile(effectiveTargetPath, "utf8");
snippet = snippet.slice(0, 200).replace(/[\r\n]+/g, " ").trim(); snippet = snippet.slice(0, 200).replace(/[\r\n]+/g, " ").trim();
} catch { /* ignore */ } } 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}"`); logger.warn(`Tiny download erkannt (${written} B): "${snippet}"`);
try { try {
await fs.promises.rm(effectiveTargetPath, { force: true }); await fs.promises.rm(effectiveTargetPath, { force: true });
@ -8758,6 +8843,7 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.progressPercent = 0; item.progressPercent = 0;
throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`); throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);
}
} }
const exactLengthRequired = isLargeBinaryLikePath(item.fileName || effectiveTargetPath); const exactLengthRequired = isLargeBinaryLikePath(item.fileName || effectiveTargetPath);

View File

@ -42,6 +42,7 @@ export interface BandwidthScheduleEntry {
export interface DownloadStats { export interface DownloadStats {
totalDownloaded: number; totalDownloaded: number;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalFiles: number;
totalFilesSession: number; totalFilesSession: number;
totalFilesAllTime: number; totalFilesAllTime: number;
totalPackages: number; totalPackages: number;

View File

@ -6120,12 +6120,12 @@ describe("download manager", () => {
await new Promise((resolve) => setTimeout(resolve, 140)); await new Promise((resolve) => setTimeout(resolve, 140));
expect(fs.existsSync(extractDir)).toBe(false); 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 snapshot = manager.getSnapshot();
const item = Object.values(snapshot.session.items)[0]; const item = Object.values(snapshot.session.items)[0];
expect(item?.status).toBe("completed"); 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(extractDir)).toBe(true);
expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true); expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true);
} finally { } finally {
@ -7111,7 +7111,7 @@ describe("download manager", () => {
await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000); await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000);
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed"); 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 () => { 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); await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed"); 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 () => { 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); await waitFor(() => fs.existsSync(expectedPath), 12000);
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed"); 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(expectedPath)).toBe(true);
expect(fs.existsSync(originalExtractedPath)).toBe(false); expect(fs.existsSync(originalExtractedPath)).toBe(false);
}); });