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;
}
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)) {
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)) {
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 });
@ -8759,6 +8844,7 @@ export class DownloadManager extends EventEmitter {
item.progressPercent = 0;
throw new Error(`Download zu klein (${written} B) Hoster-Fehlerseite?${snippet ? ` Inhalt: "${snippet}"` : ""}`);
}
}
const exactLengthRequired = isLargeBinaryLikePath(item.fileName || effectiveTargetPath);
if (item.totalBytes && item.totalBytes > 0 && written < item.totalBytes) {

View File

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

View File

@ -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);
});