Release v1.4.11 with stability hardening and full-function regression pass

This commit is contained in:
Sucukdeluxe 2026-02-27 20:25:55 +01:00
parent 6e72c63268
commit 8b5c936177
4 changed files with 103 additions and 52 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.10", "version": "1.4.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.10", "version": "1.4.11",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.10", "version": "1.4.11",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -36,6 +36,9 @@ export interface ExtractProgressUpdate {
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json"; const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
const EXTRACT_BASE_TIMEOUT_MS = 6 * 60 * 1000;
const EXTRACT_PER_GIB_TIMEOUT_MS = 7 * 60 * 1000;
const EXTRACT_MAX_TIMEOUT_MS = 40 * 60 * 1000;
type ExtractResumeState = { type ExtractResumeState = {
completedArchives: string[]; completedArchives: string[];
@ -110,6 +113,17 @@ function shouldPreferExternalZip(archivePath: string): boolean {
} }
} }
function computeExtractTimeoutMs(archivePath: string): number {
try {
const stat = fs.statSync(archivePath);
const gib = stat.size / (1024 * 1024 * 1024);
const dynamicMs = EXTRACT_BASE_TIMEOUT_MS + Math.floor(gib * EXTRACT_PER_GIB_TIMEOUT_MS);
return Math.max(EXTRACT_BASE_TIMEOUT_MS, Math.min(EXTRACT_MAX_TIMEOUT_MS, dynamicMs));
} catch {
return EXTRACT_BASE_TIMEOUT_MS;
}
}
function extractProgressFilePath(packageDir: string): string { function extractProgressFilePath(packageDir: string): string {
return path.join(packageDir, EXTRACT_PROGRESS_FILE); return path.join(packageDir, EXTRACT_PROGRESS_FILE);
} }
@ -215,6 +229,7 @@ type ExtractSpawnResult = {
ok: boolean; ok: boolean;
missingCommand: boolean; missingCommand: boolean;
aborted: boolean; aborted: boolean;
timedOut: boolean;
errorText: string; errorText: string;
}; };
@ -222,28 +237,51 @@ function runExtractCommand(
command: string, command: string,
args: string[], args: string[],
onChunk?: (chunk: string) => void, onChunk?: (chunk: string) => void,
signal?: AbortSignal signal?: AbortSignal,
timeoutMs?: number
): Promise<ExtractSpawnResult> { ): Promise<ExtractSpawnResult> {
if (signal?.aborted) { if (signal?.aborted) {
return Promise.resolve({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" }); return Promise.resolve({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
} }
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
let output = ""; let output = "";
const child = spawn(command, args, { windowsHide: true }); const child = spawn(command, args, { windowsHide: true });
let timeoutId: NodeJS.Timeout | null = null;
const finish = (result: ExtractSpawnResult): void => { const finish = (result: ExtractSpawnResult): void => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (signal && onAbort) { if (signal && onAbort) {
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
} }
resolve(result); resolve(result);
}; };
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
try {
child.kill();
} catch {
// ignore
}
finish({
ok: false,
missingCommand: false,
aborted: false,
timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`
});
}, timeoutMs);
}
const onAbort = signal const onAbort = signal
? (): void => { ? (): void => {
try { try {
@ -251,7 +289,7 @@ function runExtractCommand(
} catch { } catch {
// ignore // ignore
} }
finish({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" }); finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" });
} }
: null; : null;
if (signal && onAbort) { if (signal && onAbort) {
@ -275,13 +313,14 @@ function runExtractCommand(
ok: false, ok: false,
missingCommand: text.toLowerCase().includes("enoent"), missingCommand: text.toLowerCase().includes("enoent"),
aborted: false, aborted: false,
timedOut: false,
errorText: text errorText: text
}); });
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (code === 0 || code === 1) { if (code === 0 || code === 1) {
finish({ ok: true, missingCommand: false, aborted: false, errorText: "" }); finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
return; return;
} }
const cleaned = cleanErrorText(output); const cleaned = cleanErrorText(output);
@ -289,6 +328,7 @@ function runExtractCommand(
ok: false, ok: false,
missingCommand: false, missingCommand: false,
aborted: false, aborted: false,
timedOut: false,
errorText: cleaned || `Exit Code ${String(code ?? "?")}` errorText: cleaned || `Exit Code ${String(code ?? "?")}`
}); });
}); });
@ -353,6 +393,7 @@ async function runExternalExtract(
const command = await resolveExtractorCommand(); const command = await resolveExtractorCommand();
const passwords = passwordCandidates; const passwords = passwordCandidates;
let lastError = ""; let lastError = "";
const timeoutMs = computeExtractTimeoutMs(archivePath);
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
@ -375,7 +416,7 @@ async function runExternalExtract(
} }
bestPercent = parsed; bestPercent = parsed;
onArchiveProgress?.(bestPercent); onArchiveProgress?.(bestPercent);
}, signal); }, signal, timeoutMs);
if (result.ok) { if (result.ok) {
onArchiveProgress?.(100); onArchiveProgress?.(100);
return password; return password;
@ -385,6 +426,11 @@ async function runExternalExtract(
throw new Error("aborted:extract"); throw new Error("aborted:extract");
} }
if (result.timedOut) {
lastError = result.errorText;
break;
}
if (result.missingCommand) { if (result.missingCommand) {
resolvedExtractorCommand = null; resolvedExtractorCommand = null;
resolveFailureReason = NO_EXTRACTOR_MESSAGE; resolveFailureReason = NO_EXTRACTOR_MESSAGE;

View File

@ -205,22 +205,51 @@ export function App(): ReactElement {
}, []); }, []);
const downloadsTabActive = tab === "downloads"; const downloadsTabActive = tab === "downloads";
const deferredDownloadSearch = useDeferredValue(downloadSearch);
const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase();
const downloadSearchActive = downloadSearchQuery.length > 0;
const totalPackageCount = snapshot.session.packageOrder.length;
const shouldLimitPackageRendering = downloadsTabActive
&& snapshot.session.running
&& !downloadSearchActive
&& totalPackageCount > AUTO_RENDER_PACKAGE_LIMIT
&& !showAllPackages;
const packages = useMemo(() => { const packageIdsForView = useMemo(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
return [] as PackageEntry[]; return [] as string[];
} }
return snapshot.session.packageOrder if (downloadSearchActive) {
.map((id: string) => snapshot.session.packages[id]) return snapshot.session.packageOrder;
.filter(Boolean); }
}, [downloadsTabActive, snapshot.session.packageOrder, snapshot.session.packages]); if (shouldLimitPackageRendering) {
return snapshot.session.packageOrder.slice(0, AUTO_RENDER_PACKAGE_LIMIT);
}
return snapshot.session.packageOrder;
}, [downloadsTabActive, downloadSearchActive, shouldLimitPackageRendering, snapshot.session.packageOrder]);
const packageOrderKey = useMemo(() => { const packageOrderKey = useMemo(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
return ""; return "";
} }
return snapshot.session.packageOrder.join("|"); return packageIdsForView.join("|");
}, [downloadsTabActive, snapshot.session.packageOrder]); }, [downloadsTabActive, packageIdsForView]);
const packages = useMemo(() => {
if (!downloadsTabActive) {
return [] as PackageEntry[];
}
if (downloadSearchActive) {
return snapshot.session.packageOrder
.map((id: string) => snapshot.session.packages[id])
.filter((pkg): pkg is PackageEntry => Boolean(pkg) && pkg.name.toLowerCase().includes(downloadSearchQuery));
}
return packageIdsForView
.map((id) => snapshot.session.packages[id])
.filter((pkg): pkg is PackageEntry => Boolean(pkg));
}, [downloadsTabActive, downloadSearchActive, downloadSearchQuery, packageIdsForView, snapshot.session.packageOrder, snapshot.session.packages]);
const packagePosition = useMemo(() => { const packagePosition = useMemo(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
@ -238,18 +267,14 @@ export function App(): ReactElement {
return new Map<string, DownloadItem[]>(); return new Map<string, DownloadItem[]>();
} }
const map = new Map<string, DownloadItem[]>(); const map = new Map<string, DownloadItem[]>();
for (const packageId of snapshot.session.packageOrder) { for (const pkg of packages) {
const pkg = snapshot.session.packages[packageId];
if (!pkg) {
continue;
}
const items = pkg.itemIds const items = pkg.itemIds
.map((id) => snapshot.session.items[id]) .map((id) => snapshot.session.items[id])
.filter(Boolean) as DownloadItem[]; .filter(Boolean) as DownloadItem[];
map.set(packageId, items); map.set(pkg.id, items);
} }
return map; return map;
}, [downloadsTabActive, packageOrderKey, snapshot.session.items, snapshot.session.packages, snapshot.session.packageOrder]); }, [downloadsTabActive, packageOrderKey, packages, snapshot.session.items]);
useEffect(() => { useEffect(() => {
if (!downloadsTabActive) { if (!downloadsTabActive) {
@ -257,38 +282,18 @@ export function App(): ReactElement {
} }
setCollapsedPackages((prev) => { setCollapsedPackages((prev) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
const defaultCollapsed = snapshot.session.packageOrder.length >= 24; const defaultCollapsed = totalPackageCount >= 24;
for (const packageId of snapshot.session.packageOrder) { for (const packageId of packageIdsForView) {
next[packageId] = prev[packageId] ?? defaultCollapsed; next[packageId] = prev[packageId] ?? defaultCollapsed;
} }
return next; return next;
}); });
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder.length]); }, [downloadsTabActive, packageOrderKey, totalPackageCount, packageIdsForView]);
const deferredDownloadSearch = useDeferredValue(downloadSearch); const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length)
const filteredPackages = useMemo(() => { : 0;
const query = deferredDownloadSearch.trim().toLowerCase(); const visiblePackages = packages;
if (!query) {
return packages;
}
return packages.filter((pkg) => pkg.name.toLowerCase().includes(query));
}, [packages, deferredDownloadSearch]);
const downloadSearchActive = deferredDownloadSearch.trim().length > 0;
const shouldLimitPackageRendering = snapshot.session.running
&& !downloadSearchActive
&& filteredPackages.length > AUTO_RENDER_PACKAGE_LIMIT
&& !showAllPackages;
const visiblePackages = useMemo(() => {
if (!shouldLimitPackageRendering) {
return filteredPackages;
}
return filteredPackages.slice(0, AUTO_RENDER_PACKAGE_LIMIT);
}, [filteredPackages, shouldLimitPackageRendering]);
const hiddenPackageCount = filteredPackages.length - visiblePackages.length;
useEffect(() => { useEffect(() => {
if (!snapshot.session.running) { if (!snapshot.session.running) {
@ -855,8 +860,8 @@ export function App(): ReactElement {
<span>Dateien: {snapshot.stats.totalFiles} fertig</span> <span>Dateien: {snapshot.stats.totalFiles} fertig</span>
<span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span> <span>Gesamt: {humanSize(snapshot.stats.totalDownloaded)}</span>
</div> </div>
{packages.length === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>} {totalPackageCount === 0 && <div className="empty">Noch keine Pakete in der Queue.</div>}
{packages.length > 0 && filteredPackages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>} {totalPackageCount > 0 && packages.length === 0 && <div className="empty">Keine Pakete passend zur Suche.</div>}
{hiddenPackageCount > 0 && ( {hiddenPackageCount > 0 && (
<div className="reconnect-banner"> <div className="reconnect-banner">
Performance-Modus aktiv: {hiddenPackageCount} Paket(e) sind temporar ausgeblendet. Performance-Modus aktiv: {hiddenPackageCount} Paket(e) sind temporar ausgeblendet.