Release v1.6.23

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-04 19:26:01 +01:00
parent 9cceaacd14
commit 26b2ef0abb
8 changed files with 107 additions and 36 deletions

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void> {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
await saveSessionPayloadAsync(paths, payload);

View File

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