diff --git a/package-lock.json b/package-lock.json index 92fe059..765d460 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.1.23", + "version": "1.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.1.23", + "version": "1.1.24", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index c58d0f8..57c488d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.1.23", + "version": "1.1.24", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index eeb8dab..dc366ce 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -3,7 +3,7 @@ import os from "node:os"; import { AppSettings } from "../shared/types"; export const APP_NAME = "Debrid Download Manager"; -export const APP_VERSION = "1.1.23"; +export const APP_VERSION = "1.1.24"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 62be353..7351306 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -47,6 +47,11 @@ function canRetryStatus(status: number): boolean { return status === 429 || status >= 500; } +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"); +} + function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } @@ -723,116 +728,145 @@ export class DownloadManager extends EventEmitter { return; } - try { - const unrestricted = await this.debridService.unrestrictLink(item.url); - item.provider = unrestricted.provider; - item.retries = unrestricted.retriesUsed; - item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); - fs.mkdirSync(pkg.outputDir, { recursive: true }); - const existingTargetPath = String(item.targetPath || "").trim(); - const canReuseExistingTarget = existingTargetPath - && isPathInsideDir(existingTargetPath, pkg.outputDir) - && (item.downloadedBytes > 0 || fs.existsSync(existingTargetPath)); - const preferredTargetPath = canReuseExistingTarget - ? existingTargetPath - : path.join(pkg.outputDir, item.fileName); - item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); - item.totalBytes = unrestricted.fileSize; - item.status = "downloading"; - item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; - item.updatedAt = nowMs(); - this.emitState(); + let freshRetryUsed = false; + while (true) { + try { + const unrestricted = await this.debridService.unrestrictLink(item.url); + item.provider = unrestricted.provider; + item.retries = unrestricted.retriesUsed; + item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); + fs.mkdirSync(pkg.outputDir, { recursive: true }); + const existingTargetPath = String(item.targetPath || "").trim(); + const canReuseExistingTarget = existingTargetPath + && isPathInsideDir(existingTargetPath, pkg.outputDir) + && (item.downloadedBytes > 0 || fs.existsSync(existingTargetPath)); + const preferredTargetPath = canReuseExistingTarget + ? existingTargetPath + : path.join(pkg.outputDir, item.fileName); + item.targetPath = this.claimTargetPath(item.id, preferredTargetPath, Boolean(canReuseExistingTarget)); + item.totalBytes = unrestricted.fileSize; + item.status = "downloading"; + item.fullStatus = `Download läuft (${unrestricted.providerLabel})`; + item.updatedAt = nowMs(); + this.emitState(); - const maxAttempts = REQUEST_RETRIES; - let done = false; - let downloadRetries = 0; - while (!done && item.attempts < maxAttempts) { - item.attempts += 1; - const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes); - downloadRetries += result.retriesUsed; - active.resumable = result.resumable; - if (!active.resumable && !active.nonResumableCounted) { - active.nonResumableCounted = true; - this.nonResumableActive += 1; + const maxAttempts = REQUEST_RETRIES; + let done = false; + let downloadRetries = 0; + while (!done && item.attempts < maxAttempts) { + item.attempts += 1; + const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes); + downloadRetries += result.retriesUsed; + active.resumable = result.resumable; + if (!active.resumable && !active.nonResumableCounted) { + active.nonResumableCounted = true; + this.nonResumableActive += 1; + } + + if (this.settings.enableIntegrityCheck) { + item.status = "integrity_check"; + item.fullStatus = "CRC-Check läuft"; + item.updatedAt = nowMs(); + this.emitState(); + + const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); + if (!validation.ok) { + item.lastError = validation.message; + item.fullStatus = `${validation.message}, Neuversuch`; + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } + if (item.attempts < maxAttempts) { + item.status = "queued"; + item.progressPercent = 0; + item.downloadedBytes = 0; + item.totalBytes = unrestricted.fileSize; + this.emitState(); + await sleep(300); + continue; + } + throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); + } + } + + done = true; } - if (this.settings.enableIntegrityCheck) { - item.status = "integrity_check"; - item.fullStatus = "CRC-Check läuft"; - item.updatedAt = nowMs(); - this.emitState(); + item.retries += downloadRetries; + item.status = "completed"; + item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; + item.progressPercent = 100; + item.speedBps = 0; + item.updatedAt = nowMs(); + pkg.updatedAt = nowMs(); + this.recordRunOutcome(item.id, "completed"); - const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); - if (!validation.ok) { - item.lastError = validation.message; - item.fullStatus = `${validation.message}, Neuversuch`; + await this.handlePackagePostProcessing(pkg.id); + this.applyCompletedCleanupPolicy(pkg.id, item.id); + this.persistSoon(); + this.emitState(); + return; + } catch (error) { + const reason = active.abortReason; + if (reason === "cancel") { + item.status = "cancelled"; + item.fullStatus = "Entfernt"; + this.recordRunOutcome(item.id, "cancelled"); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } + } else if (reason === "stop") { + item.status = "cancelled"; + item.fullStatus = "Gestoppt"; + this.recordRunOutcome(item.id, "cancelled"); + try { + fs.rmSync(item.targetPath, { force: true }); + } catch { + // ignore + } + } else if (reason === "reconnect") { + item.status = "queued"; + item.fullStatus = "Wartet auf Reconnect"; + } else { + const errorText = compactErrorText(error); + const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText); + if (shouldFreshRetry) { + freshRetryUsed = true; try { fs.rmSync(item.targetPath, { force: true }); } catch { // ignore } - if (item.attempts < maxAttempts) { - item.status = "queued"; - item.progressPercent = 0; - item.downloadedBytes = 0; - item.totalBytes = unrestricted.fileSize; - this.emitState(); - await sleep(300); - continue; - } - throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`); + this.releaseTargetPath(item.id); + item.status = "queued"; + item.fullStatus = "Netzwerkfehler erkannt, frischer Retry"; + item.lastError = ""; + item.attempts = 0; + item.downloadedBytes = 0; + item.totalBytes = null; + item.progressPercent = 0; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + await sleep(450); + continue; } + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Fehler: ${item.lastError}`; } - - done = true; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + return; } - - item.retries += downloadRetries; - item.status = "completed"; - item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; - item.progressPercent = 100; - item.speedBps = 0; - item.updatedAt = nowMs(); - pkg.updatedAt = nowMs(); - this.recordRunOutcome(item.id, "completed"); - - await this.handlePackagePostProcessing(pkg.id); - this.applyCompletedCleanupPolicy(pkg.id, item.id); - this.persistSoon(); - this.emitState(); - } catch (error) { - const reason = active.abortReason; - if (reason === "cancel") { - item.status = "cancelled"; - item.fullStatus = "Entfernt"; - this.recordRunOutcome(item.id, "cancelled"); - try { - fs.rmSync(item.targetPath, { force: true }); - } catch { - // ignore - } - } else if (reason === "stop") { - item.status = "cancelled"; - item.fullStatus = "Gestoppt"; - this.recordRunOutcome(item.id, "cancelled"); - try { - fs.rmSync(item.targetPath, { force: true }); - } catch { - // ignore - } - } else if (reason === "reconnect") { - item.status = "queued"; - item.fullStatus = "Wartet auf Reconnect"; - } else { - item.status = "failed"; - this.recordRunOutcome(item.id, "failed"); - item.lastError = compactErrorText(error); - item.fullStatus = `Fehler: ${item.lastError}`; - } - item.speedBps = 0; - item.updatedAt = nowMs(); - this.persistSoon(); - this.emitState(); } } @@ -1109,6 +1143,14 @@ export class DownloadManager extends EventEmitter { if (result.failed > 0) { pkg.status = "failed"; } else { + if (result.extracted > 0) { + for (const entry of items) { + if (entry.status === "completed") { + entry.fullStatus = "Entpackt"; + entry.updatedAt = nowMs(); + } + } + } pkg.status = "completed"; } } else if (failed > 0) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index aa1c7a4..77593f3 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -125,8 +125,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ return { extracted: 0, failed: 0 }; } - fs.mkdirSync(options.targetDir, { recursive: true }); - let extracted = 0; let failed = 0; const extractedArchives: string[] = []; @@ -154,6 +152,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (options.removeSamples) { removeSampleArtifacts(options.targetDir); } + } else { + try { + if (fs.existsSync(options.targetDir) && fs.readdirSync(options.targetDir).length === 0) { + fs.rmSync(options.targetDir, { recursive: true, force: true }); + } + } catch { + // ignore + } } return { extracted, failed }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5bd5759..0107091 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -73,6 +73,11 @@ const providerLabels: Record = { alldebrid: "AllDebrid" }; +function formatSpeedMbps(speedBps: number): string { + const mbps = Math.max(0, speedBps) / (1024 * 1024); + return `${mbps.toFixed(2)} MB/s`; +} + export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [tab, setTab] = useState("collector"); @@ -309,8 +314,6 @@ export function App(): ReactElement { - - @@ -356,161 +359,166 @@ export function App(): ReactElement { )} {tab === "settings" && ( -
-
-

Debrid Provider

- - setText("token", event.target.value)} - /> - - setText("megaLogin", event.target.value)} - /> - - setText("megaPassword", event.target.value)} - /> - - setText("bestToken", event.target.value)} - /> - - setText("allDebridToken", event.target.value)} - /> - - - - - - - - - - setText("updateRepo", event.target.value)} /> - -
- -
-

Paketierung & Zielpfade

- -
- setText("outputDir", event.target.value)} /> - +
+
+
+

Einstellungen

+ Kompakt, schnell auffindbar und direkt speicherbar.
- - setText("packageName", event.target.value)} /> - -
- setText("extractDir", event.target.value)} /> - +
+ +
- -
-
-

Queue & Reconnect

- - setNum("maxParallel", Number(event.target.value) || 1)} /> - - - setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} /> - -
+
+
+

Provider & Zugang

+ + setText("token", event.target.value)} /> + + setText("megaLogin", event.target.value)} /> + + setText("megaPassword", event.target.value)} /> + + setText("bestToken", event.target.value)} /> + + setText("allDebridToken", event.target.value)} /> -
-

Integrität & Cleanup

- - - - - - - - - -
+
+
+ + +
+
+ + +
+
+ + +
+
-
- -
+ + +
+ +
+

Pfade & Paketierung

+ +
+ setText("outputDir", event.target.value)} /> + +
+ + setText("packageName", event.target.value)} /> + +
+ setText("extractDir", event.target.value)} /> + +
+ + +
+ +
+

Queue, Limits & Reconnect

+
+
+ + setNum("maxParallel", Number(event.target.value) || 1)} /> +
+
+ + setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} /> +
+
+
+
+ + setNum("speedLimitKbps", Number(event.target.value) || 0)} /> +
+
+ + +
+
+ + + +
+ +
+

Integrität, Cleanup & Updates

+ + + + + +
+
+ + +
+
+ + +
+
+ + setText("updateRepo", event.target.value)} /> + +
+
)} @@ -542,23 +550,23 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl - - - - - - + + + + + + {items.map((item) => ( - - - - - - + + + + + + ))} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index c0f276b..0dbc9ce 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1,6 +1,6 @@ :root { color-scheme: dark; - font-family: "Segoe UI", "Inter", sans-serif; + font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif; --bg: #040912; --surface: #0b1424; --card: #101d31; @@ -30,8 +30,8 @@ body, display: grid; grid-template-rows: auto auto auto 1fr; height: 100%; - padding: 14px 16px 12px; - gap: 10px; + padding: 12px 14px 10px; + gap: 8px; } .top-header { @@ -62,11 +62,11 @@ body, .control-strip { display: flex; justify-content: space-between; - gap: 16px; + gap: 12px; background: linear-gradient(180deg, rgba(20, 34, 56, 0.95), rgba(9, 16, 28, 0.95)); border: 1px solid var(--border); - border-radius: 14px; - padding: 12px 14px; + border-radius: 12px; + padding: 10px 12px; } .buttons, @@ -83,10 +83,11 @@ body, background: #0d1a2c; color: var(--text); border: 1px solid var(--border); - border-radius: 10px; - padding: 7px 12px; + border-radius: 9px; + padding: 6px 11px; cursor: pointer; font-weight: 600; + font-size: 13px; transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease; } @@ -121,10 +122,11 @@ body, background: #0b1321; border: 1px solid var(--border); color: var(--muted); - border-radius: 10px; - padding: 8px 13px; + border-radius: 9px; + padding: 7px 12px; cursor: pointer; font-weight: 600; + font-size: 13px; } .tab.active { @@ -146,12 +148,12 @@ body, .card { border: 1px solid var(--border); - border-radius: 14px; + border-radius: 12px; background: linear-gradient(180deg, rgba(17, 29, 49, 0.95), rgba(9, 16, 28, 0.95)); - padding: 12px; + padding: 10px; display: flex; flex-direction: column; - gap: 8px; + gap: 7px; } .card.wide { @@ -161,7 +163,8 @@ body, .card h3 { margin: 0; - font-size: 16px; + font-size: 15px; + letter-spacing: 0.1px; } .card label, @@ -180,8 +183,8 @@ body, background: var(--field); color: var(--text); border: 1px solid var(--border); - border-radius: 10px; - padding: 8px 10px; + border-radius: 9px; + padding: 7px 9px; } .card textarea { @@ -209,6 +212,77 @@ body, gap: 10px; } +.settings-shell { + display: grid; + grid-template-rows: auto 1fr; + gap: 8px; + height: 100%; + min-height: 0; +} + +.settings-toolbar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.settings-toolbar-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-toolbar-copy span { + color: var(--muted); + font-size: 12px; +} + +.settings-toolbar-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.settings-grid { + min-height: 0; + overflow: auto; + align-content: start; +} + +.settings-card { + gap: 6px; +} + +.field-grid { + display: grid; + gap: 8px; +} + +.field-grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.field-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field-grid > div { + min-width: 0; +} + +.toggle-line { + display: flex; + align-items: center; + gap: 7px; +} + +.toggle-line input[type="checkbox"] { + width: 15px; + height: 15px; +} + .package-card { border: 1px solid var(--border); border-radius: 14px; @@ -248,6 +322,7 @@ body, table { width: 100%; + table-layout: fixed; border-collapse: collapse; margin-top: 10px; font-size: 13px; @@ -265,13 +340,43 @@ th { font-weight: 600; } -.settings-grid { - grid-template-columns: 1fr 1fr; +td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.settings-actions { - grid-column: span 2; - justify-content: flex-end; +.num { + font-variant-numeric: tabular-nums; + font-family: "Consolas", "SFMono-Regular", "Menlo", monospace; +} + +.col-file { + width: 34%; +} + +.col-provider { + width: 14%; +} + +.col-status { + width: 26%; +} + +.col-progress { + width: 8%; +} + +.col-speed { + width: 12%; +} + +.col-retries { + width: 6%; +} + +.settings-grid { + grid-template-columns: 1.1fr 1fr; } .empty { @@ -300,11 +405,25 @@ th { align-items: flex-start; } + .settings-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .settings-toolbar-actions { + width: 100%; + } + .grid-two, .settings-grid { grid-template-columns: 1fr; } + .field-grid.two, + .field-grid.three { + grid-template-columns: 1fr; + } + .card.wide, .settings-actions { grid-column: span 1; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 804b0df..5029a84 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import http from "node:http"; import { once } from "node:events"; +import AdmZip from "adm-zip"; import { afterEach, describe, expect, it } from "vitest"; import { DownloadManager } from "../src/main/download-manager"; import { defaultSettings } from "../src/main/constants"; @@ -700,6 +701,173 @@ describe("download manager", () => { } }); + it("performs one fresh retry after fetch failed during unrestrict", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(96 * 1024, 12); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/fresh-retry") { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + 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}/fresh-retry`; + let unrestrictCalls = 0; + + 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")) { + unrestrictCalls += 1; + if (unrestrictCalls <= 3) { + throw new TypeError("fetch failed"); + } + return new Response( + JSON.stringify({ + download: directUrl, + filename: "fresh-retry.bin", + 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, + autoReconnect: false + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "fresh-retry", links: ["https://dummy/fresh"] }]); + manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(unrestrictCalls).toBeGreaterThan(3); + expect(item?.status).toBe("completed"); + expect(item?.lastError || "").toBe(""); + expect(fs.existsSync(item.targetPath)).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("creates extract directory only at extraction and marks items as Entpackt", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const zip = new AdmZip(); + zip.addFile("inside.txt", Buffer.from("ok")); + const archive = zip.toBuffer(); + + const server = http.createServer((req, res) => { + if ((req.url || "") !== "/archive") { + res.statusCode = 404; + res.end("not-found"); + return; + } + setTimeout(() => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(archive.length)); + res.end(archive); + }, 450); + }); + + 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}/archive`; + + 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: "sample.zip", + filesize: archive.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"), + createExtractSubfolder: true, + autoExtract: true, + enableIntegrityCheck: false, + cleanupMode: "none" + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "zip-pack", links: ["https://dummy/archive"] }]); + const pkgId = manager.getSnapshot().session.packageOrder[0]; + const extractDir = manager.getSnapshot().session.packages[pkgId]?.extractDir || ""; + expect(extractDir).toBeTruthy(); + expect(fs.existsSync(extractDir)).toBe(false); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 140)); + expect(fs.existsSync(extractDir)).toBe(false); + + await waitFor(() => !manager.getSnapshot().session.running, 30000); + + const snapshot = manager.getSnapshot(); + const item = Object.values(snapshot.session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.fullStatus).toBe("Entpackt"); + expect(fs.existsSync(extractDir)).toBe(true); + expect(fs.existsSync(path.join(extractDir, "inside.txt"))).toBe(true); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("keeps accurate summary when completed items are cleaned immediately", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 9b537c3..08e4bda 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -96,4 +96,26 @@ describe("extractor", () => { expect(result.failed).toBe(0); expect(fs.readFileSync(existingPath, "utf8")).toBe("old"); }); + + it("does not keep empty target dir when extraction fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8"); + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(0); + expect(result.failed).toBe(1); + expect(fs.existsSync(targetDir)).toBe(false); + }); });
DateiProviderStatusFortschrittSpeedRetriesDateiProviderStatusFortschrittSpeedRetries
{item.fileName}{item.provider ? providerLabels[item.provider] : "-"}{item.fullStatus}{item.progressPercent}%{item.speedBps > 0 ? `${Math.floor(item.speedBps / 1024)} KB/s` : "0 KB/s"}{item.retries}{item.fileName}{item.provider ? providerLabels[item.provider] : "-"}{item.fullStatus}{item.progressPercent}%{formatSpeedMbps(item.speedBps)}{item.retries}