fix: begrenze parallele Proxy-Segmente global
This commit is contained in:
@@ -4,6 +4,12 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Proxy concurrency hotfix
|
||||
|
||||
- Treat the configured proxy connection count as one process-wide segment connection budget shared by all simultaneous downloads instead of multiplying it per file.
|
||||
- Coordinate proxy leases across downloads, prevent concurrent reuse of the same list entry, and reserve the fixed API proxy from segment traffic.
|
||||
- Preserve HTTP status codes returned by the download origin so reachable proxies receiving responses such as HTTP 503 are no longer reported as unavailable proxies.
|
||||
|
||||
## [2.0.75] - 2026-08-31
|
||||
|
||||
### Proxy-only networking
|
||||
|
||||
+16
-6
@@ -10,7 +10,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
|
||||
- Verifiziert am: 31. August 2026, Europe/Berlin
|
||||
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
||||
- Arbeitsbranch: `release/v2.0.75`
|
||||
- Arbeitsbranch: `hotfix/proxy-global-budget`
|
||||
- Quellbasis: `release/v2.0.74`
|
||||
- Release-Tag: `v2.0.75`
|
||||
- Baseline-Commit: `c1e1095231ab72d71dfa9af5a3e5d2dce691d0cb`
|
||||
@@ -23,7 +23,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- GitHub `main` steht auf `4d5161b` (`v2.0.35`) und ist gegenüber `release/v2.0.74` mit 7 zu 136 Commits auseinanderentwickelt.
|
||||
- Forgejo `main` steht auf `a8c4dcc` (`v1.7.232`) und ist noch deutlich älter.
|
||||
- GitHub und Forgejo zeigen für `release/v2.0.74` beide exakt auf `c1e1095`.
|
||||
- Neue Arbeit muss bis zu einer ausdrücklich geplanten Branch-Bereinigung von `release/v2.0.75` beziehungsweise einem davon abgeleiteten Arbeitsbranch ausgehen.
|
||||
- Neue Arbeit muss bis zu einer ausdrücklich geplanten Branch-Bereinigung von `release/v2.0.75` beziehungsweise einem davon abgeleiteten Arbeitsbranch ausgehen. Der aktuelle unveröffentlichte Hotfix basiert direkt auf `release/v2.0.75`.
|
||||
|
||||
## Remotes
|
||||
|
||||
@@ -67,7 +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.
|
||||
- Der optionale Proxy-only-Modus leitet sämtliche Main-Prozess-API-, Link-Auflösungs- und normalen Download-Requests über einen fest ausgewählten authentifizierten HTTP-CONNECT-Proxy. Electron-Web-Logins und deren Session-Fetches erhalten denselben festen Proxy. Neue Dateien ab 8 MiB können zusätzlich in 2 bis 32 exakten HTTP-Range-Bereichen parallel über die gesamte Proxy-Liste geladen werden.
|
||||
- Der optionale Proxy-only-Modus leitet sämtliche Main-Prozess-API-, Link-Auflösungs- und normalen Download-Requests über einen fest ausgewählten authentifizierten HTTP-CONNECT-Proxy. Electron-Web-Logins und deren Session-Fetches erhalten denselben festen Proxy. Neue Dateien ab 8 MiB können zusätzlich in 2 bis 32 exakten HTTP-Range-Bereichen parallel über die Proxy-Liste geladen werden. Der unveröffentlichte Hotfix behandelt diesen Wert als gemeinsames Prozesslimit für alle gleichzeitigen Segmentdownloads und hält den festen API-Proxy aus dem Segmentpool heraus.
|
||||
- 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.
|
||||
|
||||
@@ -120,6 +120,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
||||
- Die Entpacklogik und ihre Produktionspfade wurden nicht verändert.
|
||||
- `v2.0.75` wurde nach ausdrücklicher Freigabe als GitHub-Update veröffentlicht und als Branch sowie annotierter Tag zu GitHub und Forgejo gespiegelt. Ein produktiver Serverdienst wurde dafür weder installiert noch neu gestartet.
|
||||
|
||||
## Unveröffentlichter Proxy-Hotfix nach v2.0.75
|
||||
|
||||
- Der bisherige Wert „Verbindungen pro Download“ wurde bei jedem parallelen Download vollständig neu angewendet. Vier Dateien mit dem Wert 16 konnten deshalb bis zu 64 Segmenttunnel öffnen. Der Wert ist nun ein gemeinsames Gesamtlimit für alle gleichzeitig laufenden Segmentdownloads; bestehende Einstellungen mit 16 werden ohne Migration als höchstens 16 parallele Segmentverbindungen insgesamt verwendet.
|
||||
- Segment-Proxys werden prozessweit koordiniert. Derselbe Listeneintrag wird nicht gleichzeitig an mehrere Segmente oder Dateien vergeben, und der fest ausgewählte API-Proxy bleibt exklusiv für API-, Login-, Link- und Einzel-Proxy-Verkehr reserviert.
|
||||
- Range-Proben und Segmentanfragen teilen dasselbe Verbindungslimit. Wartende Dateien erhalten frei werdende Slots, statt das Limit durch zusätzliche Proben oder einen eigenen lokalen Proxy-Pool zu überschreiten.
|
||||
- HTTP-Antworten des eigentlichen Downloadservers werden getrennt von Proxy-Verbindungsfehlern erfasst. Insbesondere erscheint ein über einen erreichbaren Proxy empfangener HTTP 503 mit `originHttpStatus=503` im Item-Log und nicht mehr irreführend als „Proxys nicht erreichbar“.
|
||||
- Die Entpacklogik und ihre Produktionspfade wurden nicht verändert. Der Hotfix ist noch nicht veröffentlicht, installiert oder auf einem Server gestartet.
|
||||
|
||||
## Start-, Build- und Testbefehle
|
||||
|
||||
```powershell
|
||||
@@ -152,7 +160,9 @@ 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: 6 von 6 erfolgreich. Abgedeckt sind die feste Auswahl nach gültigem 1-basiertem Listeneintrag, vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Segment-Proxys, sauberer Einzel-Proxy-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung.
|
||||
- Proxy-Segmenttests nach dem unveröffentlichten Hotfix: 9 von 9 erfolgreich. Abgedeckt sind die feste Auswahl nach gültigem 1-basiertem Listeneintrag, vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Segment-Proxys, zwei gleichzeitige Dateien unter einem gemeinsamen Verbindungslimit ohne gleichzeitige Doppelvergabe und ohne Nutzung des reservierten API-Proxys, getrennte HTTP-503-Diagnose aus Probe und Segment, sauberer Einzel-Proxy-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung.
|
||||
- Vollständiger Client-Lauf nach dem Proxy-Hotfix ohne den unter Windows nicht ausführbaren Symlink-Metadatentest: 140 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.653 Tests erfolgreich und 4 übersprungen. Darin sind alle 84 bestehenden Entpacktests unverändert grün.
|
||||
- Nach dem Proxy-Hotfix erfolgreich: TypeScript, vollständiger Main-/Renderer-Build, Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung zum rund 576 KiB großen Renderer-Chunk bleibt bestehen.
|
||||
- Proxy-only-Routingtests: 3 von 3 erfolgreich. Ein normaler globaler API-`fetch` verwendete ausschließlich den ausgewählten zweiten authentifizierten CONNECT-Proxy; der erste Proxy blieb unbenutzt. Bei fehlender Liste wurde die Zieladresse nicht kontaktiert. Electron-Regeln enthielten keine Zugangsdaten, während die Authentifizierung nur für exakt passenden Proxy-Host und -Port bereitgestellt wurde.
|
||||
- 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.
|
||||
@@ -193,7 +203,7 @@ npm exec -- tsc --noEmit
|
||||
- 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 festen Einzel-Proxy-Fallback zusätzlicher Providerverkehr angefallen sein.
|
||||
- Der feste API-Proxy wird absichtlich nicht automatisch rotiert, damit API- und Login-Sitzungen eine stabile Ausgangs-IP behalten. Bei Ausfall muss ein anderer gültiger Listeneintrag ausgewählt werden; bis dahin schlägt Proxy-only geschlossen fehl.
|
||||
- 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.
|
||||
- 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. Das gemeinsame Limit ist mit zwei gleichzeitigen lokalen End-to-End-Downloads verifiziert; ein erneuter Mehrdatei-Test gegen Real-Debrid benötigt weiterhin eine veröffentlichte und installierte Hotfix-Version.
|
||||
- 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.
|
||||
@@ -203,7 +213,7 @@ npm exec -- tsc --noEmit
|
||||
|
||||
## Nächste sinnvolle Schritte
|
||||
|
||||
1. Das veröffentlichte Proxy-only-Update zunächst mit einem festen API-Proxy und 2 bis 4 Segmentverbindungen beobachten; danach bei Bedarf auf 16 erhöhen und Erfolgsquote, Zusatzverkehr, Zielserver-Kompatibilität, Festplattenlast sowie Netto-MB/s vergleichen. Erst anschließend `maxParallel` auf 5 erhöhen.
|
||||
1. Den unveröffentlichten Proxy-Hotfix nach ausdrücklicher Release-Freigabe paketieren und anschließend mit Gesamtlimit 16 sowie `maxParallel=2` gegen zwei echte Real-Debrid-Dateien prüfen. Erfolgsquote, HTTP-Status, Zusatzverkehr, Festplattenlast und Netto-MB/s beobachten, bevor mehr gleichzeitige Dateien zugelassen werden.
|
||||
2. Nach einer getrennt 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.
|
||||
|
||||
@@ -11010,18 +11010,20 @@ export class DownloadManager extends EventEmitter {
|
||||
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",
|
||||
not_enough_proxies: "Zu wenige Proxys neben dem reservierten API-Proxy",
|
||||
proxy_unavailable: "Proxys nicht erreichbar",
|
||||
origin_http_error: "Downloadserver hat die Segmentanfrage abgelehnt",
|
||||
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.fullStatus = `Proxy-Download startet (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`;
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
logAttemptEvent("INFO", "Proxy-Segmentdownload startet", {
|
||||
attempt,
|
||||
connections: this.settings.proxyConnectionsPerDownload,
|
||||
totalConnectionLimit: this.settings.proxyConnectionsPerDownload,
|
||||
proxyListConfigured: Boolean(this.settings.proxyListPath)
|
||||
});
|
||||
const proxyResult = await downloadWithProxySegments({
|
||||
@@ -11029,6 +11031,8 @@ export class DownloadManager extends EventEmitter {
|
||||
targetPath: effectiveTargetPath,
|
||||
proxyListPath: this.settings.proxyListPath,
|
||||
connections: this.settings.proxyConnectionsPerDownload,
|
||||
totalConnectionLimit: this.settings.proxyConnectionsPerDownload,
|
||||
reservedProxyIndex: this.settings.proxyApiProxyIndex,
|
||||
signal: active.abortController.signal,
|
||||
skipTlsVerify,
|
||||
waitWhilePaused: async () => {
|
||||
@@ -11070,7 +11074,7 @@ export class DownloadManager extends EventEmitter {
|
||||
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)`;
|
||||
: `Proxy-Download läuft (Gesamtlimit ${this.settings.proxyConnectionsPerDownload})`;
|
||||
if (elapsed >= 0.5) {
|
||||
proxyWindowStartedAt = nowTick;
|
||||
proxyWindowBytes = 0;
|
||||
@@ -11109,7 +11113,8 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
logAttemptEvent("WARN", "Proxy-Segmentdownload nicht verwendet, fester Einzel-Proxy folgt", {
|
||||
attempt,
|
||||
reason: proxyReasonLabels[proxyResult.reason]
|
||||
reason: proxyReasonLabels[proxyResult.reason],
|
||||
...(proxyResult.httpStatus ? { originHttpStatus: proxyResult.httpStatus } : {})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface ProxySegmentedDownloadOptions {
|
||||
targetPath: string;
|
||||
proxyListPath: string;
|
||||
connections: number;
|
||||
totalConnectionLimit?: number;
|
||||
reservedProxyIndex?: number;
|
||||
signal: AbortSignal;
|
||||
skipTlsVerify?: boolean;
|
||||
minSegmentBytes?: number;
|
||||
@@ -66,18 +68,167 @@ export interface ProxySegmentedDownloadOptions {
|
||||
|
||||
export type ProxySegmentedDownloadResult =
|
||||
| { status: "completed"; totalBytes: number; connections: number }
|
||||
| { status: "fallback"; reason: ProxyFallbackReason };
|
||||
| { status: "fallback"; reason: ProxyFallbackReason; httpStatus?: number };
|
||||
|
||||
export type ProxyFallbackReason =
|
||||
| "proxy_file_unavailable"
|
||||
| "no_valid_proxies"
|
||||
| "range_unsupported"
|
||||
| "file_too_small"
|
||||
| "not_enough_proxies"
|
||||
| "proxy_unavailable"
|
||||
| "origin_http_error"
|
||||
| "segment_failed";
|
||||
|
||||
const proxyFileCache = new Map<string, CachedProxyFile>();
|
||||
|
||||
function proxyEndpointKey(proxy: ProxyEndpoint): string {
|
||||
return `${proxy.url}\0${proxy.authorization}`;
|
||||
}
|
||||
|
||||
class OriginHttpError extends Error {
|
||||
public constructor(public readonly statusCode: number) {
|
||||
super(`origin_http_${statusCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface ProxyLease {
|
||||
proxy: ProxyEndpoint;
|
||||
release: (succeeded: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProxyLeaseWaiter {
|
||||
poolKey: string;
|
||||
proxies: ProxyEndpoint[];
|
||||
excluded: ReadonlySet<string>;
|
||||
signal: AbortSignal;
|
||||
resolve: (lease: ProxyLease | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
class SharedProxyCoordinator {
|
||||
private connectionLimit = 16;
|
||||
private activeConnections = 0;
|
||||
private readonly activeProxies = new Set<string>();
|
||||
private readonly cooldownUntil = new Map<string, number>();
|
||||
private readonly cursors = new Map<string, number>();
|
||||
private readonly waiters: ProxyLeaseWaiter[] = [];
|
||||
|
||||
public setConnectionLimit(limit: number): void {
|
||||
this.connectionLimit = Math.max(2, Math.min(32, Math.floor(limit || 16)));
|
||||
this.drain();
|
||||
}
|
||||
|
||||
public acquire(
|
||||
poolKey: string,
|
||||
proxies: ProxyEndpoint[],
|
||||
excluded: ReadonlySet<string>,
|
||||
signal: AbortSignal
|
||||
): Promise<ProxyLease | null> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(abortError());
|
||||
}
|
||||
if (!proxies.some((proxy) => !excluded.has(proxyEndpointKey(proxy)))) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise<ProxyLease | null>((resolve, reject) => {
|
||||
const waiter: ProxyLeaseWaiter = { poolKey, proxies, excluded, signal, resolve, reject };
|
||||
const onAbort = (): void => {
|
||||
const index = this.waiters.indexOf(waiter);
|
||||
if (index >= 0) {
|
||||
this.waiters.splice(index, 1);
|
||||
reject(abortError());
|
||||
}
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
const resolveWithoutListener = waiter.resolve;
|
||||
const rejectWithoutListener = waiter.reject;
|
||||
waiter.resolve = (lease) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolveWithoutListener(lease);
|
||||
};
|
||||
waiter.reject = (error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
rejectWithoutListener(error);
|
||||
};
|
||||
this.waiters.push(waiter);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
private selectProxy(waiter: ProxyLeaseWaiter): ProxyEndpoint | null {
|
||||
if (this.activeConnections >= this.connectionLimit) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const start = (this.cursors.get(waiter.poolKey) || 0) % waiter.proxies.length;
|
||||
const select = (allowCooldown: boolean): ProxyEndpoint | null => {
|
||||
for (let offset = 0; offset < waiter.proxies.length; offset += 1) {
|
||||
const index = (start + offset) % waiter.proxies.length;
|
||||
const proxy = waiter.proxies[index];
|
||||
const key = proxyEndpointKey(proxy);
|
||||
if (waiter.excluded.has(key)
|
||||
|| this.activeProxies.has(key)
|
||||
|| (!allowCooldown && (this.cooldownUntil.get(key) || 0) > now)) {
|
||||
continue;
|
||||
}
|
||||
this.cursors.set(waiter.poolKey, (index + 1) % waiter.proxies.length);
|
||||
return proxy;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return select(false) || select(true);
|
||||
}
|
||||
|
||||
private createLease(proxy: ProxyEndpoint): ProxyLease {
|
||||
const key = proxyEndpointKey(proxy);
|
||||
this.activeConnections += 1;
|
||||
this.activeProxies.add(key);
|
||||
let released = false;
|
||||
return {
|
||||
proxy,
|
||||
release: (succeeded) => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
||||
this.activeProxies.delete(key);
|
||||
if (succeeded) {
|
||||
this.cooldownUntil.delete(key);
|
||||
} else {
|
||||
this.cooldownUntil.set(key, Date.now() + 30_000);
|
||||
}
|
||||
this.drain();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
let granted = true;
|
||||
while (granted && this.activeConnections < this.connectionLimit) {
|
||||
granted = false;
|
||||
for (let index = 0; index < this.waiters.length; index += 1) {
|
||||
const waiter = this.waiters[index];
|
||||
if (waiter.signal.aborted) {
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.reject(abortError());
|
||||
granted = true;
|
||||
break;
|
||||
}
|
||||
const proxy = this.selectProxy(waiter);
|
||||
if (!proxy) {
|
||||
continue;
|
||||
}
|
||||
this.waiters.splice(index, 1);
|
||||
waiter.resolve(this.createLease(proxy));
|
||||
granted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sharedProxyCoordinator = new SharedProxyCoordinator();
|
||||
|
||||
function parseProxyLine(rawLine: string, id: number): ProxyEndpoint | null {
|
||||
const value = rawLine.trim();
|
||||
if (!value || value.startsWith("#") || /\s/.test(value)) {
|
||||
@@ -360,6 +511,7 @@ async function consumeProbe(response: IncomingMessage, expectedBytes: number, on
|
||||
|
||||
async function probeRangeSupport(
|
||||
target: URL,
|
||||
poolKey: string,
|
||||
proxies: ProxyEndpoint[],
|
||||
signal: AbortSignal,
|
||||
options: Required<Pick<ProxySegmentedDownloadOptions, "skipTlsVerify" | "connectTimeoutMs" | "idleTimeoutMs" | "proxyAttempts">>,
|
||||
@@ -368,14 +520,21 @@ async function probeRangeSupport(
|
||||
{ status: "ok"; totalBytes: number }
|
||||
| { status: "unsupported" }
|
||||
| { status: "unavailable" }
|
||||
| { status: "origin_error"; httpStatus: number }
|
||||
> {
|
||||
const attempts = Math.min(proxies.length, options.proxyAttempts);
|
||||
const excluded = new Set<string>();
|
||||
let lastHttpStatus = 0;
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
let opened: RangeResponse | null = null;
|
||||
let lease: ProxyLease | null = null;
|
||||
try {
|
||||
lease = await sharedProxyCoordinator.acquire(poolKey, proxies, excluded, signal);
|
||||
if (!lease) break;
|
||||
excluded.add(proxyEndpointKey(lease.proxy));
|
||||
opened = await openRangeResponse(
|
||||
target,
|
||||
proxies[Math.floor((index * proxies.length) / attempts)],
|
||||
lease.proxy,
|
||||
0,
|
||||
PROXY_PROBE_END,
|
||||
signal,
|
||||
@@ -384,8 +543,19 @@ async function probeRangeSupport(
|
||||
options.idleTimeoutMs
|
||||
);
|
||||
const status = opened.response.statusCode || 0;
|
||||
if (status >= 400) {
|
||||
lastHttpStatus = status;
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(true);
|
||||
lease = null;
|
||||
continue;
|
||||
}
|
||||
if (status === 200) {
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(true);
|
||||
lease = null;
|
||||
return { status: "unsupported" };
|
||||
}
|
||||
const range = parseContentRange(opened.response.headers["content-range"]);
|
||||
@@ -398,23 +568,38 @@ async function probeRangeSupport(
|
||||
|| range.end !== expectedEnd
|
||||
|| (contentLength > 0 && contentLength !== expectedBytes)) {
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
lease.release(false);
|
||||
lease = null;
|
||||
continue;
|
||||
}
|
||||
const received = await consumeProbe(opened.response, expectedBytes, onTrafficBytes);
|
||||
opened.dispose();
|
||||
opened = null;
|
||||
if (received === expectedBytes) {
|
||||
lease.release(true);
|
||||
lease = null;
|
||||
return { status: "ok", totalBytes: range.total };
|
||||
}
|
||||
} catch (error) {
|
||||
opened?.dispose();
|
||||
opened = null;
|
||||
lease?.release(false);
|
||||
lease = null;
|
||||
if (signal.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
if (isDiskError(error)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
opened?.dispose();
|
||||
lease?.release(false);
|
||||
}
|
||||
}
|
||||
if (lastHttpStatus > 0) {
|
||||
return { status: "origin_error", httpStatus: lastHttpStatus };
|
||||
}
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
@@ -430,43 +615,6 @@ function buildSegments(totalBytes: number, count: number): 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) {
|
||||
@@ -500,10 +648,15 @@ async function downloadSegmentOnce(
|
||||
options.idleTimeoutMs
|
||||
);
|
||||
const response = opened.response;
|
||||
const status = response.statusCode || 0;
|
||||
if (status >= 400) {
|
||||
opened.dispose();
|
||||
throw new OriginHttpError(status);
|
||||
}
|
||||
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
|
||||
if (status !== 206
|
||||
|| !range
|
||||
|| range.start !== segment.start
|
||||
|| range.end !== segment.end
|
||||
@@ -562,6 +715,16 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
if (loaded.status === "empty") {
|
||||
return { status: "fallback", reason: "no_valid_proxies" };
|
||||
}
|
||||
const poolKey = path.resolve(proxyPath);
|
||||
const requestedConnections = Math.max(2, Math.min(32, Math.floor(options.connections || 16)));
|
||||
const totalConnectionLimit = Math.max(2, Math.min(32, Math.floor(options.totalConnectionLimit ?? requestedConnections)));
|
||||
sharedProxyCoordinator.setConnectionLimit(totalConnectionLimit);
|
||||
const reservedProxyIndex = Math.max(0, Math.floor(options.reservedProxyIndex || 0));
|
||||
const reservedProxy = reservedProxyIndex > 0 ? loaded.proxies[reservedProxyIndex - 1] : null;
|
||||
const reservedProxyKey = reservedProxy ? proxyEndpointKey(reservedProxy) : "";
|
||||
const segmentProxies = reservedProxyKey
|
||||
? loaded.proxies.filter((proxy) => proxyEndpointKey(proxy) !== reservedProxyKey)
|
||||
: loaded.proxies;
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
@@ -579,19 +742,24 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
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);
|
||||
const probe = await probeRangeSupport(target, poolKey, segmentProxies, 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" };
|
||||
}
|
||||
if (probe.status === "origin_error") {
|
||||
return { status: "fallback", reason: "origin_http_error", httpStatus: probe.httpStatus };
|
||||
}
|
||||
|
||||
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));
|
||||
const connections = Math.min(requestedConnections, segmentProxies.length, Math.floor(probe.totalBytes / minSegmentBytes));
|
||||
if (connections < 2) {
|
||||
return { status: "fallback", reason: "file_too_small" };
|
||||
return {
|
||||
status: "fallback",
|
||||
reason: segmentProxies.length < 2 ? "not_enough_proxies" : "file_too_small"
|
||||
};
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(options.targetPath), { recursive: true });
|
||||
@@ -627,31 +795,30 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
}
|
||||
|
||||
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>();
|
||||
const excluded = new Set<string>();
|
||||
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) {
|
||||
const lease = await sharedProxyCoordinator.acquire(poolKey, segmentProxies, excluded, signal);
|
||||
if (!lease) {
|
||||
break;
|
||||
}
|
||||
excluded.add(proxy.id);
|
||||
excluded.add(proxyEndpointKey(lease.proxy));
|
||||
let attemptProgress = 0;
|
||||
try {
|
||||
const bytes = await downloadSegmentOnce(
|
||||
target,
|
||||
tempPath,
|
||||
segment,
|
||||
proxy,
|
||||
lease.proxy,
|
||||
signal,
|
||||
normalized,
|
||||
{
|
||||
@@ -665,10 +832,10 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
probe.totalBytes,
|
||||
() => committedProgress
|
||||
);
|
||||
pool.release(proxy, true);
|
||||
lease.release(true);
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
pool.release(proxy, false);
|
||||
lease.release(error instanceof OriginHttpError);
|
||||
if (attemptProgress > 0) {
|
||||
updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes);
|
||||
}
|
||||
@@ -695,6 +862,16 @@ export async function downloadWithProxySegments(options: ProxySegmentedDownloadO
|
||||
if (isDiskError(failed.reason)) {
|
||||
throw failed.reason;
|
||||
}
|
||||
const originFailure = settled.find((result): result is PromiseRejectedResult => (
|
||||
result.status === "rejected" && result.reason instanceof OriginHttpError
|
||||
));
|
||||
if (originFailure) {
|
||||
return {
|
||||
status: "fallback",
|
||||
reason: "origin_http_error",
|
||||
httpStatus: (originFailure.reason as OriginHttpError).statusCode
|
||||
};
|
||||
}
|
||||
return { status: "fallback", reason: "segment_failed" };
|
||||
}
|
||||
|
||||
|
||||
@@ -457,12 +457,12 @@ export function buildSettingsFormViewModel({
|
||||
{
|
||||
id: "proxyConnectionsPerDownload",
|
||||
kind: "number",
|
||||
label: "Verbindungen pro Download",
|
||||
label: "Proxy-Verbindungen insgesamt",
|
||||
value: String(settings.proxyConnectionsPerDownload),
|
||||
min: 2,
|
||||
max: 32,
|
||||
disabled: !settings.proxyDownloadEnabled,
|
||||
help: `Bei ${settings.maxParallel} gleichzeitigen Downloads sind bis zu ${settings.maxParallel * settings.proxyConnectionsPerDownload} parallele Segmentverbindungen möglich. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.`
|
||||
help: `Dieses Gesamtlimit wird zwischen allen gleichzeitigen Segmentdownloads geteilt. Bei ${settings.maxParallel} aktiven Downloads laufen zusammen höchstens ${settings.proxyConnectionsPerDownload} Segmentverbindungen. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.`
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -68,7 +68,11 @@ async function createRangeServer(content: Buffer, supportsRanges = true): Promis
|
||||
return { ...running, ranges };
|
||||
}
|
||||
|
||||
async function createConnectProxy(username: string, password: string, acceptedConnections: { value: number }): Promise<RunningServer> {
|
||||
async function createConnectProxy(
|
||||
username: string,
|
||||
password: string,
|
||||
acceptedConnections: { value: number; active?: number; maxActive?: number }
|
||||
): Promise<RunningServer> {
|
||||
const server = http.createServer();
|
||||
server.on("connect", (request, clientSocket, head) => {
|
||||
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
|
||||
@@ -79,6 +83,16 @@ async function createConnectProxy(username: string, password: string, acceptedCo
|
||||
const target = new URL(`http://${request.url}`);
|
||||
const upstream = net.connect(Number(target.port), target.hostname, () => {
|
||||
acceptedConnections.value += 1;
|
||||
acceptedConnections.active = (acceptedConnections.active || 0) + 1;
|
||||
acceptedConnections.maxActive = Math.max(acceptedConnections.maxActive || 0, acceptedConnections.active);
|
||||
let released = false;
|
||||
const release = (): void => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
acceptedConnections.active = Math.max(0, (acceptedConnections.active || 0) - 1);
|
||||
};
|
||||
upstream.once("close", release);
|
||||
clientSocket.once("close", release);
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head.length > 0) upstream.write(head);
|
||||
clientSocket.pipe(upstream);
|
||||
@@ -204,6 +218,143 @@ describe("proxy segmented download", () => {
|
||||
expect(counters.some((counter) => counter.value > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("shares one connection budget across concurrent downloads and reserves the fixed API proxy", async () => {
|
||||
const content = Buffer.allocUnsafe(128 * 1024);
|
||||
for (let index = 0; index < content.length; index += 1) content[index] = index % 239;
|
||||
let activeRequests = 0;
|
||||
let maxActiveRequests = 0;
|
||||
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 = Math.min(Number(match[2]), content.length - 1);
|
||||
const body = content.subarray(start, end + 1);
|
||||
activeRequests += 1;
|
||||
maxActiveRequests = Math.max(maxActiveRequests, activeRequests);
|
||||
let finished = false;
|
||||
const finish = (): void => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
activeRequests = Math.max(0, activeRequests - 1);
|
||||
};
|
||||
response.once("close", finish);
|
||||
response.writeHead(206, {
|
||||
"Content-Range": `bytes ${start}-${end}/${content.length}`,
|
||||
"Content-Length": body.length
|
||||
});
|
||||
if (start === 0 && end === 1) {
|
||||
response.end(body);
|
||||
} else {
|
||||
setTimeout(() => response.end(body), 60);
|
||||
}
|
||||
});
|
||||
const targetServer = await listen(server);
|
||||
const counters = Array.from({ length: 9 }, () => ({ value: 0, active: 0, maxActive: 0 }));
|
||||
const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`shared${index}`, `pass${index}`, counter)));
|
||||
const directory = await createTempDirectory();
|
||||
const proxyFile = path.join(directory, "shared-proxies.txt");
|
||||
const firstTarget = path.join(directory, "first.bin");
|
||||
const secondTarget = path.join(directory, "second.bin");
|
||||
await fs.promises.writeFile(
|
||||
proxyFile,
|
||||
proxies.map((proxy, index) => `shared${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||
);
|
||||
const commonOptions = {
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/shared.bin`,
|
||||
proxyListPath: proxyFile,
|
||||
connections: 4,
|
||||
totalConnectionLimit: 4,
|
||||
reservedProxyIndex: 1,
|
||||
minSegmentBytes: 1,
|
||||
signal: new AbortController().signal
|
||||
};
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
downloadWithProxySegments({ ...commonOptions, targetPath: firstTarget }),
|
||||
downloadWithProxySegments({ ...commonOptions, targetPath: secondTarget })
|
||||
]);
|
||||
|
||||
expect(firstResult.status).toBe("completed");
|
||||
expect(secondResult.status).toBe("completed");
|
||||
expect(await fs.promises.readFile(firstTarget)).toEqual(content);
|
||||
expect(await fs.promises.readFile(secondTarget)).toEqual(content);
|
||||
expect(maxActiveRequests).toBeLessThanOrEqual(4);
|
||||
expect(counters[0].value).toBe(0);
|
||||
expect(counters.every((counter) => counter.maxActive <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an origin HTTP status instead of marking reachable proxies unavailable", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(503, { "Content-Type": "text/plain", "Content-Length": 19 });
|
||||
response.end("Service Unavailable");
|
||||
});
|
||||
const targetServer = await listen(server);
|
||||
const counters = Array.from({ length: 3 }, () => ({ value: 0 }));
|
||||
const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`status${index}`, `pass${index}`, counter)));
|
||||
const directory = await createTempDirectory();
|
||||
const proxyFile = path.join(directory, "status-proxies.txt");
|
||||
await fs.promises.writeFile(
|
||||
proxyFile,
|
||||
proxies.map((proxy, index) => `status${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||
);
|
||||
|
||||
const result = await downloadWithProxySegments({
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/unavailable.bin`,
|
||||
targetPath: path.join(directory, "unavailable.bin"),
|
||||
proxyListPath: proxyFile,
|
||||
connections: 2,
|
||||
signal: new AbortController().signal,
|
||||
minSegmentBytes: 1
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "fallback", reason: "origin_http_error", httpStatus: 503 });
|
||||
expect(counters.every((counter) => counter.value > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an origin HTTP status from failed segment requests and removes temporary data", async () => {
|
||||
const content = Buffer.alloc(64 * 1024, 11);
|
||||
const server = http.createServer((request, response) => {
|
||||
const rangeHeader = String(request.headers.range || "");
|
||||
if (rangeHeader === "bytes=0-1") {
|
||||
response.writeHead(206, {
|
||||
"Content-Range": `bytes 0-1/${content.length}`,
|
||||
"Content-Length": 2
|
||||
});
|
||||
response.end(content.subarray(0, 2));
|
||||
return;
|
||||
}
|
||||
response.writeHead(503, { "Content-Type": "text/plain", "Content-Length": 19 });
|
||||
response.end("Service Unavailable");
|
||||
});
|
||||
const targetServer = await listen(server);
|
||||
const counters = Array.from({ length: 4 }, () => ({ value: 0 }));
|
||||
const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`segment${index}`, `pass${index}`, counter)));
|
||||
const directory = await createTempDirectory();
|
||||
const proxyFile = path.join(directory, "segment-status-proxies.txt");
|
||||
const targetFile = path.join(directory, "segment-status.bin");
|
||||
await fs.promises.writeFile(
|
||||
proxyFile,
|
||||
proxies.map((proxy, index) => `segment${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n")
|
||||
);
|
||||
|
||||
const result = await downloadWithProxySegments({
|
||||
directUrl: `http://127.0.0.1:${targetServer.port}/segment-status.bin`,
|
||||
targetPath: targetFile,
|
||||
proxyListPath: proxyFile,
|
||||
connections: 2,
|
||||
signal: new AbortController().signal,
|
||||
minSegmentBytes: 1
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "fallback", reason: "origin_http_error", httpStatus: 503 });
|
||||
expect(fs.existsSync(targetFile)).toBe(false);
|
||||
expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back cleanly when the origin ignores byte ranges", async () => {
|
||||
const content = Buffer.alloc(4096, 3);
|
||||
const targetServer = await createRangeServer(content, false);
|
||||
|
||||
@@ -680,10 +680,11 @@ describe("settings views", () => {
|
||||
disabled: false
|
||||
}));
|
||||
expect(proxyGroup?.fields.find((field) => field.id === "proxyConnectionsPerDownload")).toEqual(expect.objectContaining({
|
||||
label: "Proxy-Verbindungen insgesamt",
|
||||
value: "16",
|
||||
min: 2,
|
||||
max: 32,
|
||||
help: expect.stringContaining("80 parallele Segmentverbindungen")
|
||||
help: expect.stringContaining("zusammen höchstens 16 Segmentverbindungen")
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user