Polish settings UI and harden fetch-failed recovery
Build and Release / build (push) Has been cancelled
Build and Release / build (push) Has been cancelled
This commit is contained in:
@@ -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";
|
||||
|
||||
+140
-98
@@ -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 };
|
||||
|
||||
+171
-163
@@ -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>
|
||||
|
||||
+140
-21
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user