From ef821b69a531216d157bf195049d5c8c8ec6dab5 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 15:29:49 +0100 Subject: [PATCH] Fix shutdown resume state and legacy extracted cleanup backfill v1.3.9 --- package.json | 2 +- src/main/app-controller.ts | 2 +- src/main/download-manager.ts | 103 +++++++++++++++- tests/download-manager.test.ts | 219 +++++++++++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 92968de..af3a9df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.3.8", + "version": "1.3.9", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 3b9f74c..6f00fcf 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -151,7 +151,7 @@ export class AppController { } public shutdown(): void { - this.manager.stop(); + this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); logger.info("App beendet"); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c7a725c..661e191 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -17,7 +17,7 @@ type ActiveTask = { itemId: string; packageId: string; abortController: AbortController; - abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "none"; + abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none"; resumable: boolean; speedEvents: Array<{ at: number; bytes: number }>; nonResumableCounted: boolean; @@ -86,6 +86,11 @@ function canRetryStatus(status: number): boolean { return status === 429 || status >= 500; } +function isArchiveLikePath(filePath: string): boolean { + const lower = path.basename(filePath).toLowerCase(); + return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower); +} + function isFetchFailure(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error"); @@ -601,15 +606,18 @@ export class DownloadManager extends EventEmitter { continue; } - const extractedItems = items.filter((item) => item.fullStatus === "Entpackt"); - if (extractedItems.length === 0) { + const hasExtractMarker = items.some((item) => /entpack/i.test(item.fullStatus)); + const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir); + if (!hasExtractMarker && !hasExtractedOutput) { continue; } const packageTargets = cleanupTargetsByPackage.get(packageId) ?? new Set(); - for (const item of extractedItems) { - const targetPath = String(item.targetPath || "").trim(); - if (!targetPath) { + for (const item of items) { + const rawTargetPath = String(item.targetPath || "").trim(); + const fallbackTargetPath = item.fileName ? path.join(pkg.outputDir, sanitizeFilename(item.fileName)) : ""; + const targetPath = rawTargetPath || fallbackTargetPath; + if (!targetPath || !isArchiveLikePath(targetPath)) { continue; } for (const cleanupTarget of collectArchiveCleanupTargets(targetPath)) { @@ -635,6 +643,9 @@ export class DownloadManager extends EventEmitter { let removed = 0; for (const targetPath of targets) { + if (!fs.existsSync(targetPath)) { + continue; + } try { await fs.promises.rm(targetPath, { force: true }); removed += 1; @@ -653,6 +664,32 @@ export class DownloadManager extends EventEmitter { }); } + private directoryHasAnyFiles(rootDir: string): boolean { + if (!rootDir || !fs.existsSync(rootDir)) { + return false; + } + const stack = [rootDir]; + while (stack.length > 0) { + const current = stack.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.isFile()) { + return true; + } + if (entry.isDirectory()) { + stack.push(path.join(current, entry.name)); + } + } + } + return false; + } + public cancelPackage(packageId: string): void { const pkg = this.session.packages[packageId]; if (!pkg) { @@ -755,6 +792,48 @@ export class DownloadManager extends EventEmitter { this.emitState(true); } + public prepareForShutdown(): void { + this.session.running = false; + this.session.paused = false; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + + for (const active of this.activeTasks.values()) { + const item = this.session.items[active.itemId]; + if (item && !isFinishedStatus(item.status)) { + item.status = "queued"; + item.speedBps = 0; + const pkg = this.session.packages[item.packageId]; + item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet"; + item.updatedAt = nowMs(); + } + active.abortReason = "shutdown"; + active.abortController.abort("shutdown"); + } + + for (const pkg of Object.values(this.session.packages)) { + if (pkg.status === "downloading" + || pkg.status === "validating" + || pkg.status === "extracting" + || pkg.status === "integrity_check" + || pkg.status === "paused" + || pkg.status === "reconnect_wait") { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + } + + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.runItemIds.clear(); + this.runPackageIds.clear(); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.session.summaryText = ""; + this.persistNow(); + this.emitState(true); + } + public togglePause(): boolean { if (!this.session.running) { return false; @@ -775,6 +854,13 @@ export class DownloadManager extends EventEmitter { if (item.provider !== "realdebrid" && item.provider !== "megadebrid" && item.provider !== "bestdebrid" && item.provider !== "alldebrid") { item.provider = null; } + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + continue; + } if (item.status === "downloading" || item.status === "validating" || item.status === "extracting" @@ -1272,6 +1358,11 @@ export class DownloadManager extends EventEmitter { } catch { // ignore } + } else if (reason === "shutdown") { + item.status = "queued"; + item.speedBps = 0; + const activePkg = this.session.packages[item.packageId]; + item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet"; } else if (reason === "reconnect") { item.status = "queued"; item.fullStatus = "Wartet auf Reconnect"; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index b193971..77842be 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -821,6 +821,66 @@ describe("download manager", () => { expect(snapshot.canStart).toBe(true); }); + it("requeues legacy 'Gestoppt' items on startup", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "stopped-pkg"; + const itemId = "stopped-item"; + const createdAt = Date.now() - 20_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "stopped", + outputDir: path.join(root, "downloads", "stopped"), + extractDir: path.join(root, "extract", "stopped"), + status: "downloading", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/stopped", + provider: "megadebrid", + status: "cancelled", + retries: 1, + speedBps: 0, + downloadedBytes: 512, + totalBytes: 2048, + progressPercent: 25, + fileName: "resume.part01.rar", + targetPath: path.join(root, "downloads", "stopped", "resume.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Gestoppt", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + const snapshot = manager.getSnapshot(); + expect(snapshot.session.items[itemId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Wartet"); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + }); + it("cleans leftover split archives on startup for already extracted packages", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -891,6 +951,82 @@ describe("download manager", () => { expect(fs.existsSync(keep)).toBe(true); }); + it("cleans legacy leftovers when package is extracted but marker is old", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const packageDir = path.join(root, "downloads", "legacy-old"); + const extractDir = path.join(root, "extract", "legacy-old"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(extractDir, { recursive: true }); + + const part1 = path.join(packageDir, "legacy.old.part01.rar"); + const part2 = path.join(packageDir, "legacy.old.part02.rar"); + const part3 = path.join(packageDir, "legacy.old.part03.rar"); + const keep = path.join(packageDir, "keep.nfo"); + fs.writeFileSync(part1, "part1", "utf8"); + fs.writeFileSync(part2, "part2", "utf8"); + fs.writeFileSync(part3, "part3", "utf8"); + fs.writeFileSync(keep, "keep", "utf8"); + fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8"); + + const session = emptySession(); + const packageId = "legacy-old-pkg"; + const itemId = "legacy-old-item"; + const createdAt = Date.now() - 20_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "legacy-old", + outputDir: packageDir, + extractDir, + status: "completed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/legacy-old", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: path.basename(part1), + targetPath: part1, + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Fertig (123 MB)", + createdAt, + updatedAt: createdAt + }; + + new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + cleanupMode: "delete" + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await waitFor(() => !fs.existsSync(part1) && !fs.existsSync(part2) && !fs.existsSync(part3), 5000); + expect(fs.existsSync(keep)).toBe(true); + expect(fs.existsSync(path.join(extractDir, "episode.mkv"))).toBe(true); + }); + it("resets run counters and reconnect state on start", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); @@ -1633,6 +1769,89 @@ describe("download manager", () => { } }); + it("keeps active downloads resumable on shutdown preparation", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(480 * 1024, 5); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/shutdown") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.write(binary.subarray(0, Math.floor(binary.length / 3))); + setTimeout(() => { + if (!res.writableEnded && !res.destroyed) { + res.end(binary.subarray(Math.floor(binary.length / 3))); + } + }, 2200); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/shutdown`; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("/unrestrict/link")) { + return new Response( + JSON.stringify({ + download: directUrl, + filename: "shutdown.part01.rar", + filesize: binary.length + }), + { + status: 200, + headers: { "Content-Type": "application/json" } + } + ); + } + return originalFetch(input, init); + }; + + try { + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + maxParallel: 1 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "shutdown-case", links: ["https://dummy/shutdown"] }]); + const itemId = Object.values(manager.getSnapshot().session.items)[0]?.id || ""; + manager.start(); + await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "downloading", 12000); + + manager.prepareForShutdown(); + await waitFor(() => { + const state = manager.getSnapshot(); + return !state.session.running && state.session.items[itemId]?.status === "queued"; + }, 8000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("queued"); + expect(item?.fullStatus).toBe("Wartet"); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("recovers pending extraction on startup for completed package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);