Fix download freeze spikes and unrestrict slot overshoot handling
This commit is contained in:
parent
7fe7d93e83
commit
19342647e5
@ -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 auth = "Basic " + Buffer.from(creds.get("username") + ":" + creds.get("password")).toString("base64");
|
||||||
const owner = "Sucukdeluxe";
|
const owner = "Sucukdeluxe";
|
||||||
const repo = "real-debrid-downloader";
|
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}`;
|
const baseApi = `https://codeberg.org/api/v1/repos/${owner}/${repo}`;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@ -34,7 +34,7 @@ async function main() {
|
|||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
target_commitish: "main",
|
target_commitish: "main",
|
||||||
name: tag,
|
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,
|
draft: false,
|
||||||
prerelease: false
|
prerelease: false
|
||||||
})
|
})
|
||||||
@ -47,10 +47,10 @@ async function main() {
|
|||||||
console.log("Release created:", release.id);
|
console.log("Release created:", release.id);
|
||||||
|
|
||||||
const files = [
|
const files = [
|
||||||
"Real-Debrid-Downloader Setup 1.5.27.exe",
|
"Real-Debrid-Downloader Setup 1.5.35.exe",
|
||||||
"Real-Debrid-Downloader 1.5.27.exe",
|
"Real-Debrid-Downloader 1.5.35.exe",
|
||||||
"latest.yml",
|
"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) {
|
for (const f of files) {
|
||||||
const filePath = path.join("release", f);
|
const filePath = path.join("release", f);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
HistoryEntry,
|
||||||
ParsedPackageInput,
|
ParsedPackageInput,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
@ -19,7 +20,7 @@ import { DownloadManager } from "./download-manager";
|
|||||||
import { parseCollectorInput } from "./link-parser";
|
import { parseCollectorInput } from "./link-parser";
|
||||||
import { configureLogger, getLogFilePath, logger } from "./logger";
|
import { configureLogger, getLogFilePath, logger } from "./logger";
|
||||||
import { MegaWebFallback } from "./mega-web-fallback";
|
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 { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { startDebugServer, stopDebugServer } from "./debug-server";
|
import { startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
|
|
||||||
@ -59,7 +60,10 @@ export class AppController {
|
|||||||
}));
|
}));
|
||||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||||
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
|
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.manager.on("state", (snapshot: UiSnapshot) => {
|
||||||
this.onStateHandler?.(snapshot);
|
this.onStateHandler?.(snapshot);
|
||||||
@ -280,4 +284,20 @@ export class AppController {
|
|||||||
this.megaWebFallback.dispose();
|
this.megaWebFallback.dispose();
|
||||||
logger.info("App beendet");
|
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");
|
|| 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 {
|
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||||
return status === "completed" || status === "failed" || status === "cancelled";
|
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 {
|
private getProviderCooldownRemaining(provider: string): number {
|
||||||
const entry = this.providerFailures.get(provider);
|
const entry = this.providerFailures.get(provider);
|
||||||
if (!entry || entry.cooldownUntil <= 0) {
|
if (!entry || entry.cooldownUntil <= 0) {
|
||||||
@ -3498,9 +3521,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.status !== "queued" && item.status !== "reconnect_wait") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.activeTasks.has(itemId)) {
|
if (this.activeTasks.has(itemId)) {
|
||||||
return;
|
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);
|
this.retryAfterByItem.delete(itemId);
|
||||||
|
|
||||||
@ -3580,8 +3611,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw new Error(`aborted:${active.abortReason}`);
|
throw new Error(`aborted:${active.abortReason}`);
|
||||||
}
|
}
|
||||||
// Check provider cooldown before attempting unrestrict
|
// Check provider cooldown before attempting unrestrict
|
||||||
const lastProvider = item.provider || "";
|
const cooldownProvider = item.provider || this.settings.providerPrimary || "unknown";
|
||||||
const cooldownProvider = lastProvider || this.settings.providerPrimary || "unknown";
|
|
||||||
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
const cooldownMs = this.getProviderCooldownRemaining(cooldownProvider);
|
||||||
if (cooldownMs > 0) {
|
if (cooldownMs > 0) {
|
||||||
const delayMs = Math.min(cooldownMs + 1000, 310000);
|
const delayMs = Math.min(cooldownMs + 1000, 310000);
|
||||||
@ -3598,13 +3628,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
// Record failure for all providers since we don't know which one timed out
|
// 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`);
|
throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`);
|
||||||
}
|
}
|
||||||
// Record failure for the provider that errored
|
// Record failure for the provider that errored
|
||||||
const errText = compactErrorText(unrestrictError);
|
const errText = compactErrorText(unrestrictError);
|
||||||
if (isUnrestrictFailure(errText)) {
|
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;
|
throw unrestrictError;
|
||||||
}
|
}
|
||||||
@ -3951,11 +3985,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||||
active.unrestrictRetries += 1;
|
active.unrestrictRetries += 1;
|
||||||
item.retries += 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
|
// 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)));
|
let unrestrictDelayMs = Math.min(120000, Math.floor(5000 * Math.pow(1.5, active.unrestrictRetries - 1)));
|
||||||
// Respect provider cooldown
|
// Respect provider cooldown
|
||||||
const providerCooldown = this.getProviderCooldownRemaining(item.provider || "unknown");
|
const providerCooldown = this.getProviderCooldownRemaining(failureProvider);
|
||||||
if (providerCooldown > unrestrictDelayMs) {
|
if (providerCooldown > unrestrictDelayMs) {
|
||||||
unrestrictDelayMs = providerCooldown + 1000;
|
unrestrictDelayMs = providerCooldown + 1000;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -322,6 +322,12 @@ function registerIpcHandlers(): void {
|
|||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
return controller.extractNow(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.EXPORT_QUEUE, () => controller.exportQueue());
|
||||||
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
|
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
|
||||||
validateString(json, "json");
|
validateString(json, "json");
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import fsp from "node:fs/promises";
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 { defaultSettings } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@ -164,13 +164,15 @@ export interface StoragePaths {
|
|||||||
baseDir: string;
|
baseDir: string;
|
||||||
configFile: string;
|
configFile: string;
|
||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
|
historyFile: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStoragePaths(baseDir: string): StoragePaths {
|
export function createStoragePaths(baseDir: string): StoragePaths {
|
||||||
return {
|
return {
|
||||||
baseDir,
|
baseDir,
|
||||||
configFile: path.join(baseDir, "rd_downloader_config.json"),
|
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() });
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||||
await saveSessionPayloadAsync(paths, payload);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
HistoryEntry,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -49,6 +50,9 @@ const api: ElectronApi = {
|
|||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, 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) => {
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
|
|||||||
@ -600,17 +600,17 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const itemCount = Object.keys(state.session.items).length;
|
const itemCount = Object.keys(state.session.items).length;
|
||||||
let flushDelay = itemCount >= 1500
|
let flushDelay = itemCount >= 1500
|
||||||
? 850
|
? 1200
|
||||||
: itemCount >= 700
|
: itemCount >= 700
|
||||||
? 620
|
? 920
|
||||||
: itemCount >= 250
|
: itemCount >= 250
|
||||||
? 420
|
? 640
|
||||||
: 180;
|
: 300;
|
||||||
if (!state.session.running) {
|
if (!state.session.running) {
|
||||||
flushDelay = Math.min(flushDelay, 260);
|
flushDelay = Math.min(flushDelay, 320);
|
||||||
}
|
}
|
||||||
if (activeTabRef.current !== "downloads") {
|
if (activeTabRef.current !== "downloads") {
|
||||||
flushDelay = Math.max(flushDelay, 320);
|
flushDelay = Math.max(flushDelay, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
stateFlushTimerRef.current = setTimeout(() => {
|
stateFlushTimerRef.current = setTimeout(() => {
|
||||||
@ -1740,6 +1740,35 @@ export function App(): ReactElement {
|
|||||||
return map;
|
return map;
|
||||||
}, [snapshot.packageSpeedBps]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`app-shell${dragOver ? " drag-over" : ""}`}
|
className={`app-shell${dragOver ? " drag-over" : ""}`}
|
||||||
@ -2255,15 +2284,15 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">Aktive Downloads</span>
|
<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>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">In Warteschlange</span>
|
<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>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">Fehlerhaft</span>
|
<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>
|
||||||
<div className="stat-item">
|
<div className="stat-item">
|
||||||
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
|
<span className="stat-label">{snapshot.etaText.split(": ")[0]}</span>
|
||||||
@ -2280,19 +2309,7 @@ export function App(): ReactElement {
|
|||||||
<article className="card stats-provider-card">
|
<article className="card stats-provider-card">
|
||||||
<h3>Provider-Statistik</h3>
|
<h3>Provider-Statistik</h3>
|
||||||
<div className="provider-stats">
|
<div className="provider-stats">
|
||||||
{Object.entries(
|
{providerStats.map(([provider, stats]) => (
|
||||||
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]) => (
|
|
||||||
<div key={provider} className="provider-stat-item">
|
<div key={provider} className="provider-stat-item">
|
||||||
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
|
<span className="provider-name">{provider === "unknown" ? "Unbekannt" : providerLabels[provider as DebridProvider] || provider}</span>
|
||||||
<div className="provider-bars">
|
<div className="provider-bars">
|
||||||
|
|||||||
@ -33,5 +33,8 @@ export const IPC_CHANNELS = {
|
|||||||
IMPORT_BACKUP: "app:import-backup",
|
IMPORT_BACKUP: "app:import-backup",
|
||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
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;
|
} as const;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
HistoryEntry,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -44,6 +45,9 @@ export interface ElectronApi {
|
|||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
retryExtraction: (packageId: string) => Promise<void>;
|
retryExtraction: (packageId: string) => Promise<void>;
|
||||||
extractNow: (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;
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||||
|
|||||||
@ -255,3 +255,21 @@ export interface SessionStats {
|
|||||||
activeDownloads: number;
|
activeDownloads: number;
|
||||||
queuedDownloads: 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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user