Polish settings UI and harden fetch-failed recovery
Some checks are pending
Build and Release / build (push) Waiting to run

This commit is contained in:
Sucukdeluxe 2026-02-27 11:32:06 +01:00
parent 6d777e2a56
commit 4548d809f9
9 changed files with 653 additions and 288 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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";

View File

@ -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,6 +728,8 @@ export class DownloadManager extends EventEmitter {
return;
}
let freshRetryUsed = false;
while (true) {
try {
const unrestricted = await this.debridService.unrestrictLink(item.url);
item.provider = unrestricted.provider;
@ -800,6 +807,7 @@ export class DownloadManager extends EventEmitter {
this.applyCompletedCleanupPolicy(pkg.id, item.id);
this.persistSoon();
this.emitState();
return;
} catch (error) {
const reason = active.abortReason;
if (reason === "cancel") {
@ -824,15 +832,41 @@ export class DownloadManager extends EventEmitter {
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
}
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 = compactErrorText(error);
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
}
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
}
}
}
@ -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) {

View File

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

View File

@ -73,6 +73,11 @@ const providerLabels: Record<DebridProvider, string> = {
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<UiSnapshot>(emptySnapshot);
const [tab, setTab] = useState<Tab>("collector");
@ -309,8 +314,6 @@ export function App(): ReactElement {
<option value="global">global</option>
<option value="per_download">per_download</option>
</select>
<button className="btn" onClick={onSaveSettings}>Live speichern</button>
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
</div>
</section>
@ -356,86 +359,65 @@ export function App(): ReactElement {
)}
{tab === "settings" && (
<section className="grid-two settings-grid">
<article className="card">
<h3>Debrid Provider</h3>
<section className="settings-shell">
<article className="card settings-toolbar">
<div className="settings-toolbar-copy">
<h3>Einstellungen</h3>
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
</div>
<div className="settings-toolbar-actions">
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
</div>
</article>
<section className="settings-grid">
<article className="card settings-card">
<h3>Provider & Zugang</h3>
<label>Real-Debrid API Token</label>
<input
type="password"
value={settingsDraft.token}
onChange={(event) => setText("token", event.target.value)}
/>
<input type="password" value={settingsDraft.token} onChange={(event) => setText("token", event.target.value)} />
<label>Mega-Debrid Login</label>
<input
value={settingsDraft.megaLogin}
onChange={(event) => setText("megaLogin", event.target.value)}
/>
<input value={settingsDraft.megaLogin} onChange={(event) => setText("megaLogin", event.target.value)} />
<label>Mega-Debrid Passwort</label>
<input
type="password"
value={settingsDraft.megaPassword}
onChange={(event) => setText("megaPassword", event.target.value)}
/>
<input type="password" value={settingsDraft.megaPassword} onChange={(event) => setText("megaPassword", event.target.value)} />
<label>BestDebrid API Token</label>
<input
type="password"
value={settingsDraft.bestToken}
onChange={(event) => setText("bestToken", event.target.value)}
/>
<input type="password" value={settingsDraft.bestToken} onChange={(event) => setText("bestToken", event.target.value)} />
<label>AllDebrid API Key</label>
<input
type="password"
value={settingsDraft.allDebridToken}
onChange={(event) => setText("allDebridToken", event.target.value)}
/>
<label>Primärer Provider</label>
<input type="password" value={settingsDraft.allDebridToken} onChange={(event) => setText("allDebridToken", event.target.value)} />
<div className="field-grid three">
<div>
<label>Primär</label>
<select value={settingsDraft.providerPrimary} onChange={(event) => setText("providerPrimary", event.target.value)}>
{Object.entries(providerLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<label>Sekundärer Provider</label>
</div>
<div>
<label>Sekundär</label>
<select value={settingsDraft.providerSecondary} onChange={(event) => setText("providerSecondary", event.target.value)}>
{Object.entries(providerLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<label>Tertiärer Provider</label>
</div>
<div>
<label>Tertiär</label>
<select value={settingsDraft.providerTertiary} onChange={(event) => setText("providerTertiary", event.target.value)}>
{Object.entries(providerLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<label>
<input
type="checkbox"
checked={settingsDraft.autoProviderFallback}
onChange={(event) => setBool("autoProviderFallback", event.target.checked)}
/>
Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln
</label>
<label>
<input
type="checkbox"
checked={settingsDraft.rememberToken}
onChange={(event) => setBool("rememberToken", event.target.checked)}
/>
API Keys lokal speichern
</label>
<label>GitHub Repo</label>
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
<label>
<input
type="checkbox"
checked={settingsDraft.autoUpdateCheck}
onChange={(event) => setBool("autoUpdateCheck", event.target.checked)}
/>
Beim Start auf Updates prüfen
</label>
</div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoProviderFallback} onChange={(event) => setBool("autoProviderFallback", event.target.checked)} /> Bei Fehler/Fair-Use automatisch zum nächsten Provider wechseln</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.rememberToken} onChange={(event) => setBool("rememberToken", event.target.checked)} /> Zugangsdaten lokal speichern</label>
</article>
<article className="card">
<h3>Paketierung & Zielpfade</h3>
<article className="card settings-card">
<h3>Pfade & Paketierung</h3>
<label>Download-Ordner</label>
<div className="input-row">
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
@ -468,49 +450,75 @@ export function App(): ReactElement {
}}
>Wählen</button>
</div>
<label><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label>
<label><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(event) => setBool("autoExtract", event.target.checked)} /> Auto-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(event) => setBool("hybridExtract", event.target.checked)} /> Hybrid-Extract</label>
</article>
<article className="card">
<h3>Queue & Reconnect</h3>
<label>Max. gleichzeitige Downloads</label>
<article className="card settings-card">
<h3>Queue, Limits & Reconnect</h3>
<div className="field-grid two">
<div>
<label>Max. Downloads</label>
<input type="number" min={1} max={50} value={settingsDraft.maxParallel} onChange={(event) => setNum("maxParallel", Number(event.target.value) || 1)} />
<label><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label>
</div>
<div>
<label>Reconnect-Wartezeit (Sek.)</label>
<input type="number" min={10} max={600} value={settingsDraft.reconnectWaitSeconds} onChange={(event) => setNum("reconnectWaitSeconds", Number(event.target.value) || 45)} />
<label><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label>
</div>
</div>
<div className="field-grid two">
<div>
<label>Speed-Limit (KB/s)</label>
<input type="number" min={0} max={500000} value={settingsDraft.speedLimitKbps} onChange={(event) => setNum("speedLimitKbps", Number(event.target.value) || 0)} />
</div>
<div>
<label>Speed-Modus</label>
<select value={settingsDraft.speedLimitMode} onChange={(event) => setText("speedLimitMode", event.target.value)}>
<option value="global">global</option>
<option value="per_download">per_download</option>
</select>
</div>
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.speedLimitEnabled} onChange={(event) => setBool("speedLimitEnabled", event.target.checked)} /> Speed-Limit aktivieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoReconnect} onChange={(event) => setBool("autoReconnect", event.target.checked)} /> Automatischer Reconnect</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoResumeOnStart} onChange={(event) => setBool("autoResumeOnStart", event.target.checked)} /> Auto-Resume beim Start</label>
</article>
<article className="card">
<h3>Integrität & Cleanup</h3>
<label><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
<label><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label>
<article className="card settings-card">
<h3>Integrität, Cleanup & Updates</h3>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.enableIntegrityCheck} onChange={(event) => setBool("enableIntegrityCheck", event.target.checked)} /> SFV/CRC/MD5/SHA1 prüfen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeLinkFilesAfterExtract} onChange={(event) => setBool("removeLinkFilesAfterExtract", event.target.checked)} /> Link-Dateien nach Entpacken entfernen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.removeSamplesAfterExtract} onChange={(event) => setBool("removeSamplesAfterExtract", event.target.checked)} /> Samples nach Entpacken entfernen</label>
<label>Fertiggestellte Downloads entfernen</label>
<select value={settingsDraft.completedCleanupPolicy} onChange={(event) => setText("completedCleanupPolicy", event.target.value)}>
{Object.entries(cleanupLabels).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<div className="field-grid two">
<div>
<label>Cleanup nach Entpacken</label>
<select value={settingsDraft.cleanupMode} onChange={(event) => setText("cleanupMode", event.target.value)}>
<option value="none">keine Archive löschen</option>
<option value="trash">Archive in Papierkorb</option>
<option value="delete">Archive löschen</option>
</select>
<label>Konfliktmodus beim Entpacken</label>
</div>
<div>
<label>Konfliktmodus</label>
<select value={settingsDraft.extractConflictMode} onChange={(event) => setText("extractConflictMode", event.target.value)}>
<option value="overwrite">überschreiben</option>
<option value="skip">überspringen</option>
<option value="rename">umbenennen</option>
<option value="ask">nachfragen</option>
</select>
</article>
<div className="settings-actions">
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
</div>
</div>
<label>GitHub Repo</label>
<input value={settingsDraft.updateRepo} onChange={(event) => setText("updateRepo", event.target.value)} />
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoUpdateCheck} onChange={(event) => setBool("autoUpdateCheck", event.target.checked)} /> Beim Start auf Updates prüfen</label>
</article>
</section>
</section>
)}
</main>
@ -542,23 +550,23 @@ function PackageCard({ pkg, items, onCancel }: { pkg: PackageEntry; items: Downl
<table>
<thead>
<tr>
<th>Datei</th>
<th>Provider</th>
<th>Status</th>
<th>Fortschritt</th>
<th>Speed</th>
<th>Retries</th>
<th className="col-file">Datei</th>
<th className="col-provider">Provider</th>
<th className="col-status">Status</th>
<th className="col-progress">Fortschritt</th>
<th className="col-speed">Speed</th>
<th className="col-retries">Retries</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>{item.fileName}</td>
<td>{item.provider ? providerLabels[item.provider] : "-"}</td>
<td title={item.fullStatus}>{item.fullStatus}</td>
<td>{item.progressPercent}%</td>
<td>{item.speedBps > 0 ? `${Math.floor(item.speedBps / 1024)} KB/s` : "0 KB/s"}</td>
<td>{item.retries}</td>
<td className="col-file" title={item.fileName}>{item.fileName}</td>
<td className="col-provider">{item.provider ? providerLabels[item.provider] : "-"}</td>
<td className="col-status" title={item.fullStatus}>{item.fullStatus}</td>
<td className="col-progress num">{item.progressPercent}%</td>
<td className="col-speed num">{formatSpeedMbps(item.speedBps)}</td>
<td className="col-retries num">{item.retries}</td>
</tr>
))}
</tbody>

View File

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

View File

@ -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<Response> => {
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<Response> => {
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);

View File

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