diff --git a/package.json b/package.json index 5461bf7..33a6e7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.6.22", + "version": "1.6.23", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e6ed2f5..b47d4b1 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -21,7 +21,7 @@ import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; @@ -306,9 +306,10 @@ export class AppController { // so no extraction tasks from it should keep running. this.manager.stop(); this.manager.abortAllPostProcessing(); - // Cancel any deferred persist timer so the old in-memory session - // does not overwrite the restored session file on disk. + // Cancel any deferred persist timer and queued async writes so the old + // in-memory session does not overwrite the restored session file on disk. this.manager.clearPersistTimer(); + cancelPendingAsyncSaves(); const restoredSession = normalizeLoadedSessionTransientFields( normalizeLoadedSession(parsed.session) ); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index cd6fe21..a4785e7 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -669,7 +669,11 @@ class BestDebridClient { try { return await this.tryRequest(request, link, signal); } catch (error) { - lastError = compactErrorText(error); + const errorText = compactErrorText(error); + if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { + throw error; + } + lastError = errorText; } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3edf911..2b756d4 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -32,7 +32,7 @@ type ActiveTask = { itemId: string; packageId: string; abortController: AbortController; - abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none"; + abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none"; resumable: boolean; nonResumableCounted: boolean; freshRetryUsed?: boolean; @@ -1509,10 +1509,14 @@ export class DownloadManager extends EventEmitter { } } this.runCompletedPackages.delete(packageId); + if (this.session.running) { + this.runPackageIds.add(packageId); + } pkg.status = "queued"; pkg.updatedAt = nowMs(); this.persistSoon(); this.emitState(true); + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resolveStartConflict): ${compactErrorText(err)}`)); return { skipped: false, overwritten: true }; } @@ -2557,6 +2561,9 @@ export class DownloadManager extends EventEmitter { logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`); this.persistSoon(); this.emitState(true); + if (this.session.running) { + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetPackage): ${compactErrorText(err)}`)); + } } public resetItems(itemIds: string[]): void { @@ -2615,11 +2622,18 @@ export class DownloadManager extends EventEmitter { pkg.cancelled = false; pkg.updatedAt = nowMs(); } + // Re-add package to runPackageIds so scheduler picks up the reset items + if (this.session.running) { + this.runPackageIds.add(pkgId); + } } logger.info(`${itemIds.length} Item(s) zurückgesetzt`); this.persistSoon(); this.emitState(true); + if (this.session.running) { + void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetItems): ${compactErrorText(err)}`)); + } } public setPackagePriority(packageId: string, priority: PackagePriority): void { @@ -2660,6 +2674,8 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Übersprungen"; item.speedBps = 0; item.updatedAt = nowMs(); + this.retryAfterByItem.delete(itemId); + this.retryStateByItem.delete(itemId); this.recordRunOutcome(itemId, "cancelled"); } this.persistSoon(); @@ -3099,8 +3115,9 @@ export class DownloadManager extends EventEmitter { this.retryAfterByItem.clear(); this.nonResumableActive = 0; this.session.summaryText = ""; - this.lastSettingsPersistAt = 0; // force settings save on shutdown - this.persistNow(); + // Persist synchronously on shutdown to guarantee data is written before process exits + saveSession(this.storagePaths, this.session); + saveSettings(this.storagePaths, this.settings); this.emitState(true); logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`); } @@ -3272,6 +3289,10 @@ export class DownloadManager extends EventEmitter { return false; } if (item.status === "completed") { + // With autoExtract: keep items that haven't been extracted yet + if (this.settings.autoExtract && !isExtractedLabel(item.fullStatus || "")) { + return true; + } delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); return false; @@ -3279,8 +3300,7 @@ export class DownloadManager extends EventEmitter { return true; }); if (pkg.itemIds.length === 0) { - delete this.session.packages[pkgId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== pkgId); + this.removePackageFromSession(pkgId, []); } } } @@ -3744,7 +3764,7 @@ export class DownloadManager extends EventEmitter { const targetStatus = failed > 0 ? "failed" : cancelled > 0 - ? (success > 0 ? "failed" : "cancelled") + ? (success > 0 ? "completed" : "cancelled") : "completed"; if (pkg.status !== targetStatus) { pkg.status = targetStatus; @@ -3897,8 +3917,9 @@ export class DownloadManager extends EventEmitter { const pkg = this.session.packages[packageId]; // Only create history here for deletions — completions are handled by recordPackageHistory if (pkg && this.onHistoryEntryCallback && reason === "deleted" && !this.historyRecordedPackages.has(packageId)) { - const completedItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; - const completedCount = completedItems.filter(item => item.status === "completed").length; + const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const completedItems = allItems.filter(item => item.status === "completed"); + const completedCount = completedItems.length; if (completedCount > 0) { const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); const durationSeconds = pkg.createdAt > 0 ? Math.max(1, Math.floor((nowMs() - pkg.createdAt) / 1000)) : 1; @@ -5118,6 +5139,7 @@ export class DownloadManager extends EventEmitter { const previouslyContributed = this.itemContributedBytes.get(active.itemId) || 0; if (previouslyContributed > 0) { this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed); + this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - previouslyContributed); this.itemContributedBytes.set(active.itemId, 0); } if (existingBytes > 0) { @@ -6673,11 +6695,13 @@ export class DownloadManager extends EventEmitter { return; } - // With autoExtract: only remove once ALL items are extracted, not just downloaded + // With autoExtract: only remove once all completed items are extracted (failed/cancelled don't need extraction) if (this.settings.autoExtract) { const allExtracted = pkg.itemIds.every((itemId) => { const item = this.session.items[itemId]; - return !item || isExtractedLabel(item.fullStatus || ""); + if (!item) return true; + if (item.status === "failed" || item.status === "cancelled") return true; + return isExtractedLabel(item.fullStatus || ""); }); if (!allExtracted) { return; @@ -6727,11 +6751,13 @@ export class DownloadManager extends EventEmitter { return item != null && item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed"; }); if (!hasOpen) { - // With autoExtract: only remove once ALL items are extracted, not just downloaded + // With autoExtract: only remove once completed items are extracted (failed/cancelled don't need extraction) if (this.settings.autoExtract) { const allExtracted = pkg.itemIds.every((id) => { const item = this.session.items[id]; - return !item || isExtractedLabel(item.fullStatus || ""); + if (!item) return true; + if (item.status === "failed" || item.status === "cancelled") return true; + return isExtractedLabel(item.fullStatus || ""); }); if (!allExtracted) { return; diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 3491217..1fac6dc 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -2091,6 +2091,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (options.signal?.aborted || noExtractorEncountered) break; await extractSingleArchive(archivePath); } + // Count remaining archives as failed when no extractor was found + if (noExtractorEncountered) { + const remaining = candidates.length - (extracted + failed); + if (remaining > 0) { + failed += remaining; + emitProgress(candidates.length, "", "extracting", 0, 0); + } + } } else { // Password discovery: extract first archive serially to find the correct password, // then run remaining archives in parallel with the promoted password order. diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 70c86af..6a4d9cc 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -62,7 +62,8 @@ function isRetryableErrorText(text: string): boolean { || lower.includes("aborted") || lower.includes("econnreset") || lower.includes("enotfound") - || lower.includes("etimedout"); + || lower.includes("etimedout") + || lower.includes("html statt json"); } function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { @@ -165,6 +166,15 @@ export class RealDebridClient { if (!directUrl) { throw new Error("Unrestrict ohne Download-URL"); } + try { + const parsedUrl = new URL(directUrl); + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { + throw new Error(`Ungültiges Download-URL-Protokoll (${parsedUrl.protocol})`); + } + } catch (urlError) { + if (urlError instanceof Error && urlError.message.includes("Protokoll")) throw urlError; + throw new Error("Real-Debrid Antwort enthält keine gültige Download-URL"); + } const fileName = String(payload.filename || "download.bin").trim() || "download.bin"; const fileSizeRaw = Number(payload.filesize ?? NaN); diff --git a/src/main/storage.ts b/src/main/storage.ts index 80671ec..33af16e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -339,7 +339,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState { return true; }); for (const packageId of Object.keys(packagesById)) { - if (!packageOrder.includes(packageId)) { + if (!seenOrder.has(packageId)) { + seenOrder.add(packageId); packageOrder.push(packageId); } } @@ -606,6 +607,11 @@ async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Pr } } +export function cancelPendingAsyncSaves(): void { + asyncSaveQueued = null; + asyncSettingsSaveQueued = null; +} + export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); await saveSessionPayloadAsync(paths, payload); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9e654b2..60f1aed 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1169,7 +1169,7 @@ export function App(): ReactElement { if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); - if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links gefunden"); } @@ -1187,7 +1187,7 @@ export function App(): ReactElement { const result = await window.rd.addContainers(files); if (result.addedLinks > 0) { showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } @@ -1214,7 +1214,7 @@ export function App(): ReactElement { const result = await window.rd.addContainers(dlc); if (result.addedLinks > 0) { showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); - if (snapshot.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } + if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); } @@ -1434,13 +1434,16 @@ export function App(): ReactElement { }, []); const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { - setEditingPackageId(null); - const normalized = nextName.trim(); - if (normalized && normalized !== currentName.trim()) { - void window.rd.renamePackage(packageId, normalized).catch((error) => { - showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); - }); - } + setEditingPackageId((prev) => { + if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key) + const normalized = nextName.trim(); + if (normalized && normalized !== currentName.trim()) { + void window.rd.renamePackage(packageId, normalized).catch((error) => { + showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); + }); + } + return null; + }); }, [showToast]); const onPackageToggleCollapse = useCallback((packageId: string): void => { @@ -1599,6 +1602,7 @@ export function App(): ReactElement { const onUp = (): void => { dragSelectRef.current = false; dragAnchorRef.current = null; + dragDidMoveRef.current = false; window.removeEventListener("mouseup", onUp); }; window.addEventListener("mouseup", onUp); @@ -1619,18 +1623,20 @@ export function App(): ReactElement { const showLinksPopup = useCallback((packageId: string, itemId?: string): void => { const sel = selectedIds; + const currentPackages = snapshotRef.current.session.packages; + const currentItems = snapshotRef.current.session.items; // Multi-select: collect links from all selected packages/items if (sel.size > 1) { const allLinks: { name: string; url: string }[] = []; for (const id of sel) { - const pkg = snapshot.session.packages[id]; + const pkg = currentPackages[id]; if (pkg) { for (const iid of pkg.itemIds) { - const item = snapshot.session.items[iid]; + const item = currentItems[iid]; if (item) allLinks.push({ name: item.fileName, url: item.url }); } } else { - const item = snapshot.session.items[id]; + const item = currentItems[id]; if (item) allLinks.push({ name: item.fileName, url: item.url }); } } @@ -1638,22 +1644,22 @@ export function App(): ReactElement { setContextMenu(null); return; } - const pkg = snapshot.session.packages[packageId]; + const pkg = currentPackages[packageId]; if (!pkg) { return; } if (itemId) { - const item = snapshot.session.items[itemId]; + const item = currentItems[itemId]; if (item) { setLinkPopup({ title: item.fileName, links: [{ name: item.fileName, url: item.url }], isPackage: false }); } } else { const links = pkg.itemIds - .map((id) => snapshot.session.items[id]) + .map((id) => currentItems[id]) .filter(Boolean) .map((item) => ({ name: item.fileName, url: item.url })); setLinkPopup({ title: pkg.name, links, isPackage: true }); } setContextMenu(null); - }, [snapshot.session.packages, snapshot.session.items, selectedIds]); + }, [selectedIds]); const schedules = settingsDraft.bandwidthSchedules ?? []; @@ -1815,6 +1821,8 @@ export function App(): ReactElement { if (e.key === "Escape") { const target = e.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { + // Don't clear selection if an overlay is open — let the overlay close first + if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop") || document.querySelector(".link-popup-overlay")) return; if (tabRef.current === "downloads") setSelectedIds(new Set()); else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); } @@ -1875,35 +1883,43 @@ export function App(): ReactElement { useEffect(() => { const handler = (e: globalThis.KeyboardEvent): void => { if (e.ctrlKey && !e.altKey && !e.metaKey) { + const target = e.target as HTMLElement; + const inInput = target.tagName === "INPUT" || target.tagName === "TEXTAREA"; if (e.shiftKey && e.key.toLowerCase() === "r") { + if (inInput) return; e.preventDefault(); void window.rd.restart(); return; } if (!e.shiftKey && e.key.toLowerCase() === "q") { + if (inInput) return; e.preventDefault(); void window.rd.quit(); return; } if (!e.shiftKey && e.key.toLowerCase() === "l") { + if (inInput) return; e.preventDefault(); setTab("collector"); setOpenMenu(null); return; } if (!e.shiftKey && e.key.toLowerCase() === "p") { + if (inInput) return; e.preventDefault(); setTab("settings"); setOpenMenu(null); return; } if (!e.shiftKey && e.key.toLowerCase() === "o") { + if (inInput) return; e.preventDefault(); setOpenMenu(null); void onImportDlc(); return; } if (!e.shiftKey && e.key.toLowerCase() === "a") { + if (inInput) return; if (tabRef.current === "downloads") { e.preventDefault(); setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));