feat: parallele Proxy-Segmentdownloads

This commit is contained in:
Sucukdeluxe
2026-08-31 12:49:15 +02:00
parent 50f04b2761
commit 17ac99d373
20 changed files with 1275 additions and 52 deletions
+21 -4
View File
@@ -67,6 +67,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
- Provider-Reihenfolge, Hoster-Overrides, Kontostatus, Tageslimits, Cooldowns und optionale Fallbacks bestimmen das Routing.
- Real-Debrid und Debrid-Link unterstützen Kontopools; Auswahl und Rotation berücksichtigen Aktivität, Fairness, Limits und Fehlerklassen.
- Item-Pipeline: Vorprüfung und Recovery, Provider-Auswahl, Unrestrict mit Timeout, sichere Zielpfadreservierung, Speicherplatzreservierung, HTTP-Download mit Range-Resume, optionale Integritätsprüfung, Abschluss und Paket-Postprocessing.
- Optionaler Proxy-Segmentdownload teilt neue Dateien ab 8 MiB in 2 bis 32 exakte HTTP-Range-Bereiche und lädt sie parallel über verschiedene authentifizierte HTTP-CONNECT-Proxys. Bestehende Teil-Dateien und aktive Geschwindigkeitslimits bleiben beim bisherigen Direktdownload; bei fehlender Range-Unterstützung, ungültiger Liste oder Proxy-/Segmentfehlern wird automatisch auf diesen Pfad zurückgefallen.
- Download-Retries unterscheiden Netzwerk-, Range-, Hoster-, Provider-, Konto-, Quota-, Disk- und permanente Linkfehler. Stop, Pause, Shutdown und Neustart besitzen getrennte Park- und Abbruchpfade.
- Die Queue priorisiert hoch vor normal vor niedrig, beachtet globale und providerbezogene Parallelitätsgrenzen und schützt sich mit Scheduler-Generation, Heartbeat und Stall-Watchdogs gegen veraltete Tasks.
@@ -98,6 +99,11 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
## Aktuelle unveröffentlichte Änderung
- Unter „Geschwindigkeit“ kann der Proxy-Segmentdownload aktiviert, eine externe Proxy-Textdatei ausgewählt und die Zahl der Verbindungen pro Download zwischen 2 und 32 festgelegt werden. Bei fünf parallelen Downloads und 16 Segmenten sind damit bis zu 80 Proxy-Verbindungen möglich; `maxParallel` bleibt die vorhandene globale Downloadgrenze.
- Unterstützt werden insbesondere `Benutzer:Passwort@Host:Port`, Proxy-URLs, `Host:Port` und `Host:Port:Benutzer:Passwort`. Die Liste wird nur im Main-Prozess gelesen und anhand von Dateigröße und Änderungszeit gecacht. Proxy-Zugangsdaten werden nicht protokolliert, nicht an den Renderer übertragen und nicht in Git übernommen; lediglich der lokale Listenpfad ist eine normale Einstellung.
- Jeder Proxy-Download prüft zuerst `Range: bytes=0-1`, übernimmt die autoritative HTTP-Gesamtgröße, reserviert eine zielnahe temporäre Datei und schreibt jedes Segment positionsgenau. Die Zwei-Byte-Probe umgeht ein reproduziertes Real-Debrid-Verhalten, bei dem nur `bytes=0-0` trotz 206-Header die gesamte Datei streamt. Segmente versuchen bei Verbindungs-, Authentifizierungs-, Timeout- oder Range-Fehlern andere Proxys. Erst nach vollständiger Größenprüfung und `datasync` wird die Datei atomar zum Ziel umbenannt.
- Abbruch und Segmentfehler beenden alle zugehörigen Tunnel, entfernen die temporäre Datei und rechnen nur eindeutigen Segmentfortschritt zurück. Tatsächlich empfangener Providerverkehr bleibt für Statistik und Geschwindigkeitsmessung erhalten. Ein fehlgeschlagener Proxy-Versuch startet anschließend den unveränderten Direktdownload neu.
- Der bestehende Range-Resume-Pfad für Teil-Dateien wurde nicht ersetzt. Ein aktives Geschwindigkeitslimit überspringt die Proxy-Segmentierung, damit das konfigurierte Limit nicht umgangen wird.
- Ein Start-/Beenden-Fehler nach Real-Debrid-Web-Downloads wurde behoben. Der persistente unsichtbare Web-Generator blieb nach dem Schließen des Hauptfensters geöffnet, verhinderte dadurch `window-all-closed` und hielt den Single-Instance-Lock. Weitere Starts wurden als Zweitinstanz sofort beendet, während der alte `second-instance`-Handler ohne Hauptfenster nichts tat.
- Das Hauptfenster ist unter Windows nun ausdrücklich Besitzer des App-Lebenszyklus: Nach einem natürlichen, vom Renderer bestätigten Schließen wird der kontrollierte Shutdown auch bei verbleibenden Providerfenstern gestartet. Der normale `beforeunload`-Pfad bleibt erhalten, damit noch nicht übertragene Linksammler-Änderungen synchron gesichert werden können.
- `second-instance` und `activate` stellen ein vorhandenes Hauptfenster wieder her oder erzeugen ein fehlendes neu. Ein Start während eines bereits laufenden Shutdowns plant genau einen Relaunch nach dem Prozessende ein.
@@ -142,6 +148,14 @@ npm exec -- tsc --noEmit
## Verifizierungen vom 31. August 2026
- Proxy-Parser gegen die bereitgestellte externe Liste geprüft: 1.000 von 1.000 Einträgen gültig; dabei wurden weder Proxy-Verbindungen aufgebaut noch Listeneinträge ausgegeben.
- Proxy-Segmenttests: 5 von 5 erfolgreich. Abgedeckt sind vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Proxys, Direkt-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung.
- Reale Proxy-Prüfung mit einer 930.376.313 Byte großen Real-Debrid-RAR-Datei: 16 Verbindungen, vollständiger Segmenttransfer in rund 11,7 Sekunden mit 76,09 MiB/s mittlerem Nutzdurchsatz und zwischenzeitlich rund 94 MiB/s; Gesamtlauf einschließlich `datasync` und SHA-256 dauerte 13,19 Sekunden. Der zusätzliche Prüfverkehr betrug exakt 2 Byte.
- Die reale Testdatei besitzt SHA-256 `f60f70a03cc49f544aab97d66254cb39801d7bbad827d1db935ef8b25c8b544f`, eine gültige RAR-Signatur und ein fehlerfrei lesbares Inhaltsverzeichnis. 32 verteilte 64-Byte-Stichproben an Anfang und Ende aller 16 Segmente stimmten mit frischen Range-Antworten über 32 verschiedene Listeneinträge überein.
- Settings-/Storage-Regressionen decken Aktivierung, Pfad, Wertebereich 2 bis 32, Dateiauswahl und die Darstellung von 80 möglichen Tunneln bei 5 × 16 ab.
- Vollständiger Client-Lauf nach der Proxy-Änderung: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.668 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Symlink-Fixtures in `public-release-metadata.test.ts` scheiterten vor ihrer Produktassertion mit Windows-`EPERM`.
- Finaler Wiederholungslauf ohne die zwei privilegierten Symlink-Fixtures: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.646 Tests erfolgreich und 4 übersprungen.
- Nach der Proxy-Änderung erfolgreich: TypeScript, Main-Build, Renderer-Build und Self-Check. Die bekannte Vite-Warnung zum nun rund 575 KiB großen Renderer-Chunk bleibt bestehen.
- Root-Installation mit `npm ci`: erfolgreich.
- Backup-API-Installation mit `npm ci --prefix services/backup-api`: erfolgreich.
- TypeScript mit `npm exec -- tsc --noEmit`: erfolgreich.
@@ -171,6 +185,8 @@ npm exec -- tsc --noEmit
- `npm audit` meldet aktuell 36 Treffer: 2 niedrig, 5 mittel, 25 hoch und 4 kritisch. Direkte Treffer betreffen unter anderem Electron 31.7.7, Vite 6.4.1, Vitest 2.1.9, concurrently 9.2.1 und electron-builder 25.1.8. Ein Upgrade braucht einen eigenen getesteten Migrationsschritt; kein automatisches `npm audit fix` ausführen.
- Der separate Backup-API-Baum hat aktuell 0 Audit-Treffer.
- Windows ohne Developer Mode beziehungsweise passende Berechtigung kann die zwei Symlink-basierten Public-Release-Metadaten-Tests nicht ausführen.
- Proxy-Bündelung garantiert keine lineare Addition der Einzelgeschwindigkeiten. Ergebnis und Stabilität hängen unter anderem von Proxy-Latenz, Proxy-Bandbreite, Provider-/CDN-IP-Bindung, Range-Unterstützung, Zielserver-Limits, Dateigröße und lokaler Schreibgeschwindigkeit ab. Nach einem späten Segmentfehler kann vor dem Direkt-Fallback zusätzlicher Providerverkehr angefallen sein.
- Die bereitgestellte Proxy-Liste wurde mit dem ausdrücklich bereitgestellten Real-Debrid-Testlink erfolgreich real geprüft. Die Messung gilt nur für diesen Link, diesen Zeitpunkt und einen parallelen Download; daraus folgt noch keine Garantie für fünf gleichzeitige Downloads, andere CDN-Knoten oder dauerhafte Proxy-Leistung.
- Der Arbeitsordner enthält `&`; ohne PowerShell-7-Skriptshell können npm-`cmd`-Shims fehlschlagen.
- Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository.
- Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle.
@@ -181,7 +197,8 @@ npm exec -- tsc --noEmit
## Nächste sinnvolle Schritte
1. Für eine Auslieferung der aktuellen Fehlerkorrekturen zuerst Stand, Ziel, Tests und Rollback-Weg nennen und Saschas ausdrückliches Release-/Deployment-Go abwarten.
2. Nach einer freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
5. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten.
2. Vor einer Auslieferung den Proxy-Modus mit einer unkritischen großen Testdatei zunächst bei 2 bis 4, danach bei 16 Verbindungen messen und dabei Erfolgsquote, Zusatzverkehr, Zielserver-Kompatibilität, Festplattenlast sowie Netto-MB/s vergleichen. Erst anschließend `maxParallel` auf 5 erhöhen.
3. Nach einer freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
4. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
5. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
6. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten.
+1 -4
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
"https-proxy-agent": "7.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"uuid": "11.1.1"
@@ -2266,7 +2267,6 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -3637,7 +3637,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5062,7 +5061,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -5977,7 +5975,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
+1
View File
@@ -25,6 +25,7 @@
},
"dependencies": {
"adm-zip": "0.6.0",
"https-proxy-agent": "7.0.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"uuid": "11.1.1"
+3
View File
@@ -106,6 +106,9 @@ export function defaultSettings(): AppSettings {
speedLimitEnabled: false,
speedLimitKbps: 0,
speedLimitMode: "global",
proxyDownloadEnabled: false,
proxyListPath: "",
proxyConnectionsPerDownload: 16,
updateRepo: DEFAULT_UPDATE_REPO,
autoUpdateCheck: true,
clipboardWatch: false,
+118
View File
@@ -62,6 +62,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { downloadWithProxySegments, type ProxyFallbackReason } from "./proxy-segmented-download";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isRealDebridAccountBlockedForStart, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
@@ -10918,6 +10919,7 @@ export class DownloadManager extends EventEmitter {
let lastError = "";
let effectiveTargetPath = targetPath;
let resumeRewindBytesNextAttempt = 0;
let proxyAttempted = false;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let existingBytes = 0;
try {
@@ -10996,6 +10998,122 @@ export class DownloadManager extends EventEmitter {
await sleep(250);
}
if (!proxyAttempted && attempt === 1 && existingBytes === 0 && this.settings.proxyDownloadEnabled) {
proxyAttempted = true;
if (this.settings.speedLimitEnabled) {
logAttemptEvent("INFO", "Proxy-Segmentierung wegen aktivem Geschwindigkeitslimit übersprungen", {
attempt
});
} else {
const proxyReasonLabels: Record<ProxyFallbackReason, string> = {
proxy_file_unavailable: "Proxy-Liste nicht verfügbar",
no_valid_proxies: "Keine gültigen Proxys in der Liste",
range_unsupported: "Server unterstützt keine Byte-Bereiche",
file_too_small: "Datei ist für mehrere Segmente zu klein",
proxy_unavailable: "Proxys nicht erreichbar",
segment_failed: "Proxy-Segmente konnten nicht vollständig geladen werden"
};
let proxyWindowBytes = 0;
let proxyWindowStartedAt = nowMs();
let lastProxyUiEmitAt = 0;
item.fullStatus = `Proxy-Download startet (${this.settings.proxyConnectionsPerDownload} Verbindungen)`;
item.updatedAt = nowMs();
this.emitState();
logAttemptEvent("INFO", "Proxy-Segmentdownload startet", {
attempt,
connections: this.settings.proxyConnectionsPerDownload,
proxyListConfigured: Boolean(this.settings.proxyListPath)
});
const proxyResult = await downloadWithProxySegments({
directUrl,
targetPath: effectiveTargetPath,
proxyListPath: this.settings.proxyListPath,
connections: this.settings.proxyConnectionsPerDownload,
signal: active.abortController.signal,
skipTlsVerify,
waitWhilePaused: async () => {
while (this.session.paused && this.session.running && !active.abortController.signal.aborted) {
item.status = "paused";
item.fullStatus = "Pausiert";
item.updatedAt = nowMs();
this.emitState();
await sleep(120);
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
},
onTrafficBytes: (bytes) => {
proxyWindowBytes += bytes;
this.recordProviderDownloadedBytes(
item.provider,
bytes,
item.providerAccountId,
item.providerAccountLabel
);
this.recordSpeed(bytes, item.packageId);
},
onProgress: (deltaBytes, downloadedBytes, totalBytes) => {
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes + deltaBytes);
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes + deltaBytes);
this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) + deltaBytes);
this.itemContributedBytes.set(
active.itemId,
Math.max(0, (this.itemContributedBytes.get(active.itemId) || 0) + deltaBytes)
);
const nowTick = nowMs();
const elapsed = Math.max((nowTick - proxyWindowStartedAt) / 1000, 0.2);
item.status = this.session.paused ? "paused" : "downloading";
item.downloadedBytes = downloadedBytes;
item.totalBytes = totalBytes;
item.progressPercent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)));
item.speedBps = this.session.paused ? 0 : Math.max(0, Math.floor(proxyWindowBytes / elapsed));
item.fullStatus = this.session.paused
? "Pausiert"
: `Proxy-Download läuft (${this.settings.proxyConnectionsPerDownload} Verbindungen)`;
if (elapsed >= 0.5) {
proxyWindowStartedAt = nowTick;
proxyWindowBytes = 0;
}
if (nowTick - lastProxyUiEmitAt >= DOWNLOAD_LIVE_UPDATE_INTERVAL_MS || deltaBytes < 0) {
item.updatedAt = nowTick;
this.emitState();
lastProxyUiEmitAt = nowTick;
}
}
});
if (proxyResult.status === "completed") {
active.resumable = true;
item.status = "downloading";
item.downloadedBytes = proxyResult.totalBytes;
item.totalBytes = proxyResult.totalBytes;
item.progressPercent = 100;
item.speedBps = 0;
item.fullStatus = "Finalisierend...";
item.updatedAt = nowMs();
this.emitState();
logAttemptEvent("INFO", "Proxy-Segmentdownload abgeschlossen", {
attempt,
connections: proxyResult.connections,
totalBytes: proxyResult.totalBytes,
targetPath: effectiveTargetPath
});
return { resumable: true };
}
item.status = "downloading";
item.downloadedBytes = 0;
item.progressPercent = 0;
item.speedBps = 0;
item.fullStatus = "Direktdownload wird gestartet";
item.updatedAt = nowMs();
this.emitState();
logAttemptEvent("WARN", "Proxy-Segmentdownload nicht verwendet, Direktdownload folgt", {
attempt,
reason: proxyReasonLabels[proxyResult.reason]
});
}
}
let response: Response;
const connectTimeoutMs = getDownloadConnectTimeoutMs();
let connectTimer: NodeJS.Timeout | null = null;
+11
View File
@@ -945,6 +945,17 @@ function registerIpcHandlers(): void {
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
return result.canceled ? null : result.filePaths[0] || null;
});
handleTrusted(IPC_CHANNELS.PICK_PROXY_LIST, async () => {
const options = {
properties: ["openFile"] as Array<"openFile">,
filters: [
{ name: "Proxy-Listen", extensions: ["txt", "list"] },
{ name: "Alle Dateien", extensions: ["*"] }
]
};
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
return result.canceled ? null : result.filePaths[0] || null;
});
handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => {
const options = {
properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">,
+691
View File
@@ -0,0 +1,691 @@
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { IncomingMessage } from "node:http";
import { HttpsProxyAgent } from "https-proxy-agent";
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
const DEFAULT_MIN_SEGMENT_BYTES = 4 * 1024 * 1024;
const DEFAULT_PROXY_ATTEMPTS = 3;
const PROXY_PROBE_END = 1;
const MAX_REDIRECTS = 5;
const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024;
const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]);
interface ProxyEndpoint {
id: number;
url: string;
authorization: string;
}
interface CachedProxyFile {
mtimeMs: number;
size: number;
proxies: ProxyEndpoint[];
}
interface ParsedRange {
start: number;
end: number;
total: number;
}
interface RangeResponse {
response: IncomingMessage;
dispose: () => void;
}
interface Segment {
index: number;
start: number;
end: number;
}
export interface ProxySegmentedDownloadOptions {
directUrl: string;
targetPath: string;
proxyListPath: string;
connections: number;
signal: AbortSignal;
skipTlsVerify?: boolean;
minSegmentBytes?: number;
connectTimeoutMs?: number;
idleTimeoutMs?: number;
proxyAttempts?: number;
waitWhilePaused?: () => Promise<void>;
onTrafficBytes?: (bytes: number) => void;
onProgress?: (deltaBytes: number, downloadedBytes: number, totalBytes: number) => void;
}
export type ProxySegmentedDownloadResult =
| { status: "completed"; totalBytes: number; connections: number }
| { status: "fallback"; reason: ProxyFallbackReason };
export type ProxyFallbackReason =
| "proxy_file_unavailable"
| "no_valid_proxies"
| "range_unsupported"
| "file_too_small"
| "proxy_unavailable"
| "segment_failed";
const proxyFileCache = new Map<string, CachedProxyFile>();
function parseProxyLine(rawLine: string, id: number): ProxyEndpoint | null {
const value = rawLine.trim();
if (!value || value.startsWith("#") || /\s/.test(value)) {
return null;
}
let candidate = value;
if (!/^https?:\/\//i.test(candidate)) {
const parts = candidate.split(":");
if (!candidate.includes("@") && parts.length === 4 && /^\d+$/.test(parts[1])) {
const [host, port, username, password] = parts;
candidate = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
} else if (candidate.includes("@")) {
const separator = candidate.lastIndexOf("@");
const credentials = candidate.slice(0, separator);
const address = candidate.slice(separator + 1);
const credentialSeparator = credentials.indexOf(":");
if (credentialSeparator < 1 || !address) {
return null;
}
candidate = `http://${encodeURIComponent(credentials.slice(0, credentialSeparator))}:${encodeURIComponent(credentials.slice(credentialSeparator + 1))}@${address}`;
} else {
candidate = `http://${candidate}`;
}
}
try {
const parsed = new URL(candidate);
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname || !parsed.port) {
return null;
}
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return null;
}
let authorization = "";
if (parsed.username || parsed.password) {
const username = decodeURIComponent(parsed.username);
const password = decodeURIComponent(parsed.password);
authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
parsed.username = "";
parsed.password = "";
}
parsed.pathname = "";
parsed.search = "";
parsed.hash = "";
return { id, url: parsed.toString(), authorization };
} catch {
return null;
}
}
export function parseProxyList(content: string): number {
return content
.replace(/^\uFEFF/, "")
.split(/\r?\n/)
.reduce((count, line, index) => count + (parseProxyLine(line, index) ? 1 : 0), 0);
}
async function loadProxyFile(filePath: string): Promise<
{ status: "ok"; proxies: ProxyEndpoint[] }
| { status: "unavailable" }
| { status: "empty" }
> {
const normalizedPath = path.resolve(filePath.trim());
try {
const stat = await fs.promises.stat(normalizedPath);
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_PROXY_FILE_BYTES) {
return { status: stat.size <= 0 ? "empty" : "unavailable" };
}
const cached = proxyFileCache.get(normalizedPath);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return cached.proxies.length > 0
? { status: "ok", proxies: cached.proxies }
: { status: "empty" };
}
const content = await fs.promises.readFile(normalizedPath, "utf8");
const seen = new Set<string>();
const proxies = content
.replace(/^\uFEFF/, "")
.split(/\r?\n/)
.map((line, index) => parseProxyLine(line, index))
.filter((proxy): proxy is ProxyEndpoint => {
if (!proxy) return false;
const key = `${proxy.url}\0${proxy.authorization}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
proxyFileCache.set(normalizedPath, { mtimeMs: stat.mtimeMs, size: stat.size, proxies });
return proxies.length > 0 ? { status: "ok", proxies } : { status: "empty" };
} catch {
return { status: "unavailable" };
}
}
function parseContentRange(value: string | undefined): ParsedRange | null {
const match = /^bytes\s+(\d+)-(\d+)\/(\d+)$/i.exec(String(value || "").trim());
if (!match) {
return null;
}
const start = Number(match[1]);
const end = Number(match[2]);
const total = Number(match[3]);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || !Number.isSafeInteger(total)
|| start < 0 || end < start || total <= end) {
return null;
}
return { start, end, total };
}
function abortError(): Error {
return new Error("aborted:proxy_download");
}
function isDiskError(error: unknown): boolean {
const code = error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code || "")
: "";
return DISK_ERROR_CODES.has(code);
}
async function openRangeResponse(
target: URL,
proxy: ProxyEndpoint,
start: number,
end: number,
signal: AbortSignal,
skipTlsVerify: boolean,
connectTimeoutMs: number,
idleTimeoutMs: number,
redirectCount = 0
): Promise<RangeResponse> {
if (signal.aborted) {
throw abortError();
}
const agent = new HttpsProxyAgent(proxy.url, {
keepAlive: false,
headers: proxy.authorization ? { "Proxy-Authorization": proxy.authorization } : {}
});
const requestOptions: https.RequestOptions = {
method: "GET",
headers: {
Accept: "*/*",
Range: `bytes=${start}-${end}`,
"User-Agent": "Multi-Debrid-Downloader"
},
agent,
rejectUnauthorized: !skipTlsVerify
};
return new Promise<RangeResponse>((resolve, reject) => {
let settled = false;
let responseRef: IncomingMessage | null = null;
const requestFn: typeof http.request = target.protocol === "https:" ? https.request : http.request;
const request = requestFn(target, requestOptions, (response) => {
responseRef = response;
clearTimeout(connectTimer);
const status = response.statusCode || 0;
const location = response.headers.location;
if (status >= 300 && status < 400 && location) {
settled = true;
response.resume();
response.once("end", () => {
cleanup();
if (redirectCount >= MAX_REDIRECTS) {
reject(new Error("proxy_redirect_limit"));
return;
}
let redirected: URL;
try {
redirected = new URL(location, target);
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") {
reject(new Error("proxy_redirect_invalid"));
return;
}
} catch {
reject(new Error("proxy_redirect_invalid"));
return;
}
openRangeResponse(
redirected,
proxy,
start,
end,
signal,
skipTlsVerify,
connectTimeoutMs,
idleTimeoutMs,
redirectCount + 1
).then(resolve, reject);
});
return;
}
settled = true;
response.setTimeout(idleTimeoutMs, () => response.destroy(new Error("proxy_idle_timeout")));
response.once("close", cleanup);
resolve({
response,
dispose: () => {
cleanup();
response.destroy();
}
});
});
const cleanup = (): void => {
clearTimeout(connectTimer);
signal.removeEventListener("abort", onAbort);
agent.destroy();
};
const onAbort = (): void => {
request.destroy(abortError());
responseRef?.destroy(abortError());
};
const connectTimer = setTimeout(() => request.destroy(new Error("proxy_connect_timeout")), connectTimeoutMs);
signal.addEventListener("abort", onAbort, { once: true });
request.once("error", (error) => {
cleanup();
if (!settled) {
reject(signal.aborted ? abortError() : error);
}
});
request.setTimeout(idleTimeoutMs, () => request.destroy(new Error("proxy_idle_timeout")));
request.end();
});
}
async function consumeProbe(response: IncomingMessage, expectedBytes: number, onTrafficBytes?: (bytes: number) => void): Promise<number> {
let received = 0;
for await (const rawChunk of response) {
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
received += chunk.length;
onTrafficBytes?.(chunk.length);
if (received > expectedBytes) {
throw new Error("proxy_probe_overflow");
}
}
return received;
}
async function probeRangeSupport(
target: URL,
proxies: ProxyEndpoint[],
signal: AbortSignal,
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs" | "proxyAttempts">>,
onTrafficBytes?: (bytes: number) => void
): Promise<
{ status: "ok"; totalBytes: number }
| { status: "unsupported" }
| { status: "unavailable" }
> {
const attempts = Math.min(proxies.length, options.proxyAttempts);
for (let index = 0; index < attempts; index += 1) {
let opened: RangeResponse | null = null;
try {
opened = await openRangeResponse(
target,
proxies[Math.floor((index * proxies.length) / attempts)],
0,
PROXY_PROBE_END,
signal,
options.skipTlsVerify,
options.connectTimeoutMs,
options.idleTimeoutMs
);
const status = opened.response.statusCode || 0;
if (status === 200) {
opened.dispose();
return { status: "unsupported" };
}
const range = parseContentRange(opened.response.headers["content-range"]);
const expectedEnd = range ? Math.min(PROXY_PROBE_END, range.total - 1) : -1;
const expectedBytes = expectedEnd + 1;
const contentLength = Number(opened.response.headers["content-length"] || 0);
if (status !== 206
|| !range
|| range.start !== 0
|| range.end !== expectedEnd
|| (contentLength > 0 && contentLength !== expectedBytes)) {
opened.dispose();
continue;
}
const received = await consumeProbe(opened.response, expectedBytes, onTrafficBytes);
opened.dispose();
if (received === expectedBytes) {
return { status: "ok", totalBytes: range.total };
}
} catch (error) {
opened?.dispose();
if (signal.aborted) {
throw abortError();
}
if (isDiskError(error)) {
throw error;
}
}
}
return { status: "unavailable" };
}
function buildSegments(totalBytes: number, count: number): Segment[] {
const baseSize = Math.floor(totalBytes / count);
const remainder = totalBytes % count;
let start = 0;
return Array.from({ length: count }, (_, index) => {
const size = baseSize + (index < remainder ? 1 : 0);
const segment = { index, start, end: start + size - 1 };
start += size;
return segment;
});
}
class ProxyPool {
private cursor = 0;
private active = new Set<number>();
private cooldownUntil = new Map<number, number>();
public constructor(private readonly proxies: ProxyEndpoint[]) {}
public acquire(excluded: ReadonlySet<number>): ProxyEndpoint | null {
const now = Date.now();
const select = (allowActive: boolean, allowCooldown: boolean): ProxyEndpoint | null => {
for (let offset = 0; offset < this.proxies.length; offset += 1) {
const index = (this.cursor + offset) % this.proxies.length;
const proxy = this.proxies[index];
if (excluded.has(proxy.id)
|| (!allowActive && this.active.has(proxy.id))
|| (!allowCooldown && (this.cooldownUntil.get(proxy.id) || 0) > now)) {
continue;
}
this.cursor = (index + 1) % this.proxies.length;
this.active.add(proxy.id);
return proxy;
}
return null;
};
return select(false, false) || select(false, true) || select(true, true);
}
public release(proxy: ProxyEndpoint, succeeded: boolean): void {
this.active.delete(proxy.id);
if (succeeded) {
this.cooldownUntil.delete(proxy.id);
} else {
this.cooldownUntil.set(proxy.id, Date.now() + 30_000);
}
}
}
async function writeBufferAt(handle: fs.promises.FileHandle, buffer: Buffer, position: number): Promise<void> {
let offset = 0;
while (offset < buffer.length) {
const result = await handle.write(buffer, offset, buffer.length - offset, position + offset);
if (result.bytesWritten <= 0) {
throw new Error("proxy_disk_write_zero");
}
offset += result.bytesWritten;
}
}
async function downloadSegmentOnce(
target: URL,
tempPath: string,
segment: Segment,
proxy: ProxyEndpoint,
signal: AbortSignal,
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs">>,
callbacks: Pick<ProxySegmentedDownloadOptions, "waitWhilePaused" | "onTrafficBytes" | "onProgress">,
totalBytes: number,
currentProgress: () => number
): Promise<number> {
const opened = await openRangeResponse(
target,
proxy,
segment.start,
segment.end,
signal,
options.skipTlsVerify,
options.connectTimeoutMs,
options.idleTimeoutMs
);
const response = opened.response;
const expectedBytes = segment.end - segment.start + 1;
const range = parseContentRange(response.headers["content-range"]);
const contentLength = Number(response.headers["content-length"] || 0);
if ((response.statusCode || 0) !== 206
|| !range
|| range.start !== segment.start
|| range.end !== segment.end
|| range.total !== totalBytes
|| (contentLength > 0 && contentLength !== expectedBytes)) {
opened.dispose();
throw new Error("proxy_range_mismatch");
}
let handle: fs.promises.FileHandle;
try {
handle = await fs.promises.open(tempPath, "r+");
} catch (error) {
opened.dispose();
throw error;
}
let received = 0;
try {
for await (const rawChunk of response) {
if (signal.aborted) {
throw abortError();
}
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
callbacks.onTrafficBytes?.(chunk.length);
if (received + chunk.length > expectedBytes) {
throw new Error("proxy_segment_overflow");
}
if (callbacks.waitWhilePaused) {
response.setTimeout(0);
await callbacks.waitWhilePaused();
response.setTimeout(options.idleTimeoutMs);
}
await writeBufferAt(handle, chunk, segment.start + received);
received += chunk.length;
callbacks.onProgress?.(chunk.length, currentProgress() + chunk.length, totalBytes);
}
if (received !== expectedBytes) {
throw new Error("proxy_segment_underflow");
}
return received;
} finally {
opened.dispose();
await handle.close();
}
}
export async function downloadWithProxySegments(options: ProxySegmentedDownloadOptions): Promise<ProxySegmentedDownloadResult> {
const proxyPath = String(options.proxyListPath || "").trim();
if (!proxyPath) {
return { status: "fallback", reason: "proxy_file_unavailable" };
}
const loaded = await loadProxyFile(proxyPath);
if (loaded.status === "unavailable") {
return { status: "fallback", reason: "proxy_file_unavailable" };
}
if (loaded.status === "empty") {
return { status: "fallback", reason: "no_valid_proxies" };
}
let target: URL;
try {
target = new URL(options.directUrl);
if (target.protocol !== "http:" && target.protocol !== "https:") {
return { status: "fallback", reason: "proxy_unavailable" };
}
} catch {
return { status: "fallback", reason: "proxy_unavailable" };
}
const normalized = {
skipTlsVerify: Boolean(options.skipTlsVerify),
connectTimeoutMs: Math.max(1_000, Math.floor(options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)),
idleTimeoutMs: Math.max(2_000, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS)),
proxyAttempts: Math.max(1, Math.min(10, Math.floor(options.proxyAttempts ?? DEFAULT_PROXY_ATTEMPTS)))
};
const probe = await probeRangeSupport(target, loaded.proxies, options.signal, normalized, options.onTrafficBytes);
if (probe.status === "unsupported") {
return { status: "fallback", reason: "range_unsupported" };
}
if (probe.status === "unavailable") {
return { status: "fallback", reason: "proxy_unavailable" };
}
const requestedConnections = Math.max(2, Math.min(32, Math.floor(options.connections || 16)));
const minSegmentBytes = Math.max(1, Math.floor(options.minSegmentBytes ?? DEFAULT_MIN_SEGMENT_BYTES));
const connections = Math.min(requestedConnections, loaded.proxies.length, Math.floor(probe.totalBytes / minSegmentBytes));
if (connections < 2) {
return { status: "fallback", reason: "file_too_small" };
}
await fs.promises.mkdir(path.dirname(options.targetPath), { recursive: true });
try {
const targetStat = await fs.promises.stat(options.targetPath);
if (targetStat.size > 0) {
return { status: "fallback", reason: "segment_failed" };
}
await fs.promises.rm(options.targetPath, { force: true });
} catch (error) {
const code = error && typeof error === "object" && "code" in error
? String((error as NodeJS.ErrnoException).code || "")
: "";
if (code !== "ENOENT") {
throw error;
}
}
const tempPath = `${options.targetPath}.proxy-${process.pid}-${randomUUID()}.part`;
let committedProgress = 0;
let tempCreated = false;
const updateProgress = (deltaBytes: number, _downloadedBytes: number, totalBytes: number): void => {
committedProgress = Math.max(0, Math.min(totalBytes, committedProgress + deltaBytes));
options.onProgress?.(deltaBytes, committedProgress, totalBytes);
};
try {
const initialHandle = await fs.promises.open(tempPath, "wx");
tempCreated = true;
try {
await initialHandle.truncate(probe.totalBytes);
} finally {
await initialHandle.close();
}
const segments = buildSegments(probe.totalBytes, connections);
const pool = new ProxyPool(loaded.proxies);
const segmentController = new AbortController();
const cascadeAbort = (): void => segmentController.abort("parent_abort");
options.signal.addEventListener("abort", cascadeAbort, { once: true });
const signal = AbortSignal.any([options.signal, segmentController.signal]);
const tasks = segments.map(async (segment) => {
const excluded = new Set<number>();
let lastError: unknown = null;
for (let attempt = 0; attempt < normalized.proxyAttempts; attempt += 1) {
if (signal.aborted) {
throw abortError();
}
const proxy = pool.acquire(excluded);
if (!proxy) {
break;
}
excluded.add(proxy.id);
let attemptProgress = 0;
try {
const bytes = await downloadSegmentOnce(
target,
tempPath,
segment,
proxy,
signal,
normalized,
{
waitWhilePaused: options.waitWhilePaused,
onTrafficBytes: options.onTrafficBytes,
onProgress: (deltaBytes, downloadedBytes, totalBytes) => {
attemptProgress += deltaBytes;
updateProgress(deltaBytes, downloadedBytes, totalBytes);
}
},
probe.totalBytes,
() => committedProgress
);
pool.release(proxy, true);
return bytes;
} catch (error) {
pool.release(proxy, false);
if (attemptProgress > 0) {
updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes);
}
if (options.signal.aborted || isDiskError(error)) {
throw error;
}
lastError = error;
}
}
throw lastError || new Error("proxy_segment_failed");
});
const guardedTasks = tasks.map((task) => task.catch((error) => {
segmentController.abort("segment_failed");
throw error;
}));
const settled = await Promise.allSettled(guardedTasks);
options.signal.removeEventListener("abort", cascadeAbort);
const failed = settled.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (failed) {
if (options.signal.aborted) {
throw abortError();
}
if (isDiskError(failed.reason)) {
throw failed.reason;
}
return { status: "fallback", reason: "segment_failed" };
}
const finalizedHandle = await fs.promises.open(tempPath, "r+");
try {
await finalizedHandle.datasync();
} finally {
await finalizedHandle.close();
}
const finalStat = await fs.promises.stat(tempPath);
if (finalStat.size !== probe.totalBytes) {
return { status: "fallback", reason: "segment_failed" };
}
await fs.promises.rename(tempPath, options.targetPath);
tempCreated = false;
return { status: "completed", totalBytes: probe.totalBytes, connections };
} catch (error) {
if (options.signal.aborted || String(error).includes("aborted:proxy_download")) {
throw abortError();
}
if (isDiskError(error)) {
throw error;
}
return { status: "fallback", reason: "segment_failed" };
} finally {
if (tempCreated) {
try {
await fs.promises.rm(tempPath, { force: true });
} catch {
}
}
if (committedProgress > 0 && tempCreated) {
updateProgress(-committedProgress, 0, probe.totalBytes);
}
}
}
+3
View File
@@ -204,6 +204,9 @@ export function createRendererSettings(settings: AppSettings): RendererSettings
speedLimitEnabled: settings.speedLimitEnabled,
speedLimitKbps: settings.speedLimitKbps,
speedLimitMode: settings.speedLimitMode,
proxyDownloadEnabled: settings.proxyDownloadEnabled,
proxyListPath: settings.proxyListPath,
proxyConnectionsPerDownload: settings.proxyConnectionsPerDownload,
updateRepo: settings.updateRepo,
autoUpdateCheck: settings.autoUpdateCheck,
clipboardWatch: settings.clipboardWatch,
+3
View File
@@ -612,6 +612,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
speedLimitEnabled: Boolean(settings.speedLimitEnabled),
speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000),
speedLimitMode: settings.speedLimitMode,
proxyDownloadEnabled: Boolean(settings.proxyDownloadEnabled),
proxyListPath: asText(settings.proxyListPath).slice(0, 4096),
proxyConnectionsPerDownload: clampNumber(settings.proxyConnectionsPerDownload, defaults.proxyConnectionsPerDownload, 2, 32),
autoUpdateCheck: Boolean(settings.autoUpdateCheck),
updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo),
clipboardWatch: Boolean(settings.clipboardWatch),
+1
View File
@@ -94,6 +94,7 @@ const api: ElectronApi = {
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
writeClipboardText: (text: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
pickProxyList: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_PROXY_LIST),
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
+10 -1
View File
@@ -953,6 +953,7 @@ const emptySnapshot = (): UiSnapshot => ({
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
proxyDownloadEnabled: false, proxyListPath: "", proxyConnectionsPerDownload: 16,
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
@@ -6064,7 +6065,8 @@ export function App(): ReactElement {
historyMaxEntries: [50, 100000, 500],
historyMaxAgeDays: [0, 3650, 0],
maxParallelExtract: [1, 8, 2],
reconnectWaitSeconds: [10, 600, 45]
reconnectWaitSeconds: [10, 600, 45],
proxyConnectionsPerDownload: [2, 32, 16]
};
const bounds = numericLimits[fieldId as keyof RendererSettingsDraft];
if (bounds) {
@@ -6128,6 +6130,13 @@ export function App(): ReactElement {
});
return;
}
if (fieldId === "proxyListPath") {
void performQuickAction(async () => {
const selectedPath = await window.rd.pickProxyList();
if (selectedPath) setText("proxyListPath", selectedPath);
});
return;
}
const targetKey = fieldId === "outputDir" ? "outputDir" : fieldId === "extractDir" ? "extractDir" : fieldId === "mkvLibraryDir" ? "mkvLibraryDir" : null;
if (targetKey) {
void performQuickAction(async () => {
@@ -422,6 +422,40 @@ export function buildSettingsFormViewModel({
{ id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 }
]
},
{
id: "speed-proxy",
title: "Proxy-Download",
description: "Teilt neue Downloads in Byte-Bereiche und lädt sie parallel über verschiedene HTTP-Proxys. Falls der Server keine Bereiche unterstützt oder die Proxys ausfallen, läuft der bestehende Direktdownload weiter.",
fields: [
{
id: "proxyDownloadEnabled",
kind: "switch",
label: "Proxy-Download aktivieren",
value: settings.proxyDownloadEnabled,
help: "Gilt für neue Dateien ab 8 MiB. Ein aktives Geschwindigkeitslimit verwendet weiterhin den Direktdownload."
},
{
id: "proxyListPath",
kind: "path",
label: "Proxy-Liste",
value: settings.proxyListPath,
placeholder: "Textdatei mit einem Proxy pro Zeile",
actionLabel: "Datei wählen",
disabled: !settings.proxyDownloadEnabled,
help: "Unterstützt unter anderem Benutzer:Passwort@Host:Port. Zugangsdaten werden nicht protokolliert."
},
{
id: "proxyConnectionsPerDownload",
kind: "number",
label: "Verbindungen pro Download",
value: String(settings.proxyConnectionsPerDownload),
min: 2,
max: 32,
disabled: !settings.proxyDownloadEnabled,
help: `Bei ${settings.maxParallel} gleichzeitigen Downloads sind bis zu ${settings.maxParallel * settings.proxyConnectionsPerDownload} Proxy-Verbindungen möglich.`
}
]
},
...scheduleGroups,
{
id: "speed-schedule-add",
+1
View File
@@ -39,6 +39,7 @@ export const IPC_CHANNELS = {
EXPORT_QUEUE: "queue:export",
IMPORT_QUEUE: "queue:import",
PICK_FOLDER: "dialog:pick-folder",
PICK_PROXY_LIST: "dialog:pick-proxy-list",
PICK_CONTAINERS: "dialog:pick-containers",
STATE_UPDATE: "state:update",
CLIPBOARD_DETECTED: "clipboard:detected",
+1
View File
@@ -114,6 +114,7 @@ export interface ElectronApi {
toggleClipboard: () => Promise<boolean>;
writeClipboardText: (text: string) => Promise<boolean>;
pickFolder: () => Promise<string | null>;
pickProxyList: () => Promise<string | null>;
pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>;
resetSessionStats: () => Promise<void>;
+8 -2
View File
@@ -139,7 +139,13 @@ export interface DailyStartSettings {
dailyStartLastOutcome: DailyStartOutcome;
}
export interface AppSettings extends DailyStartSettings {
export interface ProxyDownloadSettings {
proxyDownloadEnabled: boolean;
proxyListPath: string;
proxyConnectionsPerDownload: number;
}
export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings {
language: AppLanguage;
token: string;
realDebridUseWebLogin: boolean;
@@ -291,7 +297,7 @@ export interface RendererAccount {
status: DebridAccountStatus | null;
}
export interface RendererSettings extends DailyStartSettings {
export interface RendererSettings extends DailyStartSettings, ProxyDownloadSettings {
language: AppLanguage;
realDebridUseWebLogin: boolean;
realDebridDisabledAccountIds: string[];
+274
View File
@@ -0,0 +1,274 @@
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { downloadWithProxySegments, parseProxyList } from "../src/main/proxy-segmented-download";
interface RunningServer {
port: number;
close: () => Promise<void>;
}
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
async function listen(server: http.Server): Promise<RunningServer> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Testserver konnte nicht gestartet werden");
}
const sockets = new Set<net.Socket>();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
const close = async (): Promise<void> => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
};
cleanups.push(close);
return { port: address.port, close };
}
async function createRangeServer(content: Buffer, supportsRanges = true): Promise<RunningServer & { ranges: string[] }> {
const ranges: string[] = [];
const server = http.createServer((request, response) => {
const rangeHeader = String(request.headers.range || "");
ranges.push(rangeHeader);
if (!supportsRanges || !rangeHeader) {
response.writeHead(200, { "Content-Length": content.length });
response.end(content);
return;
}
const match = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader);
if (!match) {
response.writeHead(416, { "Content-Range": `bytes */${content.length}` });
response.end();
return;
}
const start = Number(match[1]);
const end = Math.min(Number(match[2]), content.length - 1);
const body = content.subarray(start, end + 1);
response.writeHead(206, {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${content.length}`,
"Content-Length": body.length
});
response.end(body);
});
const running = await listen(server);
return { ...running, ranges };
}
async function createConnectProxy(username: string, password: string, acceptedConnections: { value: number }): Promise<RunningServer> {
const server = http.createServer();
server.on("connect", (request, clientSocket, head) => {
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
if (request.headers["proxy-authorization"] !== expectedAuth) {
clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
return;
}
const target = new URL(`http://${request.url}`);
const upstream = net.connect(Number(target.port), target.hostname, () => {
acceptedConnections.value += 1;
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) upstream.write(head);
clientSocket.pipe(upstream);
upstream.pipe(clientSocket);
});
upstream.once("error", () => clientSocket.destroy());
clientSocket.once("error", () => upstream.destroy());
});
return listen(server);
}
async function createRejectingProxy(): Promise<RunningServer> {
const server = http.createServer();
server.on("connect", (_request, socket) => socket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"));
return listen(server);
}
async function createTempDirectory(): Promise<string> {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-proxy-test-"));
cleanups.push(() => fs.promises.rm(directory, { recursive: true, force: true }));
return directory;
}
describe("proxy segmented download", () => {
it("accepts authenticated premium proxy list formats without exposing entries", () => {
const secret = "private-password";
const count = parseProxyList([
`user:${secret}@127.0.0.1:8080`,
"http://name:password@example.test:3128",
"127.0.0.1:9000:name:password",
"invalid",
"# ignored"
].join("\n"));
expect(count).toBe(3);
expect(String(count)).not.toContain(secret);
});
it("downloads exact byte segments through different authenticated proxies", async () => {
const content = Buffer.allocUnsafe(256 * 1024);
for (let index = 0; index < content.length; index += 1) content[index] = index % 251;
const targetServer = await createRangeServer(content);
const proxyCounters = Array.from({ length: 4 }, () => ({ value: 0 }));
const proxies = await Promise.all(proxyCounters.map((counter, index) => createConnectProxy(`user${index}`, `pass${index}`, counter)));
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
const targetFile = path.join(directory, "archive.bin");
await fs.promises.writeFile(
proxyFile,
proxies.map((proxy, index) => `user${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n"),
"utf8"
);
const progress: number[] = [];
let trafficBytes = 0;
const result = await downloadWithProxySegments({
directUrl: `http://127.0.0.1:${targetServer.port}/archive.bin`,
targetPath: targetFile,
proxyListPath: proxyFile,
connections: 4,
signal: new AbortController().signal,
minSegmentBytes: 1,
onTrafficBytes: (bytes) => { trafficBytes += bytes; },
onProgress: (_delta, downloaded) => progress.push(downloaded)
});
expect(result).toEqual({ status: "completed", totalBytes: content.length, connections: 4 });
expect(await fs.promises.readFile(targetFile)).toEqual(content);
expect(proxyCounters.every((counter) => counter.value > 0)).toBe(true);
expect(targetServer.ranges).toContain("bytes=0-1");
expect(new Set(targetServer.ranges.filter((range) => range !== "bytes=0-1")).size).toBe(4);
expect(progress.at(-1)).toBe(content.length);
expect(trafficBytes).toBe(content.length + 2);
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
});
it("uses another proxy after a rejected connection", async () => {
const content = Buffer.alloc(64 * 1024, 7);
const targetServer = await createRangeServer(content);
const rejected = await createRejectingProxy();
const counters = Array.from({ length: 3 }, () => ({ value: 0 }));
const working = await Promise.all(counters.map((counter, index) => createConnectProxy(`ok${index}`, `secret${index}`, counter)));
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
const targetFile = path.join(directory, "retry.bin");
await fs.promises.writeFile(proxyFile, [
`bad:bad@127.0.0.1:${rejected.port}`,
...working.map((proxy, index) => `ok${index}:secret${index}@127.0.0.1:${proxy.port}`)
].join("\n"));
const result = await downloadWithProxySegments({
directUrl: `http://127.0.0.1:${targetServer.port}/retry.bin`,
targetPath: targetFile,
proxyListPath: proxyFile,
connections: 2,
signal: new AbortController().signal,
minSegmentBytes: 1
});
expect(result.status).toBe("completed");
expect(await fs.promises.readFile(targetFile)).toEqual(content);
expect(counters.some((counter) => counter.value > 0)).toBe(true);
});
it("falls back cleanly when the origin ignores byte ranges", async () => {
const content = Buffer.alloc(4096, 3);
const targetServer = await createRangeServer(content, false);
const counter = { value: 0 };
const proxy = await createConnectProxy("user", "password", counter);
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
const targetFile = path.join(directory, "fallback.bin");
await fs.promises.writeFile(proxyFile, `user:password@127.0.0.1:${proxy.port}`);
const result = await downloadWithProxySegments({
directUrl: `http://127.0.0.1:${targetServer.port}/fallback.bin`,
targetPath: targetFile,
proxyListPath: proxyFile,
connections: 4,
signal: new AbortController().signal,
minSegmentBytes: 1
});
expect(result).toEqual({ status: "fallback", reason: "range_unsupported" });
expect(fs.existsSync(targetFile)).toBe(false);
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
});
it("cancels all segments and removes temporary data", async () => {
const content = Buffer.alloc(256 * 1024, 5);
const server = http.createServer((request, response) => {
const match = /^bytes=(\d+)-(\d+)$/.exec(String(request.headers.range || ""));
if (!match) {
response.writeHead(416);
response.end();
return;
}
const start = Number(match[1]);
const end = Number(match[2]);
const body = content.subarray(start, end + 1);
response.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${content.length}`,
"Content-Length": body.length
});
if (start === 0 && end === 1) {
response.end(body);
return;
}
let offset = 0;
const timer = setInterval(() => {
if (offset >= body.length) {
clearInterval(timer);
response.end();
return;
}
const next = Math.min(body.length, offset + 1024);
response.write(body.subarray(offset, next));
offset = next;
}, 5);
response.once("close", () => clearInterval(timer));
});
const targetServer = await listen(server);
const counters = Array.from({ length: 2 }, () => ({ value: 0 }));
const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`cancel${index}`, `pass${index}`, counter)));
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
const targetFile = path.join(directory, "cancel.bin");
await fs.promises.writeFile(
proxyFile,
proxies.map((proxy, index) => `cancel${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
);
const controller = new AbortController();
const progress: number[] = [];
const download = downloadWithProxySegments({
directUrl: `http://127.0.0.1:${targetServer.port}/cancel.bin`,
targetPath: targetFile,
proxyListPath: proxyFile,
connections: 2,
signal: controller.signal,
minSegmentBytes: 1,
onProgress: (_delta, downloaded) => {
progress.push(downloaded);
if (downloaded >= 4096) controller.abort("test_cancel");
}
});
await expect(download).rejects.toThrow("aborted:proxy_download");
expect(progress.at(-1)).toBe(0);
expect(fs.existsSync(targetFile)).toBe(false);
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
});
});
+37
View File
@@ -642,6 +642,43 @@ describe("settings views", () => {
});
});
it("projects proxy segmentation controls with safe defaults and connection totals", () => {
const form = buildSettingsFormViewModel({
settings: {
...createRendererSettings({
...defaultSettings(),
maxParallel: 5,
proxyDownloadEnabled: true,
proxyListPath: "C:\\proxy-list.txt",
proxyConnectionsPerDownload: 16
}),
archivePasswordList: "",
notifyUrl: ""
},
section: "speed",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const proxyGroup = form.groups.find((group) => group.id === "speed-proxy");
expect(proxyGroup?.fields.map((field) => field.id)).toEqual([
"proxyDownloadEnabled",
"proxyListPath",
"proxyConnectionsPerDownload"
]);
expect(proxyGroup?.fields.find((field) => field.id === "proxyListPath")).toEqual(expect.objectContaining({
value: "C:\\proxy-list.txt",
actionLabel: "Datei wählen",
disabled: false
}));
expect(proxyGroup?.fields.find((field) => field.id === "proxyConnectionsPerDownload")).toEqual(expect.objectContaining({
value: "16",
min: 2,
max: 32,
help: expect.stringContaining("80 Proxy-Verbindungen")
}));
});
it("projects every notification control in the required order with exact bounds", () => {
const form = buildSettingsFormViewModel({
settings: {
+12
View File
@@ -582,6 +582,9 @@ describe("settings storage", () => {
retryLimit: 999,
reconnectWaitSeconds: 9999,
speedLimitKbps: -1,
proxyDownloadEnabled: true,
proxyListPath: " C:\\proxies.txt ",
proxyConnectionsPerDownload: 999,
outputDir: " ",
extractDir: " ",
mkvLibraryDir: " ",
@@ -599,6 +602,9 @@ describe("settings storage", () => {
expect(normalized.retryLimit).toBe(99);
expect(normalized.reconnectWaitSeconds).toBe(600);
expect(normalized.speedLimitKbps).toBe(0);
expect(normalized.proxyDownloadEnabled).toBe(true);
expect(normalized.proxyListPath).toBe("C:\\proxies.txt");
expect(normalized.proxyConnectionsPerDownload).toBe(32);
expect(normalized.outputDir).toBe(defaultSettings().outputDir);
expect(normalized.extractDir).toBe(defaultSettings().extractDir);
expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir);
@@ -631,6 +637,9 @@ describe("settings storage", () => {
retryLimit: "-3",
reconnectWaitSeconds: "1",
speedLimitMode: "not-valid",
proxyDownloadEnabled: 1,
proxyListPath: " C:\\proxy-list.txt ",
proxyConnectionsPerDownload: "1",
updateRepo: "",
autoSortPackagesByProgress: false
}),
@@ -644,6 +653,9 @@ describe("settings storage", () => {
expect(loaded.retryLimit).toBe(0);
expect(loaded.reconnectWaitSeconds).toBe(10);
expect(loaded.speedLimitMode).toBe("global");
expect(loaded.proxyDownloadEnabled).toBe(true);
expect(loaded.proxyListPath).toBe("C:\\proxy-list.txt");
expect(loaded.proxyConnectionsPerDownload).toBe(2);
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
expect(loaded.autoSortPackagesByProgress).toBe(false);
});
+3
View File
@@ -117,6 +117,9 @@ function createSettings(): AppSettings {
speedLimitEnabled: false,
speedLimitKbps: 0,
speedLimitMode: "global",
proxyDownloadEnabled: false,
proxyListPath: "",
proxyConnectionsPerDownload: 16,
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
autoUpdateCheck: true,
clipboardWatch: true,
+1
View File
@@ -168,6 +168,7 @@ export function createVisualElectronApi(
},
writeClipboardText: async () => true,
pickFolder: async () => "C:\\Visual\\Selected",
pickProxyList: async () => "C:\\Visual\\proxy-list.txt",
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
getSessionStats: async () => ({
bandwidth: {