From 19342647e5545a57c929276c3481020511fdbc9d Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 2 Mar 2026 23:47:54 +0100 Subject: [PATCH] Fix download freeze spikes and unrestrict slot overshoot handling --- _upload_release.mjs | 10 ++--- src/main/app-controller.ts | 24 +++++++++- src/main/download-manager.ts | 51 +++++++++++++++++++--- src/main/main.ts | 6 +++ src/main/storage.ts | 85 +++++++++++++++++++++++++++++++++++- src/preload/preload.ts | 4 ++ src/renderer/App.tsx | 61 ++++++++++++++++---------- src/shared/ipc.ts | 5 ++- src/shared/preload-api.ts | 4 ++ src/shared/types.ts | 18 ++++++++ 10 files changed, 230 insertions(+), 38 deletions(-) diff --git a/_upload_release.mjs b/_upload_release.mjs index 5fd2c28..8023018 100644 --- a/_upload_release.mjs +++ b/_upload_release.mjs @@ -17,7 +17,7 @@ for (const line of credResult.stdout.split(/\r?\n/)) { const auth = "Basic " + Buffer.from(creds.get("username") + ":" + creds.get("password")).toString("base64"); const owner = "Sucukdeluxe"; const repo = "real-debrid-downloader"; -const tag = "v1.5.27"; +const tag = "v1.5.35"; const baseApi = `https://codeberg.org/api/v1/repos/${owner}/${repo}`; async function main() { @@ -34,7 +34,7 @@ async function main() { tag_name: tag, target_commitish: "main", name: tag, - body: "- Increase column spacing for Fortschritt/Größe/Geladen", + body: "- Fix: Fortschritt zeigt jetzt kombinierten Wert (Download + Entpacken)\n- Fix: Pausieren zeigt nicht mehr 'Warte auf Daten'\n- Pixel-perfekte Dual-Layer Progress-Bar Texte (clip-path)", draft: false, prerelease: false }) @@ -47,10 +47,10 @@ async function main() { console.log("Release created:", release.id); const files = [ - "Real-Debrid-Downloader Setup 1.5.27.exe", - "Real-Debrid-Downloader 1.5.27.exe", + "Real-Debrid-Downloader Setup 1.5.35.exe", + "Real-Debrid-Downloader 1.5.35.exe", "latest.yml", - "Real-Debrid-Downloader Setup 1.5.27.exe.blockmap" + "Real-Debrid-Downloader Setup 1.5.35.exe.blockmap" ]; for (const f of files) { const filePath = path.join("release", f); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 7362365..dc51d2d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -4,6 +4,7 @@ import { AddLinksPayload, AppSettings, DuplicatePolicy, + HistoryEntry, ParsedPackageInput, SessionStats, StartConflictEntry, @@ -19,7 +20,7 @@ import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { MegaWebFallback } from "./mega-web-fallback"; -import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; @@ -59,7 +60,10 @@ export class AppController { })); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), - invalidateMegaSession: () => this.megaWebFallback.invalidateSession() + invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + onHistoryEntry: (entry: HistoryEntry) => { + addHistoryEntry(this.storagePaths, entry); + } }); this.manager.on("state", (snapshot: UiSnapshot) => { this.onStateHandler?.(snapshot); @@ -280,4 +284,20 @@ export class AppController { this.megaWebFallback.dispose(); logger.info("App beendet"); } + + public getHistory(): HistoryEntry[] { + return loadHistory(this.storagePaths); + } + + public clearHistory(): void { + clearHistory(this.storagePaths); + } + + public removeHistoryEntry(entryId: string): void { + removeHistoryEntry(this.storagePaths, entryId); + } + + public addToHistory(entry: HistoryEntry): void { + addHistoryEntry(this.storagePaths, entry); + } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 40d91d9..8490aff 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -242,6 +242,20 @@ function isUnrestrictFailure(errorText: string): boolean { || text.includes("session") || text.includes("login"); } +function isProviderBusyUnrestrictError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("too many active") + || text.includes("too many concurrent") + || text.includes("too many downloads") + || text.includes("active download") + || text.includes("concurrent limit") + || text.includes("slot limit") + || text.includes("limit reached") + || text.includes("zu viele aktive") + || text.includes("zu viele gleichzeitige") + || text.includes("zu viele downloads"); +} + function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } @@ -3126,6 +3140,15 @@ export class DownloadManager extends EventEmitter { } } + private applyProviderBusyBackoff(provider: string, cooldownMs: number): void { + const key = String(provider || "").trim() || "unknown"; + const now = nowMs(); + const entry = this.providerFailures.get(key) || { count: 0, lastFailAt: 0, cooldownUntil: 0 }; + entry.lastFailAt = now; + entry.cooldownUntil = Math.max(entry.cooldownUntil, now + Math.max(0, Math.floor(cooldownMs))); + this.providerFailures.set(key, entry); + } + private getProviderCooldownRemaining(provider: string): number { const entry = this.providerFailures.get(provider); if (!entry || entry.cooldownUntil <= 0) { @@ -3498,9 +3521,17 @@ export class DownloadManager extends EventEmitter { if (!item || !pkg || pkg.cancelled || !pkg.enabled) { return; } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + return; + } if (this.activeTasks.has(itemId)) { return; } + const maxParallel = Math.max(1, Number(this.settings.maxParallel) || 1); + if (this.activeTasks.size >= maxParallel) { + logger.warn(`startItem übersprungen (Parallel-Limit): active=${this.activeTasks.size}, max=${maxParallel}, item=${item.fileName || item.id}`); + return; + } this.retryAfterByItem.delete(itemId); @@ -3580,8 +3611,7 @@ export class DownloadManager extends EventEmitter { throw new Error(`aborted:${active.abortReason}`); } // Check provider cooldown before attempting unrestrict - const lastProvider = item.provider || ""; - const cooldownProvider = lastProvider || this.settings.providerPrimary || "unknown"; + const cooldownProvider = item.provider || this.settings.providerPrimary || "unknown"; const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider); if (cooldownMs > 0) { const delayMs = Math.min(cooldownMs + 1000, 310000); @@ -3598,13 +3628,17 @@ export class DownloadManager extends EventEmitter { } catch (unrestrictError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { // Record failure for all providers since we don't know which one timed out - this.recordProviderFailure(lastProvider || "unknown"); + this.recordProviderFailure(cooldownProvider); throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`); } // Record failure for the provider that errored const errText = compactErrorText(unrestrictError); if (isUnrestrictFailure(errText)) { - this.recordProviderFailure(lastProvider || "unknown"); + this.recordProviderFailure(cooldownProvider); + if (isProviderBusyUnrestrictError(errText)) { + const busyCooldownMs = Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + this.applyProviderBusyBackoff(cooldownProvider, busyCooldownMs); + } } throw unrestrictError; } @@ -3951,11 +3985,16 @@ export class DownloadManager extends EventEmitter { if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) { active.unrestrictRetries += 1; item.retries += 1; - this.recordProviderFailure(item.provider || "unknown"); + const failureProvider = item.provider || this.settings.providerPrimary || "unknown"; + this.recordProviderFailure(failureProvider); + if (isProviderBusyUnrestrictError(errorText)) { + const busyCooldownMs = Math.min(60000, 12000 + Number(active.unrestrictRetries || 0) * 3000); + this.applyProviderBusyBackoff(failureProvider, busyCooldownMs); + } // Escalating backoff: 5s, 7.5s, 11s, 17s, 25s, 38s, ... up to 120s let unrestrictDelayMs = Math.min(120000, Math.floor(5000 * Math.pow(1.5, active.unrestrictRetries - 1))); // Respect provider cooldown - const providerCooldown = this.getProviderCooldownRemaining(item.provider || "unknown"); + const providerCooldown = this.getProviderCooldownRemaining(failureProvider); if (providerCooldown > unrestrictDelayMs) { unrestrictDelayMs = providerCooldown + 1000; } diff --git a/src/main/main.ts b/src/main/main.ts index 87b1e6b..ec42952 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -322,6 +322,12 @@ function registerIpcHandlers(): void { validateString(packageId, "packageId"); return controller.extractNow(packageId); }); + ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); + ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); + ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { + validateString(entryId, "entryId"); + return controller.removeHistoryEntry(entryId); + }); ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue()); ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { validateString(json, "json"); diff --git a/src/main/storage.ts b/src/main/storage.ts index 0b483f7..f512b51 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, PackageEntry, SessionState } from "../shared/types"; +import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, SessionState } from "../shared/types"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -164,13 +164,15 @@ export interface StoragePaths { baseDir: string; configFile: string; sessionFile: string; + historyFile: string; } export function createStoragePaths(baseDir: string): StoragePaths { return { baseDir, configFile: path.join(baseDir, "rd_downloader_config.json"), - sessionFile: path.join(baseDir, "rd_session_state.json") + sessionFile: path.join(baseDir, "rd_session_state.json"), + historyFile: path.join(baseDir, "rd_history.json") }; } @@ -562,3 +564,82 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); await saveSessionPayloadAsync(paths, payload); } + +const MAX_HISTORY_ENTRIES = 500; + +function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null { + const entry = asRecord(raw); + if (!entry) return null; + + const id = asText(entry.id) || `hist-${Date.now().toString(36)}-${index}`; + const name = asText(entry.name) || "Unbenannt"; + const providerRaw = asText(entry.provider); + + return { + id, + name, + totalBytes: clampNumber(entry.totalBytes, 0, 0, Number.MAX_SAFE_INTEGER), + downloadedBytes: clampNumber(entry.downloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER), + fileCount: clampNumber(entry.fileCount, 0, 0, 100000), + provider: VALID_ITEM_PROVIDERS.has(providerRaw as DebridProvider) ? providerRaw as DebridProvider : null, + completedAt: clampNumber(entry.completedAt, Date.now(), 0, Number.MAX_SAFE_INTEGER), + durationSeconds: clampNumber(entry.durationSeconds, 0, 0, Number.MAX_SAFE_INTEGER), + status: entry.status === "deleted" ? "deleted" : "completed", + outputDir: asText(entry.outputDir) + }; +} + +export function loadHistory(paths: StoragePaths): HistoryEntry[] { + ensureBaseDir(paths.baseDir); + if (!fs.existsSync(paths.historyFile)) { + return []; + } + + try { + const raw = JSON.parse(fs.readFileSync(paths.historyFile, "utf8")) as unknown; + if (!Array.isArray(raw)) return []; + + const entries: HistoryEntry[] = []; + for (let i = 0; i < raw.length && entries.length < MAX_HISTORY_ENTRIES; i++) { + const normalized = normalizeHistoryEntry(raw[i], i); + if (normalized) entries.push(normalized); + } + return entries; + } catch { + return []; + } +} + +export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void { + ensureBaseDir(paths.baseDir); + const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES); + const payload = JSON.stringify(trimmed, null, 2); + const tempPath = `${paths.historyFile}.tmp`; + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.historyFile); +} + +export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): HistoryEntry[] { + const existing = loadHistory(paths); + const updated = [entry, ...existing].slice(0, MAX_HISTORY_ENTRIES); + saveHistory(paths, updated); + return updated; +} + +export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { + const existing = loadHistory(paths); + const updated = existing.filter(e => e.id !== entryId); + saveHistory(paths, updated); + return updated; +} + +export function clearHistory(paths: StoragePaths): void { + ensureBaseDir(paths.baseDir); + if (fs.existsSync(paths.historyFile)) { + try { + fs.unlinkSync(paths.historyFile); + } catch { + // ignore + } + } +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 496a84a..120a325 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -3,6 +3,7 @@ import { AddLinksPayload, AppSettings, DuplicatePolicy, + HistoryEntry, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -49,6 +50,9 @@ const api: ElectronApi = { openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), + getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), + clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), + removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d8b1903..2a2f884 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -600,17 +600,17 @@ export function App(): ReactElement { const itemCount = Object.keys(state.session.items).length; let flushDelay = itemCount >= 1500 - ? 850 + ? 1200 : itemCount >= 700 - ? 620 + ? 920 : itemCount >= 250 - ? 420 - : 180; + ? 640 + : 300; if (!state.session.running) { - flushDelay = Math.min(flushDelay, 260); + flushDelay = Math.min(flushDelay, 320); } if (activeTabRef.current !== "downloads") { - flushDelay = Math.max(flushDelay, 320); + flushDelay = Math.max(flushDelay, 800); } stateFlushTimerRef.current = setTimeout(() => { @@ -1740,6 +1740,35 @@ export function App(): ReactElement { return map; }, [snapshot.packageSpeedBps]); + const itemStatusCounts = useMemo(() => { + const counts = { downloading: 0, queued: 0, failed: 0 }; + for (const item of Object.values(snapshot.session.items)) { + if (item.status === "downloading") { + counts.downloading += 1; + } else if (item.status === "queued" || item.status === "reconnect_wait") { + counts.queued += 1; + } else if (item.status === "failed") { + counts.failed += 1; + } + } + return counts; + }, [snapshot.session.items]); + + const providerStats = useMemo(() => { + const stats: Record = {}; + for (const item of Object.values(snapshot.session.items)) { + const provider = item.provider || "unknown"; + if (!stats[provider]) { + stats[provider] = { total: 0, completed: 0, failed: 0, bytes: 0 }; + } + stats[provider].total += 1; + if (item.status === "completed") stats[provider].completed += 1; + if (item.status === "failed") stats[provider].failed += 1; + stats[provider].bytes += item.downloadedBytes; + } + return Object.entries(stats); + }, [snapshot.session.items]); + return (
Aktive Downloads - {Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length} + {itemStatusCounts.downloading}
In Warteschlange - {Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length} + {itemStatusCounts.queued}
Fehlerhaft - {Object.values(snapshot.session.items).filter((item) => item.status === "failed").length} + {itemStatusCounts.failed}
{snapshot.etaText.split(": ")[0]} @@ -2280,19 +2309,7 @@ export function App(): ReactElement {

Provider-Statistik

- {Object.entries( - Object.values(snapshot.session.items).reduce((acc, item) => { - const provider = item.provider || "unknown"; - if (!acc[provider]) { - acc[provider] = { total: 0, completed: 0, failed: 0, bytes: 0 }; - } - acc[provider].total += 1; - if (item.status === "completed") acc[provider].completed += 1; - if (item.status === "failed") acc[provider].failed += 1; - acc[provider].bytes += item.downloadedBytes; - return acc; - }, {} as Record) - ).map(([provider, stats]) => ( + {providerStats.map(([provider, stats]) => (
{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5e151d8..5fe7792 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -33,5 +33,8 @@ export const IPC_CHANNELS = { IMPORT_BACKUP: "app:import-backup", OPEN_LOG: "app:open-log", RETRY_EXTRACTION: "queue:retry-extraction", - EXTRACT_NOW: "queue:extract-now" + EXTRACT_NOW: "queue:extract-now", + GET_HISTORY: "history:get", + CLEAR_HISTORY: "history:clear", + REMOVE_HISTORY_ENTRY: "history:remove-entry" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 08bce06..665c914 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -2,6 +2,7 @@ import type { AddLinksPayload, AppSettings, DuplicatePolicy, + HistoryEntry, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -44,6 +45,9 @@ export interface ElectronApi { openLog: () => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; + getHistory: () => Promise; + clearHistory: () => Promise; + removeHistoryEntry: (entryId: string) => Promise; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 532adf6..9093cdc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -255,3 +255,21 @@ export interface SessionStats { activeDownloads: number; queuedDownloads: number; } + +export interface HistoryEntry { + id: string; + name: string; + totalBytes: number; + downloadedBytes: number; + fileCount: number; + provider: DebridProvider | null; + completedAt: number; + durationSeconds: number; + status: "completed" | "deleted"; + outputDir: string; +} + +export interface HistoryState { + entries: HistoryEntry[]; + maxEntries: number; +}