Release v1.4.28 with expanded bug audit fixes
Build and Release / build (push) Has been cancelled

This commit is contained in:
Sucukdeluxe
2026-02-28 19:47:46 +01:00
parent 05a0c4fd55
commit 84d8f37ba6
16 changed files with 966 additions and 282 deletions
+319 -110
View File
@@ -54,7 +54,7 @@ function getDownloadStallTimeoutMs(): number {
function getDownloadConnectTimeoutMs(): number {
const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 180000) {
if (Number.isFinite(fromEnv) && fromEnv >= 250 && fromEnv <= 180000) {
return Math.floor(fromEnv);
}
return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS;
@@ -103,6 +103,13 @@ function cloneSession(session: SessionState): SessionState {
};
}
function cloneSettings(settings: AppSettings): AppSettings {
return {
...settings,
bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry }))
};
}
function parseContentRangeTotal(contentRange: string | null): number | null {
if (!contentRange) {
return null;
@@ -123,7 +130,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i);
if (encodedMatch?.[1]) {
let value = encodedMatch[1].trim();
value = value.replace(/^UTF-8''/i, "");
value = value.replace(/^[A-Za-z0-9._-]+(?:'[^']*)?'/, "");
value = value.replace(/^['"]+|['"]+$/g, "");
try {
const decoded = decodeURIComponent(value).trim();
@@ -144,13 +151,9 @@ function parseContentDispositionFilename(contentDisposition: string | null): str
return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, "");
}
function canRetryStatus(status: number): boolean {
return status === 429 || status >= 500;
}
function isArchiveLikePath(filePath: string): boolean {
const lower = path.basename(filePath).toLowerCase();
return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower);
}
function isFetchFailure(errorText: string): boolean {
@@ -259,7 +262,7 @@ export function ensureRepackToken(baseName: string): string {
return baseName;
}
const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, ".REPACK.$2");
const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, "$1REPACK.$2");
if (withQualityToken !== baseName) {
return withQualityToken;
}
@@ -357,6 +360,8 @@ export class DownloadManager extends EventEmitter {
private lastGlobalProgressAt = 0;
private retryAfterByItem = new Map<string, number>();
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super();
this.settings = settings;
@@ -428,10 +433,16 @@ export class DownloadManager extends EventEmitter {
const reconnectMs = Math.max(0, this.session.reconnectUntil - now);
const snapshotSession = cloneSession(this.session);
const snapshotSettings = cloneSettings(this.settings);
const snapshotSummary = this.summary
? { ...this.summary }
: null;
return {
settings: this.settings,
session: this.session,
summary: this.summary,
settings: snapshotSettings,
session: snapshotSession,
summary: snapshotSummary,
stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`,
@@ -494,7 +505,14 @@ export class DownloadManager extends EventEmitter {
}
public reorderPackages(packageIds: string[]): void {
const valid = packageIds.filter((id) => this.session.packages[id]);
const seen = new Set<string>();
const valid = packageIds.filter((id) => {
if (!this.session.packages[id] || seen.has(id)) {
return false;
}
seen.add(id);
return true;
});
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
this.session.packageOrder = [...valid, ...remaining];
this.persistSoon();
@@ -508,6 +526,7 @@ export class DownloadManager extends EventEmitter {
}
this.recordRunOutcome(itemId, "cancelled");
const active = this.activeTasks.get(itemId);
const hasActiveTask = Boolean(active);
if (active) {
active.abortReason = "cancel";
active.abortController.abort("cancel");
@@ -523,7 +542,10 @@ export class DownloadManager extends EventEmitter {
}
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.releaseTargetPath(itemId);
this.retryAfterByItem.delete(itemId);
if (!hasActiveTask) {
this.releaseTargetPath(itemId);
}
this.persistSoon();
this.emitState(true);
}
@@ -631,12 +653,21 @@ export class DownloadManager extends EventEmitter {
return { addedPackages: 0, addedLinks: 0 };
}
const inputs: ParsedPackageInput[] = data.packages
.filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0)
.map((pkg) => ({ name: pkg.name, links: pkg.links }));
.map((pkg) => {
const name = typeof pkg?.name === "string" ? pkg.name : "";
const linksRaw = Array.isArray(pkg?.links) ? pkg.links : [];
const links = linksRaw
.filter((link) => typeof link === "string")
.map((link) => link.trim())
.filter(Boolean);
return { name, links };
})
.filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0);
return this.addPackages(inputs);
}
public clearAll(): void {
this.clearPersistTimer();
this.stop();
this.abortPostProcessing("clear_all");
if (this.stateEmitTimer) {
@@ -652,6 +683,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
@@ -663,6 +695,8 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractRequeue.clear();
this.packagePostProcessQueue = Promise.resolve();
this.summary = null;
this.nonResumableActive = 0;
this.retryAfterByItem.clear();
this.persistNow();
this.emitState(true);
}
@@ -804,9 +838,16 @@ export class DownloadManager extends EventEmitter {
this.runItemIds.delete(itemId);
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("cancel");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId);
@@ -818,6 +859,12 @@ export class DownloadManager extends EventEmitter {
}
if (policy === "overwrite") {
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("overwrite");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir);
if (canDeleteExtractDir) {
try {
@@ -857,10 +904,12 @@ export class DownloadManager extends EventEmitter {
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
this.retryAfterByItem.delete(itemId);
if (this.session.running) {
this.runItemIds.add(itemId);
}
}
this.runCompletedPackages.delete(packageId);
pkg.status = "queued";
pkg.updatedAt = nowMs();
this.persistSoon();
@@ -1257,6 +1306,11 @@ export class DownloadManager extends EventEmitter {
}
}
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("cancel");
}
this.removePackageFromSession(packageId, itemIds);
this.persistSoon();
this.emitState(true);
@@ -1297,6 +1351,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.session.running = false;
@@ -1312,6 +1367,7 @@ export class DownloadManager extends EventEmitter {
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.summary = null;
this.nonResumableActive = 0;
this.persistSoon();
this.emitState(true);
return;
@@ -1320,6 +1376,7 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.session.running = true;
this.session.paused = false;
@@ -1340,6 +1397,7 @@ export class DownloadManager extends EventEmitter {
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.summary = null;
this.nonResumableActive = 0;
this.persistSoon();
this.emitState(true);
void this.ensureScheduler().catch((error) => {
@@ -1356,6 +1414,7 @@ export class DownloadManager extends EventEmitter {
this.session.paused = false;
this.session.reconnectUntil = 0;
this.session.reconnectReason = "";
this.retryAfterByItem.clear();
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("stop");
@@ -1369,6 +1428,7 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.clearPersistTimer();
this.session.running = false;
this.session.paused = false;
this.session.reconnectUntil = 0;
@@ -1423,6 +1483,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.nonResumableActive = 0;
this.session.summaryText = "";
this.persistNow();
this.emitState(true);
@@ -1506,8 +1568,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) {
pkg.status = "completed";
}
@@ -1543,6 +1605,14 @@ export class DownloadManager extends EventEmitter {
}
}
private clearPersistTimer(): void {
if (!this.persistTimer) {
return;
}
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
private persistSoon(): void {
if (this.persistTimer) {
return;
@@ -1658,10 +1728,6 @@ export class DownloadManager extends EventEmitter {
const parsed = path.parse(preferredPath);
const preferredKey = pathKey(preferredPath);
const baseDirKey = process.platform === "win32" ? parsed.dir.toLowerCase() : parsed.dir;
const baseNameKey = process.platform === "win32" ? parsed.name.toLowerCase() : parsed.name;
const baseExtKey = process.platform === "win32" ? parsed.ext.toLowerCase() : parsed.ext;
const sep = path.sep;
const maxIndex = 10000;
for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0
@@ -1669,7 +1735,7 @@ export class DownloadManager extends EventEmitter {
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
const key = index === 0
? preferredKey
: `${baseDirKey}${sep}${baseNameKey} (${index})${baseExtKey}`;
: pathKey(candidate);
const owner = this.reservedTargetPaths.get(key);
const existsOnDisk = fs.existsSync(candidate);
const allowExistingCandidate = allowExistingFile && index === 0;
@@ -1809,7 +1875,11 @@ export class DownloadManager extends EventEmitter {
continue;
}
const targetStatus = failed > 0 ? "failed" : cancelled > 0 && success === 0 ? "cancelled" : "completed";
const targetStatus = failed > 0
? "failed"
: cancelled > 0
? (success > 0 ? "failed" : "cancelled")
: "completed";
if (pkg.status !== targetStatus) {
pkg.status = targetStatus;
pkg.updatedAt = nowMs();
@@ -1865,7 +1935,14 @@ export class DownloadManager extends EventEmitter {
}
private removePackageFromSession(packageId: string, itemIds: string[]): void {
const postProcessController = this.packagePostProcessAbortControllers.get(packageId);
if (postProcessController && !postProcessController.signal.aborted) {
postProcessController.abort("package_removed");
}
this.packagePostProcessAbortControllers.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
@@ -1918,7 +1995,7 @@ export class DownloadManager extends EventEmitter {
this.runGlobalStallWatchdog(now);
if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) {
if (this.activeTasks.size === 0 && !this.hasQueuedItems() && !this.hasDelayedQueuedItems() && this.packagePostProcessTasks.size === 0) {
this.finishRun();
break;
}
@@ -2031,7 +2108,8 @@ export class DownloadManager extends EventEmitter {
private markQueuedAsReconnectWait(): boolean {
let changed = false;
const waitText = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
const waitSeconds = Math.max(0, Math.ceil((this.session.reconnectUntil - nowMs()) / 1000));
const waitText = `Reconnect-Wait (${waitSeconds}s)`;
const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items);
for (const itemId of itemIds) {
const item = this.session.items[itemId];
@@ -2056,6 +2134,7 @@ export class DownloadManager extends EventEmitter {
}
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
const now = nowMs();
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
@@ -2066,6 +2145,13 @@ export class DownloadManager extends EventEmitter {
if (!item) {
continue;
}
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
if (retryAfter > now) {
continue;
}
if (retryAfter > 0) {
this.retryAfterByItem.delete(itemId);
}
if (item.status === "queued" || item.status === "reconnect_wait") {
return { packageId, itemId };
}
@@ -2078,6 +2164,28 @@ export class DownloadManager extends EventEmitter {
return this.findNextQueuedItem() !== null;
}
private hasDelayedQueuedItems(): boolean {
const now = nowMs();
for (const [itemId, readyAt] of this.retryAfterByItem.entries()) {
if (readyAt <= now) {
continue;
}
const item = this.session.items[itemId];
if (!item) {
continue;
}
if (item.status !== "queued" && item.status !== "reconnect_wait") {
continue;
}
const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
return true;
}
return false;
}
private countQueuedItems(): number {
let count = 0;
for (const packageId of this.session.packageOrder) {
@@ -2098,6 +2206,18 @@ export class DownloadManager extends EventEmitter {
return count;
}
private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void {
const waitMs = Math.max(0, Math.floor(delayMs));
item.status = "queued";
item.speedBps = 0;
item.fullStatus = statusText;
item.updatedAt = nowMs();
item.attempts = 0;
active.abortController = new AbortController();
active.abortReason = "none";
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
}
private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId];
const pkg = this.session.packages[packageId];
@@ -2108,6 +2228,8 @@ export class DownloadManager extends EventEmitter {
return;
}
this.retryAfterByItem.delete(itemId);
item.status = "validating";
item.fullStatus = "Link wird umgewandelt";
item.updatedAt = nowMs();
@@ -2154,7 +2276,7 @@ export class DownloadManager extends EventEmitter {
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) {
try {
const unrestricted = await this.debridService.unrestrictLink(item.url);
const unrestricted = await this.debridService.unrestrictLink(item.url, active.abortController.signal);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
@@ -2191,6 +2313,10 @@ export class DownloadManager extends EventEmitter {
this.nonResumableActive += 1;
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (this.settings.enableIntegrityCheck) {
item.status = "integrity_check";
item.fullStatus = "CRC-Check läuft";
@@ -2198,6 +2324,9 @@ export class DownloadManager extends EventEmitter {
this.emitState();
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
if (!validation.ok) {
item.lastError = validation.message;
item.fullStatus = `${validation.message}, Neuversuch`;
@@ -2207,7 +2336,7 @@ export class DownloadManager extends EventEmitter {
// ignore
}
if (item.attempts < maxAttempts) {
item.status = "queued";
item.status = "integrity_check";
item.progressPercent = 0;
item.downloadedBytes = 0;
item.totalBytes = unrestricted.fileSize;
@@ -2219,6 +2348,10 @@ export class DownloadManager extends EventEmitter {
}
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
const finalTargetPath = String(item.targetPath || "").trim();
const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath)
? fs.statSync(finalTargetPath).size
@@ -2241,6 +2374,11 @@ export class DownloadManager extends EventEmitter {
done = true;
}
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
item.status = "completed";
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
item.progressPercent = 100;
@@ -2249,13 +2387,15 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs();
this.recordRunOutcome(item.id, "completed");
void this.runPackagePostProcessing(pkg.id).catch((err) => {
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
}).finally(() => {
this.applyCompletedCleanupPolicy(pkg.id, item.id);
this.persistSoon();
this.emitState();
});
if (this.session.running && !active.abortController.signal.aborted) {
void this.runPackagePostProcessing(pkg.id).catch((err) => {
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
}).finally(() => {
this.applyCompletedCleanupPolicy(pkg.id, item.id);
this.persistSoon();
this.emitState();
});
}
this.persistSoon();
this.emitState();
return;
@@ -2280,16 +2420,11 @@ export class DownloadManager extends EventEmitter {
item.status = "cancelled";
item.fullStatus = "Gestoppt";
this.recordRunOutcome(item.id, "cancelled");
if (claimedTargetPath) {
try {
fs.rmSync(claimedTargetPath, { force: true });
} catch {
// ignore
}
if (!active.resumable && claimedTargetPath && !fs.existsSync(claimedTargetPath)) {
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
}
item.downloadedBytes = 0;
item.progressPercent = 0;
item.totalBytes = null;
} else if (reason === "shutdown") {
item.status = "queued";
item.speedBps = 0;
@@ -2297,6 +2432,7 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
} else if (reason === "reconnect") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Wartet auf Reconnect";
} else if (reason === "package_toggle") {
item.status = "queued";
@@ -2306,18 +2442,11 @@ export class DownloadManager extends EventEmitter {
stallRetries += 1;
if (stallRetries <= 2) {
item.retries += 1;
item.status = "queued";
item.speedBps = 0;
item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`;
this.queueRetry(item, active, 350 * stallRetries, `Keine Daten empfangen, Retry ${stallRetries}/2`);
item.lastError = "";
item.attempts = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(350 * stallRetries);
continue;
return;
}
item.status = "failed";
item.lastError = "Download hing wiederholt";
@@ -2337,6 +2466,15 @@ export class DownloadManager extends EventEmitter {
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
return;
}
if (shouldFreshRetry) {
freshRetryUsed = true;
@@ -2347,53 +2485,34 @@ export class DownloadManager extends EventEmitter {
// ignore
}
this.releaseTargetPath(item.id);
item.status = "queued";
item.fullStatus = "Netzwerkfehler erkannt, frischer Retry";
this.queueRetry(item, active, 450, "Netzwerkfehler erkannt, frischer Retry");
item.lastError = "";
item.attempts = 0;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
await sleep(450);
continue;
return;
}
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1;
item.retries += 1;
item.status = "queued";
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
this.queueRetry(item, active, Math.min(8000, 2000 * unrestrictRetries), `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`);
item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(Math.min(8000, 2000 * unrestrictRetries));
continue;
return;
}
if (genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1;
item.retries += 1;
item.status = "queued";
item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`;
this.queueRetry(item, active, Math.min(1200, 300 * genericErrorRetries), `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`);
item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(Math.min(1200, 300 * genericErrorRetries));
continue;
return;
}
item.status = "failed";
@@ -2482,6 +2601,7 @@ export class DownloadManager extends EventEmitter {
if (!response.ok) {
if (response.status === 416 && existingBytes > 0) {
await response.arrayBuffer().catch(() => undefined);
const rangeTotal = parseContentRangeTotal(response.headers.get("content-range"));
const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal;
if (expectedTotal && existingBytes === expectedTotal) {
@@ -2654,6 +2774,7 @@ export class DownloadManager extends EventEmitter {
active.abortController.signal.addEventListener("abort", onAbort, { once: true });
});
let bodyError: unknown = null;
try {
const body = response.body;
if (!body) {
@@ -2744,7 +2865,7 @@ export class DownloadManager extends EventEmitter {
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal);
if (active.abortController.signal.aborted) {
throw new Error(`aborted:${active.abortReason}`);
}
@@ -2778,29 +2899,44 @@ export class DownloadManager extends EventEmitter {
}
} finally {
clearInterval(idleTimer);
}
} finally {
await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) {
resolve();
return;
try {
reader.releaseLock();
} catch {
// ignore
}
const onDone = (): void => {
stream.off("error", onError);
stream.off("finish", onDone);
stream.off("close", onDone);
resolve();
};
const onError = (streamError: Error): void => {
stream.off("finish", onDone);
stream.off("close", onDone);
reject(streamError);
};
stream.once("finish", onDone);
stream.once("close", onDone);
stream.once("error", onError);
stream.end();
});
}
} catch (error) {
bodyError = error;
throw error;
} finally {
try {
await new Promise<void>((resolve, reject) => {
if (stream.closed || stream.destroyed) {
resolve();
return;
}
const onDone = (): void => {
stream.off("error", onError);
stream.off("finish", onDone);
stream.off("close", onDone);
resolve();
};
const onError = (streamError: Error): void => {
stream.off("finish", onDone);
stream.off("close", onDone);
reject(streamError);
};
stream.once("finish", onDone);
stream.once("close", onDone);
stream.once("error", onError);
stream.end();
});
} catch (streamCloseError) {
if (!bodyError) {
throw streamCloseError;
}
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
}
}
item.downloadedBytes = written;
@@ -2970,8 +3106,8 @@ export class DownloadManager extends EventEmitter {
if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else if (success > 0) {
pkg.status = "completed";
}
@@ -2999,6 +3135,10 @@ export class DownloadManager extends EventEmitter {
if (!entry.enabled) {
continue;
}
if (entry.startHour === entry.endHour) {
this.cachedSpeedLimitKbps = entry.speedLimitKbps;
return this.cachedSpeedLimitKbps;
}
const wraps = entry.startHour > entry.endHour;
const inRange = wraps
? hour >= entry.startHour || hour < entry.endHour
@@ -3017,14 +3157,46 @@ export class DownloadManager extends EventEmitter {
return 0;
}
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> {
private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, signal?: AbortSignal): Promise<void> {
const task = this.globalSpeedLimitQueue
.catch(() => undefined)
.then(async () => {
if (signal?.aborted) {
throw new Error("aborted:speed_limit");
}
const now = nowMs();
const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now);
if (waitMs > 0) {
await sleep(waitMs);
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
}, waitMs);
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal?.removeEventListener("abort", onAbort);
reject(new Error("aborted:speed_limit"));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
if (signal?.aborted) {
throw new Error("aborted:speed_limit");
}
const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt);
@@ -3036,7 +3208,7 @@ export class DownloadManager extends EventEmitter {
await task;
}
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, signal?: AbortSignal): Promise<void> {
const limitKbps = this.getEffectiveSpeedLimitKbps();
if (limitKbps <= 0) {
return;
@@ -3050,13 +3222,38 @@ export class DownloadManager extends EventEmitter {
if (projected > allowed) {
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
if (sleepMs > 0) {
await sleep(Math.min(300, sleepMs));
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve();
}, Math.min(300, sleepMs));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal?.removeEventListener("abort", onAbort);
reject(new Error("aborted:speed_limit"));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
}
return;
}
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond);
await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal);
}
private findReadyArchiveSets(pkg: PackageEntry): Set<string> {
@@ -3125,7 +3322,7 @@ export class DownloadManager extends EventEmitter {
if (/\.rar$/i.test(entryPointName) && !/\.part\d+\.rar$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase();
const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`^${escaped}\\.r(ar|\\d{2})$`, "i").test(fileName);
return new RegExp(`^${escaped}\\.r(ar|\\d{2,3})$`, "i").test(fileName);
}
if (/\.zip\.001$/i.test(entryPointName)) {
const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase();
@@ -3323,6 +3520,9 @@ export class DownloadManager extends EventEmitter {
}
}
const extractDeadline = setTimeout(() => {
if (signal?.aborted || extractAbortController.signal.aborted) {
return;
}
timedOut = true;
logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`);
if (!extractAbortController.signal.aborted) {
@@ -3432,8 +3632,8 @@ export class DownloadManager extends EventEmitter {
}
} else if (failed > 0) {
pkg.status = "failed";
} else if (cancelled > 0 && success === 0) {
pkg.status = "cancelled";
} else if (cancelled > 0) {
pkg.status = success > 0 ? "failed" : "cancelled";
} else {
pkg.status = "completed";
}
@@ -3483,9 +3683,17 @@ export class DownloadManager extends EventEmitter {
}
if (policy === "immediate") {
if (this.settings.autoExtract) {
const item = this.session.items[itemId];
const extracted = item ? isExtractedLabel(item.fullStatus || "") : false;
if (!extracted) {
return;
}
}
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
if (pkg.itemIds.length === 0) {
this.removePackageFromSession(packageId, []);
}
@@ -3536,6 +3744,7 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0;
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.nonResumableActive = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.persistNow();