Polish settings UI and harden fetch-failed recovery
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
6d777e2a56
commit
4548d809f9
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,161 +359,166 @@ export function App(): ReactElement {
|
||||
)}
|
||||
|
||||
{tab === "settings" && (
|
||||
<section className="grid-two settings-grid">
|
||||
<article className="card">
|
||||
<h3>Debrid Provider</h3>
|
||||
<label>Real-Debrid API Token</label>
|
||||
<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)}
|
||||
/>
|
||||
<label>Mega-Debrid Passwort</label>
|
||||
<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)}
|
||||
/>
|
||||
<label>AllDebrid API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settingsDraft.allDebridToken}
|
||||
onChange={(event) => setText("allDebridToken", event.target.value)}
|
||||
/>
|
||||
<label>Primärer Provider</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>
|
||||
<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>
|
||||
<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>
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<h3>Paketierung & Zielpfade</h3>
|
||||
<label>Download-Ordner</label>
|
||||
<div className="input-row">
|
||||
<input value={settingsDraft.outputDir} onChange={(event) => setText("outputDir", event.target.value)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("outputDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
<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>
|
||||
<label>Paketname (optional)</label>
|
||||
<input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} />
|
||||
<label>Entpacken nach</label>
|
||||
<div className="input-row">
|
||||
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("extractDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
<div className="settings-toolbar-actions">
|
||||
<button className="btn" onClick={onCheckUpdates}>Updates prüfen</button>
|
||||
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</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>
|
||||
</article>
|
||||
|
||||
<article className="card">
|
||||
<h3>Queue & Reconnect</h3>
|
||||
<label>Max. gleichzeitige 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>
|
||||
<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>
|
||||
</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)} />
|
||||
<label>Mega-Debrid Login</label>
|
||||
<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)} />
|
||||
<label>BestDebrid API Token</label>
|
||||
<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)} />
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-actions">
|
||||
<button className="btn accent" onClick={onSaveSettings}>Settings speichern</button>
|
||||
</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 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)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("outputDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
</div>
|
||||
<label>Paketname (optional)</label>
|
||||
<input value={settingsDraft.packageName} onChange={(event) => setText("packageName", event.target.value)} />
|
||||
<label>Entpacken nach</label>
|
||||
<div className="input-row">
|
||||
<input value={settingsDraft.extractDir} onChange={(event) => setText("extractDir", event.target.value)} />
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
void performQuickAction(async () => {
|
||||
const selected = await window.rd.pickFolder();
|
||||
if (selected) {
|
||||
setText("extractDir", selected);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>Wählen</button>
|
||||
</div>
|
||||
<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 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)} />
|
||||
</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)} />
|
||||
</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 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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user