Fix download freeze spikes and unrestrict slot overshoot handling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+83
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user