Fix download freeze spikes and unrestrict slot overshoot handling

This commit is contained in:
Sucukdeluxe 2026-03-02 23:47:54 +01:00
parent 7fe7d93e83
commit 19342647e5
10 changed files with 230 additions and 38 deletions

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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");

View File

@ -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
}
}
}

View File

@ -3,6 +3,7 @@ import {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
HistoryEntry,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -49,6 +50,9 @@ const api: ElectronApi = {
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => 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);

View File

@ -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<string, { total: number; completed: number; failed: number; bytes: number }> = {};
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 (
<div
className={`app-shell${dragOver ? " drag-over" : ""}`}
@ -2255,15 +2284,15 @@ export function App(): ReactElement {
</div>
<div className="stat-item">
<span className="stat-label">Aktive Downloads</span>
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "downloading").length}</span>
<span className="stat-value">{itemStatusCounts.downloading}</span>
</div>
<div className="stat-item">
<span className="stat-label">In Warteschlange</span>
<span className="stat-value">{Object.values(snapshot.session.items).filter((item) => item.status === "queued" || item.status === "reconnect_wait").length}</span>
<span className="stat-value">{itemStatusCounts.queued}</span>
</div>
<div className="stat-item">
<span className="stat-label">Fehlerhaft</span>
<span className="stat-value danger">{Object.values(snapshot.session.items).filter((item) => item.status === "failed").length}</span>
<span className="stat-value danger">{itemStatusCounts.failed}</span>
</div>
<div className="stat-item">
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
@ -2280,19 +2309,7 @@ export function App(): ReactElement {
<article className="card stats-provider-card">
<h3>Provider-Statistik</h3>
<div className="provider-stats">
{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<string, { total: number; completed: number; failed: number; bytes: number }>)
).map(([provider, stats]) => (
{providerStats.map(([provider, stats]) => (
<div key={provider} className="provider-stat-item">
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
<div className="provider-bars">

View File

@ -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;

View File

@ -2,6 +2,7 @@ import type {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
HistoryEntry,
SessionStats,
StartConflictEntry,
StartConflictResolutionResult,
@ -44,6 +45,9 @@ export interface ElectronApi {
openLog: () => Promise<void>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -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;
}