From 84d8f37ba60fb4f5e82b3853dfc5630b2023bc88 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Feb 2026 19:47:46 +0100 Subject: [PATCH] Release v1.4.28 with expanded bug audit fixes --- package-lock.json | 4 +- package.json | 2 +- src/main/app-controller.ts | 32 ++- src/main/cleanup.ts | 8 +- src/main/constants.ts | 2 +- src/main/container.ts | 14 +- src/main/debrid.ts | 193 +++++++++++----- src/main/download-manager.ts | 429 ++++++++++++++++++++++++++--------- src/main/extractor.ts | 194 ++++++++++++---- src/main/logger.ts | 2 + src/main/main.ts | 43 +++- src/main/realdebrid.ts | 40 +++- src/main/storage.ts | 49 +++- src/main/update.ts | 25 +- src/main/utils.ts | 25 +- src/renderer/App.tsx | 186 ++++++++++++--- 16 files changed, 966 insertions(+), 282 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c31646..55d84df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.27", + "version": "1.4.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.27", + "version": "1.4.28", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 3531b22..0cdae5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.27", + "version": "1.4.28", "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 99846f1..c3ee675 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -12,7 +12,7 @@ import { UpdateInstallResult } from "../shared/types"; import { importDlcContainers } from "./container"; -import { APP_VERSION, defaultSettings } from "./constants"; +import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; @@ -20,6 +20,15 @@ import { MegaWebFallback } from "./mega-web-fallback"; import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; import { checkGitHubUpdate, installLatestUpdate } from "./update"; +function sanitizeSettingsPatch(partial: Partial): Partial { + const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); + return Object.fromEntries(entries) as Partial; +} + +function settingsFingerprint(settings: AppSettings): string { + return JSON.stringify(normalizeSettings(settings)); +} + export class AppController { private settings: AppSettings; @@ -33,6 +42,8 @@ export class AppController { private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; + public constructor() { configureLogger(this.storagePaths.baseDir); this.settings = loadSettings(this.storagePaths); @@ -45,7 +56,7 @@ export class AppController { megaWebUnrestrict: (link: string) => this.megaWebFallback.unrestrict(link) }); this.manager.on("state", (snapshot: UiSnapshot) => { - this.onState?.(snapshot); + this.onStateHandler?.(snapshot); }); logger.info(`App gestartet v${APP_VERSION}`); logger.info(`Log-Datei: ${getLogFilePath()}`); @@ -72,7 +83,16 @@ export class AppController { ); } - public onState: ((snapshot: UiSnapshot) => void) | null = null; + public get onState(): ((snapshot: UiSnapshot) => void) | null { + return this.onStateHandler; + } + + public set onState(handler: ((snapshot: UiSnapshot) => void) | null) { + this.onStateHandler = handler; + if (handler) { + handler(this.manager.getSnapshot()); + } + } public getSnapshot(): UiSnapshot { return this.manager.getSnapshot(); @@ -87,13 +107,13 @@ export class AppController { } public updateSettings(partial: Partial): AppSettings { + const sanitizedPatch = sanitizeSettingsPatch(partial); const nextSettings = normalizeSettings({ - ...defaultSettings(), ...this.settings, - ...partial + ...sanitizedPatch }); - if (JSON.stringify(nextSettings) === JSON.stringify(this.settings)) { + if (settingsFingerprint(nextSettings) === settingsFingerprint(this.settings)) { return this.settings; } diff --git a/src/main/cleanup.ts b/src/main/cleanup.ts index 2d02609..a30edc3 100644 --- a/src/main/cleanup.ts +++ b/src/main/cleanup.ts @@ -9,15 +9,15 @@ async function yieldToLoop(): Promise { } export function isArchiveOrTempFile(filePath: string): boolean { - const lower = filePath.toLowerCase(); - const ext = path.extname(lower); + const lowerName = path.basename(filePath).toLowerCase(); + const ext = path.extname(lowerName); if (ARCHIVE_TEMP_EXTENSIONS.has(ext)) { return true; } - if (lower.includes(".part") && lower.endsWith(".rar")) { + if (lowerName.includes(".part") && lowerName.endsWith(".rar")) { return true; } - return RAR_SPLIT_RE.test(lower); + return RAR_SPLIT_RE.test(lowerName); } export function cleanupCancelledPackageArtifacts(packageDir: string): number { diff --git a/src/main/constants.ts b/src/main/constants.ts index 9c2e590..b1956e7 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -21,7 +21,7 @@ export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rs export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i; export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz"]); -export const RAR_SPLIT_RE = /\.r\d{2}$/i; +export const RAR_SPLIT_RE = /\.r\d{2,3}$/i; export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024; export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; diff --git a/src/main/container.ts b/src/main/container.ts index 1d36ae7..fe63332 100644 --- a/src/main/container.ts +++ b/src/main/container.ts @@ -194,11 +194,21 @@ export async function importDlcContainers(filePaths: string[]): Promise { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) { + throw new Error("aborted:debrid"); + } + await new Promise((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + signal.removeEventListener("abort", onAbort); + resolve(); + }, Math.max(0, ms)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted:debrid")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -105,7 +133,7 @@ function pickNumber(payload: Record | null, keys: string[]): nu } for (const key of keys) { const value = Number(payload[key] ?? NaN); - if (Number.isFinite(value) && value > 0) { + if (Number.isFinite(value) && value >= 0) { return Math.floor(value); } } @@ -124,6 +152,15 @@ function parseError(status: number, responseText: string, payload: Record | null): string { + const errorValue = payload?.error; + if (typeof errorValue === "string" && errorValue.trim()) { + return errorValue.trim(); + } + const errorObj = asRecord(errorValue); + return pickString(errorObj, ["message", "code"]) || "AllDebrid API error"; +} + function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { const seen = new Set(); const result: DebridProvider[] = []; @@ -219,8 +256,7 @@ export function extractRapidgatorFilenameFromHtml(html: string): string { /([^<]+)<\/title>/i, /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i, /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i, - /download\s+file\s+([^<\r\n]+)/i, - /([A-Za-z0-9][A-Za-z0-9._\-()[\] ]{2,220}\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub))/i + /download\s+file\s+([^<\r\n]+)/i ]; for (const pattern of patterns) { @@ -243,6 +279,7 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i } const size = Math.max(1, Math.min(concurrency, items.length)); let index = 0; + let firstError: unknown = null; const next = (): T | undefined => { if (index >= items.length) { return undefined; @@ -254,14 +291,30 @@ async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (i const runners = Array.from({ length: size }, async () => { let current = next(); while (current !== undefined) { - await worker(current); + try { + await worker(current); + } catch (error) { + if (!firstError) { + firstError = error; + } + } current = next(); } }); await Promise.all(runners); + if (firstError) { + throw firstError; + } } -async function resolveRapidgatorFilename(link: string): Promise<string> { +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + if (!signal) { + return AbortSignal.timeout(timeoutMs); + } + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + +async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> { if (!isRapidgatorLink(link)) { return ""; } @@ -270,6 +323,10 @@ async function resolveRapidgatorFilename(link: string): Promise<string> { return fromUrl; } + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) { try { const response = await fetch(link, { @@ -279,26 +336,44 @@ async function resolveRapidgatorFilename(link: string): Promise<string> { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9,de;q=0.8" }, - signal: AbortSignal.timeout(API_TIMEOUT_MS) + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) }); if (!response.ok) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); continue; } return ""; } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + if (contentType + && !contentType.includes("text/html") + && !contentType.includes("application/xhtml") + && !contentType.includes("text/plain") + && !contentType.includes("text/xml") + && !contentType.includes("application/xml")) { + return ""; + } + const html = await response.text(); const fromHtml = extractRapidgatorFilenameFromHtml(html); if (fromHtml) { return fromHtml; } - } catch { - // retry below + return ""; + } catch (error) { + const errorText = compactErrorText(error); + if (/aborted/i.test(errorText)) { + throw error; + } + if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) { + return ""; + } } if (attempt < REQUEST_RETRIES + 2) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); } } @@ -338,6 +413,9 @@ class MegaDebridClient { web.retriesUsed = attempt - 1; return web; } + if (web && !web.directUrl) { + throw new Error("Mega-Web Antwort ohne Download-Link"); + } if (!lastError) { lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer"; } @@ -376,7 +454,7 @@ class BestDebridClient { for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { try { const headers: Record<string, string> = { - "User-Agent": "RD-Node-Downloader/1.1.12" + "User-Agent": DEBRID_USER_AGENT }; if (request.useAuthHeader) { headers.Authorization = `Bearer ${this.token}`; @@ -402,6 +480,14 @@ class BestDebridClient { const directUrl = pickString(payload, ["download", "debridLink", "link"]); if (directUrl) { + try { + const parsedDirect = new URL(directUrl); + if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") { + throw new Error("invalid_protocol"); + } + } catch { + throw new Error("BestDebrid Antwort enthält ungültige Download-URL"); + } const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink); const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]); return { @@ -426,7 +512,7 @@ class BestDebridClient { await sleep(retryDelay(attempt)); } } - throw new Error(lastError || "BestDebrid Request fehlgeschlagen"); + throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, "")); } } @@ -473,7 +559,7 @@ class AllDebridClient { headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.15" + "User-Agent": DEBRID_USER_AGENT }, body, signal: AbortSignal.timeout(API_TIMEOUT_MS) @@ -501,8 +587,7 @@ class AllDebridClient { const status = pickString(payload, ["status"]); if (status && status.toLowerCase() === "error") { - const errorObj = asRecord(payload?.error); - throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error"); + throw new Error(parseAllDebridError(payload)); } chunkResolved = true; @@ -534,7 +619,9 @@ class AllDebridClient { const responseLink = pickString(info, ["link"]); const byResponse = canonicalToInput.get(canonicalLink(responseLink)); - const byIndex = chunk.length === 1 ? chunk[0] : ""; + const byIndex = chunk.length === 1 + ? chunk[0] + : ""; const original = byResponse || byIndex; if (!original) { continue; @@ -555,7 +642,7 @@ class AllDebridClient { headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.12" + "User-Agent": DEBRID_USER_AGENT }, body: new URLSearchParams({ link }), signal: AbortSignal.timeout(API_TIMEOUT_MS) @@ -577,11 +664,13 @@ class AllDebridClient { if (looksHtml) { throw new Error("AllDebrid lieferte HTML statt JSON"); } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } const status = pickString(payload, ["status"]); if (status && status.toLowerCase() === "error") { - const errorObj = asRecord(payload?.error); - throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error"); + throw new Error(parseAllDebridError(payload)); } const data = asRecord(payload?.data); @@ -612,29 +701,24 @@ class AllDebridClient { export class DebridService { private settings: AppSettings; - private realDebridClient: RealDebridClient; - - private allDebridClient: AllDebridClient; - private options: DebridServiceOptions; public constructor(settings: AppSettings, options: DebridServiceOptions = {}) { this.settings = settings; this.options = options; - this.realDebridClient = new RealDebridClient(settings.token); - this.allDebridClient = new AllDebridClient(settings.allDebridToken); } public setSettings(next: AppSettings): void { this.settings = next; - this.realDebridClient = new RealDebridClient(next.token); - this.allDebridClient = new AllDebridClient(next.allDebridToken); } public async resolveFilenames( links: string[], - onResolved?: (link: string, fileName: string) => void + onResolved?: (link: string, fileName: string) => void, + signal?: AbortSignal ): Promise<Map<string, string>> { + const settings = { ...this.settings }; + const allDebridClient = new AllDebridClient(settings.allDebridToken); const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link))); if (unresolved.length === 0) { return new Map<string, string>(); @@ -653,10 +737,10 @@ export class DebridService { onResolved?.(link, normalized); }; - const token = this.settings.allDebridToken.trim(); + const token = settings.allDebridToken.trim(); if (token) { try { - const infos = await this.allDebridClient.getLinkInfos(unresolved); + const infos = await allDebridClient.getLinkInfos(unresolved); for (const [link, fileName] of infos.entries()) { reportResolved(link, fileName); } @@ -667,14 +751,14 @@ export class DebridService { const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link)); await runWithConcurrency(remaining, 6, async (link) => { - const fromPage = await resolveRapidgatorFilename(link); + const fromPage = await resolveRapidgatorFilename(link, signal); reportResolved(link, fromPage); }); const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link)); await runWithConcurrency(stillUnresolved, 4, async (link) => { try { - const unrestricted = await this.unrestrictLink(link); + const unrestricted = await this.unrestrictLink(link, signal, settings); reportResolved(link, unrestricted.fileName || ""); } catch { // ignore final fallback errors @@ -684,23 +768,24 @@ export class DebridService { return clean; } - public async unrestrictLink(link: string): Promise<ProviderUnrestrictedLink> { + public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> { + const settings = settingsSnapshot ? { ...settingsSnapshot } : { ...this.settings }; const order = toProviderOrder( - this.settings.providerPrimary, - this.settings.providerSecondary, - this.settings.providerTertiary + settings.providerPrimary, + settings.providerSecondary, + settings.providerTertiary ); const primary = order[0]; - if (!this.settings.autoProviderFallback) { - if (!this.isProviderConfigured(primary)) { + if (!settings.autoProviderFallback) { + if (!this.isProviderConfiguredFor(settings, primary)) { throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`); } try { - const result = await this.unrestrictViaProvider(primary, link); + const result = await this.unrestrictViaProvider(settings, primary, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { - const fromPage = await resolveRapidgatorFilename(link); + const fromPage = await resolveRapidgatorFilename(link, signal); if (fromPage) { fileName = fromPage; } @@ -720,16 +805,16 @@ export class DebridService { const attempts: string[] = []; for (const provider of order) { - if (!this.isProviderConfigured(provider)) { + if (!this.isProviderConfiguredFor(settings, provider)) { continue; } configuredFound = true; try { - const result = await this.unrestrictViaProvider(provider, link); + const result = await this.unrestrictViaProvider(settings, provider, link, signal); let fileName = result.fileName; if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { - const fromPage = await resolveRapidgatorFilename(link); + const fromPage = await resolveRapidgatorFilename(link, signal); if (fromPage) { fileName = fromPage; } @@ -752,29 +837,29 @@ export class DebridService { throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`); } - private isProviderConfigured(provider: DebridProvider): boolean { + private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean { if (provider === "realdebrid") { - return Boolean(this.settings.token.trim()); + return Boolean(settings.token.trim()); } if (provider === "megadebrid") { - return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim() && this.options.megaWebUnrestrict); + return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim() && this.options.megaWebUnrestrict); } if (provider === "alldebrid") { - return Boolean(this.settings.allDebridToken.trim()); + return Boolean(settings.allDebridToken.trim()); } - return Boolean(this.settings.bestToken.trim()); + return Boolean(settings.bestToken.trim()); } - private async unrestrictViaProvider(provider: DebridProvider, link: string): Promise<UnrestrictedLink> { + private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { if (provider === "realdebrid") { - return this.realDebridClient.unrestrictLink(link); + return new RealDebridClient(settings.token).unrestrictLink(link, signal); } if (provider === "megadebrid") { return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link); } if (provider === "alldebrid") { - return this.allDebridClient.unrestrictLink(link); + return new AllDebridClient(settings.allDebridToken).unrestrictLink(link); } - return new BestDebridClient(this.settings.bestToken).unrestrictLink(link); + return new BestDebridClient(settings.bestToken).unrestrictLink(link); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index e33df9e..d548696 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -54,7 +54,7 @@ function getDownloadStallTimeoutMs(): number { function getDownloadConnectTimeoutMs(): number { const fromEnv = Number(process.env.RD_CONNECT_TIMEOUT_MS ?? NaN); - if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 180000) { + if (Number.isFinite(fromEnv) && fromEnv >= 250 && fromEnv <= 180000) { return Math.floor(fromEnv); } return DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS; @@ -103,6 +103,13 @@ function cloneSession(session: SessionState): SessionState { }; } +function cloneSettings(settings: AppSettings): AppSettings { + return { + ...settings, + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })) + }; +} + function parseContentRangeTotal(contentRange: string | null): number | null { if (!contentRange) { return null; @@ -123,7 +130,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str const encodedMatch = contentDisposition.match(/filename\*\s*=\s*([^;]+)/i); if (encodedMatch?.[1]) { let value = encodedMatch[1].trim(); - value = value.replace(/^UTF-8''/i, ""); + value = value.replace(/^[A-Za-z0-9._-]+(?:'[^']*)?'/, ""); value = value.replace(/^['"]+|['"]+$/g, ""); try { const decoded = decodeURIComponent(value).trim(); @@ -144,13 +151,9 @@ function parseContentDispositionFilename(contentDisposition: string | null): str return plainMatch[1].trim().replace(/^['"]+|['"]+$/g, ""); } -function canRetryStatus(status: number): boolean { - return status === 429 || status >= 500; -} - function isArchiveLikePath(filePath: string): boolean { const lower = path.basename(filePath).toLowerCase(); - return /\.(?:part\d+\.rar|rar|r\d{2}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower); + return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower); } function isFetchFailure(errorText: string): boolean { @@ -259,7 +262,7 @@ export function ensureRepackToken(baseName: string): string { return baseName; } - const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, ".REPACK.$2"); + const withQualityToken = baseName.replace(SCENE_QUALITY_TOKEN_RE, "$1REPACK.$2"); if (withQualityToken !== baseName) { return withQualityToken; } @@ -357,6 +360,8 @@ export class DownloadManager extends EventEmitter { private lastGlobalProgressAt = 0; + private retryAfterByItem = new Map<string, number>(); + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; @@ -428,10 +433,16 @@ export class DownloadManager extends EventEmitter { const reconnectMs = Math.max(0, this.session.reconnectUntil - now); + const snapshotSession = cloneSession(this.session); + const snapshotSettings = cloneSettings(this.settings); + const snapshotSummary = this.summary + ? { ...this.summary } + : null; + return { - settings: this.settings, - session: this.session, - summary: this.summary, + settings: snapshotSettings, + session: snapshotSession, + summary: snapshotSummary, stats: this.getStats(now), speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, etaText: paused ? "ETA: --" : `ETA: ${formatEta(eta)}`, @@ -494,7 +505,14 @@ export class DownloadManager extends EventEmitter { } public reorderPackages(packageIds: string[]): void { - const valid = packageIds.filter((id) => this.session.packages[id]); + const seen = new Set<string>(); + const valid = packageIds.filter((id) => { + if (!this.session.packages[id] || seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); const remaining = this.session.packageOrder.filter((id) => !valid.includes(id)); this.session.packageOrder = [...valid, ...remaining]; this.persistSoon(); @@ -508,6 +526,7 @@ export class DownloadManager extends EventEmitter { } this.recordRunOutcome(itemId, "cancelled"); const active = this.activeTasks.get(itemId); + const hasActiveTask = Boolean(active); if (active) { active.abortReason = "cancel"; active.abortController.abort("cancel"); @@ -523,7 +542,10 @@ export class DownloadManager extends EventEmitter { } delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); - this.releaseTargetPath(itemId); + this.retryAfterByItem.delete(itemId); + if (!hasActiveTask) { + this.releaseTargetPath(itemId); + } this.persistSoon(); this.emitState(true); } @@ -631,12 +653,21 @@ export class DownloadManager extends EventEmitter { return { addedPackages: 0, addedLinks: 0 }; } const inputs: ParsedPackageInput[] = data.packages - .filter((pkg) => pkg.name && Array.isArray(pkg.links) && pkg.links.length > 0) - .map((pkg) => ({ name: pkg.name, links: pkg.links })); + .map((pkg) => { + const name = typeof pkg?.name === "string" ? pkg.name : ""; + const linksRaw = Array.isArray(pkg?.links) ? pkg.links : []; + const links = linksRaw + .filter((link) => typeof link === "string") + .map((link) => link.trim()) + .filter(Boolean); + return { name, links }; + }) + .filter((pkg) => pkg.name.trim().length > 0 && pkg.links.length > 0); return this.addPackages(inputs); } public clearAll(): void { + this.clearPersistTimer(); this.stop(); this.abortPostProcessing("clear_all"); if (this.stateEmitTimer) { @@ -652,6 +683,7 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); @@ -663,6 +695,8 @@ export class DownloadManager extends EventEmitter { this.hybridExtractRequeue.clear(); this.packagePostProcessQueue = Promise.resolve(); this.summary = null; + this.nonResumableActive = 0; + this.retryAfterByItem.clear(); this.persistNow(); this.emitState(true); } @@ -804,9 +838,16 @@ export class DownloadManager extends EventEmitter { this.runItemIds.delete(itemId); this.runOutcomes.delete(itemId); this.itemContributedBytes.delete(itemId); + this.retryAfterByItem.delete(itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + if (postProcessController && !postProcessController.signal.aborted) { + postProcessController.abort("cancel"); + } + this.packagePostProcessAbortControllers.delete(packageId); + this.packagePostProcessTasks.delete(packageId); delete this.session.packages[packageId]; this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); this.runPackageIds.delete(packageId); @@ -818,6 +859,12 @@ export class DownloadManager extends EventEmitter { } if (policy === "overwrite") { + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + if (postProcessController && !postProcessController.signal.aborted) { + postProcessController.abort("overwrite"); + } + this.packagePostProcessAbortControllers.delete(packageId); + this.packagePostProcessTasks.delete(packageId); const canDeleteExtractDir = this.isPackageSpecificExtractDir(pkg) && !this.isExtractDirSharedWithOtherPackages(pkg.id, pkg.extractDir); if (canDeleteExtractDir) { try { @@ -857,10 +904,12 @@ export class DownloadManager extends EventEmitter { item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))); this.runOutcomes.delete(itemId); this.itemContributedBytes.delete(itemId); + this.retryAfterByItem.delete(itemId); if (this.session.running) { this.runItemIds.add(itemId); } } + this.runCompletedPackages.delete(packageId); pkg.status = "queued"; pkg.updatedAt = nowMs(); this.persistSoon(); @@ -1257,6 +1306,11 @@ export class DownloadManager extends EventEmitter { } } + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + if (postProcessController && !postProcessController.signal.aborted) { + postProcessController.abort("cancel"); + } + this.removePackageFromSession(packageId, itemIds); this.persistSoon(); this.emitState(true); @@ -1297,6 +1351,7 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.session.running = false; @@ -1312,6 +1367,7 @@ export class DownloadManager extends EventEmitter { this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.summary = null; + this.nonResumableActive = 0; this.persistSoon(); this.emitState(true); return; @@ -1320,6 +1376,7 @@ export class DownloadManager extends EventEmitter { this.runPackageIds = new Set(runItems.map((item) => item.packageId)); this.runOutcomes.clear(); this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); this.session.running = true; this.session.paused = false; @@ -1340,6 +1397,7 @@ export class DownloadManager extends EventEmitter { this.globalSpeedLimitQueue = Promise.resolve(); this.globalSpeedLimitNextAt = 0; this.summary = null; + this.nonResumableActive = 0; this.persistSoon(); this.emitState(true); void this.ensureScheduler().catch((error) => { @@ -1356,6 +1414,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.retryAfterByItem.clear(); this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.abortPostProcessing("stop"); @@ -1369,6 +1428,7 @@ export class DownloadManager extends EventEmitter { public prepareForShutdown(): void { logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); + this.clearPersistTimer(); this.session.running = false; this.session.paused = false; this.session.reconnectUntil = 0; @@ -1423,6 +1483,8 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.clear(); this.runOutcomes.clear(); this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.nonResumableActive = 0; this.session.summaryText = ""; this.persistNow(); this.emitState(true); @@ -1506,8 +1568,8 @@ export class DownloadManager extends EventEmitter { if (failed > 0) { pkg.status = "failed"; - } else if (cancelled > 0 && success === 0) { - pkg.status = "cancelled"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "failed" : "cancelled"; } else if (success > 0) { pkg.status = "completed"; } @@ -1543,6 +1605,14 @@ export class DownloadManager extends EventEmitter { } } + private clearPersistTimer(): void { + if (!this.persistTimer) { + return; + } + clearTimeout(this.persistTimer); + this.persistTimer = null; + } + private persistSoon(): void { if (this.persistTimer) { return; @@ -1658,10 +1728,6 @@ export class DownloadManager extends EventEmitter { const parsed = path.parse(preferredPath); const preferredKey = pathKey(preferredPath); - const baseDirKey = process.platform === "win32" ? parsed.dir.toLowerCase() : parsed.dir; - const baseNameKey = process.platform === "win32" ? parsed.name.toLowerCase() : parsed.name; - const baseExtKey = process.platform === "win32" ? parsed.ext.toLowerCase() : parsed.ext; - const sep = path.sep; const maxIndex = 10000; for (let index = 0; index <= maxIndex; index += 1) { const candidate = index === 0 @@ -1669,7 +1735,7 @@ export class DownloadManager extends EventEmitter { : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); const key = index === 0 ? preferredKey - : `${baseDirKey}${sep}${baseNameKey} (${index})${baseExtKey}`; + : pathKey(candidate); const owner = this.reservedTargetPaths.get(key); const existsOnDisk = fs.existsSync(candidate); const allowExistingCandidate = allowExistingFile && index === 0; @@ -1809,7 +1875,11 @@ export class DownloadManager extends EventEmitter { continue; } - const targetStatus = failed > 0 ? "failed" : cancelled > 0 && success === 0 ? "cancelled" : "completed"; + const targetStatus = failed > 0 + ? "failed" + : cancelled > 0 + ? (success > 0 ? "failed" : "cancelled") + : "completed"; if (pkg.status !== targetStatus) { pkg.status = targetStatus; pkg.updatedAt = nowMs(); @@ -1865,7 +1935,14 @@ export class DownloadManager extends EventEmitter { } private removePackageFromSession(packageId: string, itemIds: string[]): void { + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + if (postProcessController && !postProcessController.signal.aborted) { + postProcessController.abort("package_removed"); + } + this.packagePostProcessAbortControllers.delete(packageId); + this.packagePostProcessTasks.delete(packageId); for (const itemId of itemIds) { + this.retryAfterByItem.delete(itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } @@ -1918,7 +1995,7 @@ export class DownloadManager extends EventEmitter { this.runGlobalStallWatchdog(now); - if (this.activeTasks.size === 0 && !this.hasQueuedItems() && this.packagePostProcessTasks.size === 0) { + if (this.activeTasks.size === 0 && !this.hasQueuedItems() && !this.hasDelayedQueuedItems() && this.packagePostProcessTasks.size === 0) { this.finishRun(); break; } @@ -2031,7 +2108,8 @@ export class DownloadManager extends EventEmitter { private markQueuedAsReconnectWait(): boolean { let changed = false; - const waitText = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`; + const waitSeconds = Math.max(0, Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)); + const waitText = `Reconnect-Wait (${waitSeconds}s)`; const itemIds = this.runItemIds.size > 0 ? this.runItemIds : Object.keys(this.session.items); for (const itemId of itemIds) { const item = this.session.items[itemId]; @@ -2056,6 +2134,7 @@ export class DownloadManager extends EventEmitter { } private findNextQueuedItem(): { packageId: string; itemId: string } | null { + const now = nowMs(); for (const packageId of this.session.packageOrder) { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) { @@ -2066,6 +2145,13 @@ export class DownloadManager extends EventEmitter { if (!item) { continue; } + const retryAfter = this.retryAfterByItem.get(itemId) || 0; + if (retryAfter > now) { + continue; + } + if (retryAfter > 0) { + this.retryAfterByItem.delete(itemId); + } if (item.status === "queued" || item.status === "reconnect_wait") { return { packageId, itemId }; } @@ -2078,6 +2164,28 @@ export class DownloadManager extends EventEmitter { return this.findNextQueuedItem() !== null; } + private hasDelayedQueuedItems(): boolean { + const now = nowMs(); + for (const [itemId, readyAt] of this.retryAfterByItem.entries()) { + if (readyAt <= now) { + continue; + } + const item = this.session.items[itemId]; + if (!item) { + continue; + } + if (item.status !== "queued" && item.status !== "reconnect_wait") { + continue; + } + const pkg = this.session.packages[item.packageId]; + if (!pkg || pkg.cancelled || !pkg.enabled) { + continue; + } + return true; + } + return false; + } + private countQueuedItems(): number { let count = 0; for (const packageId of this.session.packageOrder) { @@ -2098,6 +2206,18 @@ export class DownloadManager extends EventEmitter { return count; } + private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void { + const waitMs = Math.max(0, Math.floor(delayMs)); + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = statusText; + item.updatedAt = nowMs(); + item.attempts = 0; + active.abortController = new AbortController(); + active.abortReason = "none"; + this.retryAfterByItem.set(item.id, nowMs() + waitMs); + } + private startItem(packageId: string, itemId: string): void { const item = this.session.items[itemId]; const pkg = this.session.packages[packageId]; @@ -2108,6 +2228,8 @@ export class DownloadManager extends EventEmitter { return; } + this.retryAfterByItem.delete(itemId); + item.status = "validating"; item.fullStatus = "Link wird umgewandelt"; item.updatedAt = nowMs(); @@ -2154,7 +2276,7 @@ export class DownloadManager extends EventEmitter { const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES); while (true) { try { - const unrestricted = await this.debridService.unrestrictLink(item.url); + const unrestricted = await this.debridService.unrestrictLink(item.url, active.abortController.signal); if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } @@ -2191,6 +2313,10 @@ export class DownloadManager extends EventEmitter { this.nonResumableActive += 1; } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + if (this.settings.enableIntegrityCheck) { item.status = "integrity_check"; item.fullStatus = "CRC-Check läuft"; @@ -2198,6 +2324,9 @@ export class DownloadManager extends EventEmitter { this.emitState(); const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } if (!validation.ok) { item.lastError = validation.message; item.fullStatus = `${validation.message}, Neuversuch`; @@ -2207,7 +2336,7 @@ export class DownloadManager extends EventEmitter { // ignore } if (item.attempts < maxAttempts) { - item.status = "queued"; + item.status = "integrity_check"; item.progressPercent = 0; item.downloadedBytes = 0; item.totalBytes = unrestricted.fileSize; @@ -2219,6 +2348,10 @@ export class DownloadManager extends EventEmitter { } } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + const finalTargetPath = String(item.targetPath || "").trim(); const fileSizeOnDisk = finalTargetPath && fs.existsSync(finalTargetPath) ? fs.statSync(finalTargetPath).size @@ -2241,6 +2374,11 @@ export class DownloadManager extends EventEmitter { done = true; } + + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + item.status = "completed"; item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`; item.progressPercent = 100; @@ -2249,13 +2387,15 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); this.recordRunOutcome(item.id, "completed"); - void this.runPackagePostProcessing(pkg.id).catch((err) => { - logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`); - }).finally(() => { - this.applyCompletedCleanupPolicy(pkg.id, item.id); - this.persistSoon(); - this.emitState(); - }); + if (this.session.running && !active.abortController.signal.aborted) { + void this.runPackagePostProcessing(pkg.id).catch((err) => { + logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`); + }).finally(() => { + this.applyCompletedCleanupPolicy(pkg.id, item.id); + this.persistSoon(); + this.emitState(); + }); + } this.persistSoon(); this.emitState(); return; @@ -2280,16 +2420,11 @@ export class DownloadManager extends EventEmitter { item.status = "cancelled"; item.fullStatus = "Gestoppt"; this.recordRunOutcome(item.id, "cancelled"); - if (claimedTargetPath) { - try { - fs.rmSync(claimedTargetPath, { force: true }); - } catch { - // ignore - } + if (!active.resumable && claimedTargetPath && !fs.existsSync(claimedTargetPath)) { + item.downloadedBytes = 0; + item.progressPercent = 0; + item.totalBytes = null; } - item.downloadedBytes = 0; - item.progressPercent = 0; - item.totalBytes = null; } else if (reason === "shutdown") { item.status = "queued"; item.speedBps = 0; @@ -2297,6 +2432,7 @@ export class DownloadManager extends EventEmitter { item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet"; } else if (reason === "reconnect") { item.status = "queued"; + item.speedBps = 0; item.fullStatus = "Wartet auf Reconnect"; } else if (reason === "package_toggle") { item.status = "queued"; @@ -2306,18 +2442,11 @@ export class DownloadManager extends EventEmitter { stallRetries += 1; if (stallRetries <= 2) { item.retries += 1; - item.status = "queued"; - item.speedBps = 0; - item.fullStatus = `Keine Daten empfangen, Retry ${stallRetries}/2`; + this.queueRetry(item, active, 350 * stallRetries, `Keine Daten empfangen, Retry ${stallRetries}/2`); item.lastError = ""; - item.attempts = 0; - item.updatedAt = nowMs(); - active.abortController = new AbortController(); - active.abortReason = "none"; this.persistSoon(); this.emitState(); - await sleep(350 * stallRetries); - continue; + return; } item.status = "failed"; item.lastError = "Download hing wiederholt"; @@ -2337,6 +2466,15 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; + item.status = "failed"; + this.recordRunOutcome(item.id, "failed"); + item.lastError = errorText; + item.fullStatus = `Fehler: ${item.lastError}`; + item.speedBps = 0; + item.updatedAt = nowMs(); + this.persistSoon(); + this.emitState(); + return; } if (shouldFreshRetry) { freshRetryUsed = true; @@ -2347,53 +2485,34 @@ export class DownloadManager extends EventEmitter { // ignore } this.releaseTargetPath(item.id); - item.status = "queued"; - item.fullStatus = "Netzwerkfehler erkannt, frischer Retry"; + this.queueRetry(item, active, 450, "Netzwerkfehler erkannt, frischer Retry"); item.lastError = ""; - item.attempts = 0; item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; - item.speedBps = 0; - item.updatedAt = nowMs(); this.persistSoon(); this.emitState(); - await sleep(450); - continue; + return; } if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) { unrestrictRetries += 1; item.retries += 1; - item.status = "queued"; - item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`; + this.queueRetry(item, active, Math.min(8000, 2000 * unrestrictRetries), `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`); item.lastError = errorText; - item.attempts = 0; - item.speedBps = 0; - item.updatedAt = nowMs(); - active.abortController = new AbortController(); - active.abortReason = "none"; this.persistSoon(); this.emitState(); - await sleep(Math.min(8000, 2000 * unrestrictRetries)); - continue; + return; } if (genericErrorRetries < maxGenericErrorRetries) { genericErrorRetries += 1; item.retries += 1; - item.status = "queued"; - item.fullStatus = `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`; + this.queueRetry(item, active, Math.min(1200, 300 * genericErrorRetries), `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`); item.lastError = errorText; - item.attempts = 0; - item.speedBps = 0; - item.updatedAt = nowMs(); - active.abortController = new AbortController(); - active.abortReason = "none"; this.persistSoon(); this.emitState(); - await sleep(Math.min(1200, 300 * genericErrorRetries)); - continue; + return; } item.status = "failed"; @@ -2482,6 +2601,7 @@ export class DownloadManager extends EventEmitter { if (!response.ok) { if (response.status === 416 && existingBytes > 0) { + await response.arrayBuffer().catch(() => undefined); const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal; if (expectedTotal && existingBytes === expectedTotal) { @@ -2654,6 +2774,7 @@ export class DownloadManager extends EventEmitter { active.abortController.signal.addEventListener("abort", onAbort, { once: true }); }); + let bodyError: unknown = null; try { const body = response.body; if (!body) { @@ -2744,7 +2865,7 @@ export class DownloadManager extends EventEmitter { } const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); - await this.applySpeedLimit(buffer.length, windowBytes, windowStarted); + await this.applySpeedLimit(buffer.length, windowBytes, windowStarted, active.abortController.signal); if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } @@ -2778,29 +2899,44 @@ export class DownloadManager extends EventEmitter { } } finally { clearInterval(idleTimer); - } - } finally { - await new Promise<void>((resolve, reject) => { - if (stream.closed || stream.destroyed) { - resolve(); - return; + try { + reader.releaseLock(); + } catch { + // ignore } - const onDone = (): void => { - stream.off("error", onError); - stream.off("finish", onDone); - stream.off("close", onDone); - resolve(); - }; - const onError = (streamError: Error): void => { - stream.off("finish", onDone); - stream.off("close", onDone); - reject(streamError); - }; - stream.once("finish", onDone); - stream.once("close", onDone); - stream.once("error", onError); - stream.end(); - }); + } + } catch (error) { + bodyError = error; + throw error; + } finally { + try { + await new Promise<void>((resolve, reject) => { + if (stream.closed || stream.destroyed) { + resolve(); + return; + } + const onDone = (): void => { + stream.off("error", onError); + stream.off("finish", onDone); + stream.off("close", onDone); + resolve(); + }; + const onError = (streamError: Error): void => { + stream.off("finish", onDone); + stream.off("close", onDone); + reject(streamError); + }; + stream.once("finish", onDone); + stream.once("close", onDone); + stream.once("error", onError); + stream.end(); + }); + } catch (streamCloseError) { + if (!bodyError) { + throw streamCloseError; + } + logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`); + } } item.downloadedBytes = written; @@ -2970,8 +3106,8 @@ export class DownloadManager extends EventEmitter { if (failed > 0) { pkg.status = "failed"; - } else if (cancelled > 0 && success === 0) { - pkg.status = "cancelled"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "failed" : "cancelled"; } else if (success > 0) { pkg.status = "completed"; } @@ -2999,6 +3135,10 @@ export class DownloadManager extends EventEmitter { if (!entry.enabled) { continue; } + if (entry.startHour === entry.endHour) { + this.cachedSpeedLimitKbps = entry.speedLimitKbps; + return this.cachedSpeedLimitKbps; + } const wraps = entry.startHour > entry.endHour; const inRange = wraps ? hour >= entry.startHour || hour < entry.endHour @@ -3017,14 +3157,46 @@ export class DownloadManager extends EventEmitter { return 0; } - private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise<void> { + private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number, signal?: AbortSignal): Promise<void> { const task = this.globalSpeedLimitQueue .catch(() => undefined) .then(async () => { + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); + } const now = nowMs(); const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); if (waitMs > 0) { - await sleep(waitMs); + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(); + }, waitMs); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + } + + if (signal?.aborted) { + throw new Error("aborted:speed_limit"); } const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); @@ -3036,7 +3208,7 @@ export class DownloadManager extends EventEmitter { await task; } - private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> { + private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number, signal?: AbortSignal): Promise<void> { const limitKbps = this.getEffectiveSpeedLimitKbps(); if (limitKbps <= 0) { return; @@ -3050,13 +3222,38 @@ export class DownloadManager extends EventEmitter { if (projected > allowed) { const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000); if (sleepMs > 0) { - await sleep(Math.min(300, sleepMs)); + await new Promise<void>((resolve, reject) => { + let timer: NodeJS.Timeout | null = setTimeout(() => { + timer = null; + if (signal) { + signal.removeEventListener("abort", onAbort); + } + resolve(); + }, Math.min(300, sleepMs)); + + const onAbort = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + signal?.removeEventListener("abort", onAbort); + reject(new Error("aborted:speed_limit")); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + }); } } return; } - await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond); + await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond, signal); } private findReadyArchiveSets(pkg: PackageEntry): Set<string> { @@ -3125,7 +3322,7 @@ export class DownloadManager extends EventEmitter { if (/\.rar$/i.test(entryPointName) && !/\.part\d+\.rar$/i.test(entryPointName)) { const stem = entryPointName.replace(/\.rar$/i, "").toLowerCase(); const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped}\\.r(ar|\\d{2})$`, "i").test(fileName); + return new RegExp(`^${escaped}\\.r(ar|\\d{2,3})$`, "i").test(fileName); } if (/\.zip\.001$/i.test(entryPointName)) { const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase(); @@ -3323,6 +3520,9 @@ export class DownloadManager extends EventEmitter { } } const extractDeadline = setTimeout(() => { + if (signal?.aborted || extractAbortController.signal.aborted) { + return; + } timedOut = true; logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`); if (!extractAbortController.signal.aborted) { @@ -3432,8 +3632,8 @@ export class DownloadManager extends EventEmitter { } } else if (failed > 0) { pkg.status = "failed"; - } else if (cancelled > 0 && success === 0) { - pkg.status = "cancelled"; + } else if (cancelled > 0) { + pkg.status = success > 0 ? "failed" : "cancelled"; } else { pkg.status = "completed"; } @@ -3483,9 +3683,17 @@ export class DownloadManager extends EventEmitter { } if (policy === "immediate") { + if (this.settings.autoExtract) { + const item = this.session.items[itemId]; + const extracted = item ? isExtractedLabel(item.fullStatus || "") : false; + if (!extracted) { + return; + } + } pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); + this.retryAfterByItem.delete(itemId); if (pkg.itemIds.length === 0) { this.removePackageFromSession(packageId, []); } @@ -3536,6 +3744,7 @@ export class DownloadManager extends EventEmitter { this.speedBytesLastWindow = 0; this.globalSpeedLimitQueue = Promise.resolve(); this.globalSpeedLimitNextAt = 0; + this.nonResumableActive = 0; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.persistNow(); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ec4d2f0..570bbba 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -319,19 +319,17 @@ function winRarCandidates(): string[] { const installed = [ path.join(programFiles, "WinRAR", "UnRAR.exe"), - path.join(programFiles, "WinRAR", "WinRAR.exe"), path.join(programFilesX86, "WinRAR", "UnRAR.exe"), - path.join(programFilesX86, "WinRAR", "WinRAR.exe") + path.join(programFilesX86, "WinRAR", "UnRAR.exe") ]; if (localAppData) { installed.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe")); - installed.push(path.join(localAppData, "Programs", "WinRAR", "WinRAR.exe")); } const ordered = resolvedExtractorCommand - ? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"] - : [...installed, "UnRAR.exe", "WinRAR.exe", "unrar", "winrar"]; + ? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "unrar"] + : [...installed, "UnRAR.exe", "unrar"]; return Array.from(new Set(ordered.filter(Boolean))); } @@ -378,6 +376,47 @@ type ExtractSpawnResult = { errorText: string; }; +function killProcessTree(child: { pid?: number; kill: () => void }): void { + const pid = Number(child.pid || 0); + if (!Number.isFinite(pid) || pid <= 0) { + try { + child.kill(); + } catch { + // ignore + } + return; + } + + if (process.platform === "win32") { + try { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore" + }); + killer.on("error", () => { + try { + child.kill(); + } catch { + // ignore + } + }); + } catch { + try { + child.kill(); + } catch { + // ignore + } + } + return; + } + + try { + child.kill(); + } catch { + // ignore + } +} + function runExtractCommand( command: string, args: string[], @@ -394,6 +433,8 @@ function runExtractCommand( let output = ""; const child = spawn(command, args, { windowsHide: true }); let timeoutId: NodeJS.Timeout | null = null; + let timedOutByWatchdog = false; + let abortedBySignal = false; const finish = (result: ExtractSpawnResult): void => { if (settled) { @@ -412,11 +453,8 @@ function runExtractCommand( if (timeoutMs && timeoutMs > 0) { timeoutId = setTimeout(() => { - try { - child.kill(); - } catch { - // ignore - } + timedOutByWatchdog = true; + killProcessTree(child); finish({ ok: false, missingCommand: false, @@ -429,11 +467,8 @@ function runExtractCommand( const onAbort = signal ? (): void => { - try { - child.kill(); - } catch { - // ignore - } + abortedBySignal = true; + killProcessTree(child); finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); } : null; @@ -464,6 +499,20 @@ function runExtractCommand( }); child.on("close", (code) => { + if (abortedBySignal) { + finish({ ok: false, missingCommand: false, aborted: true, timedOut: false, errorText: "aborted:extract" }); + return; + } + if (timedOutByWatchdog) { + finish({ + ok: false, + missingCommand: false, + aborted: false, + timedOut: true, + errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s` + }); + return; + } if (code === 0) { finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); return; @@ -543,7 +592,7 @@ async function resolveExtractorCommandInternal(): Promise<string> { } const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"]; const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); - if (!probe.missingCommand) { + if (probe.ok) { resolvedExtractorCommand = command; resolveFailureReason = ""; resolveFailureAt = 0; @@ -680,17 +729,20 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: const zip = new AdmZip(archivePath); const entries = zip.getEntries(); const resolvedTarget = path.resolve(targetDir); + const usedOutputs = new Set<string>(); + const renameCounters = new Map<string, number>(); + for (const entry of entries) { if (signal?.aborted) { throw new Error("aborted:extract"); } - const outputPath = path.resolve(targetDir, entry.entryName); - if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) { + const baseOutputPath = path.resolve(targetDir, entry.entryName); + if (!baseOutputPath.startsWith(resolvedTarget + path.sep) && baseOutputPath !== resolvedTarget) { logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); continue; } if (entry.isDirectory) { - fs.mkdirSync(outputPath, { recursive: true }); + fs.mkdirSync(baseOutputPath, { recursive: true }); continue; } @@ -708,52 +760,76 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: }).header; const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN); const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN); - const crc = Number(header?.crc ?? header?.dataHeader?.crc ?? 0); - if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) { + if (!Number.isFinite(uncompressedSize) || uncompressedSize < 0) { + throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); + } + if (!Number.isFinite(compressedSize) || compressedSize < 0) { + throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); + } + + if (uncompressedSize > memoryLimitBytes) { const entryMb = Math.ceil(uncompressedSize / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } - if (Number.isFinite(compressedSize) && compressedSize > memoryLimitBytes) { + if (compressedSize > memoryLimitBytes) { const entryMb = Math.ceil(compressedSize / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } - if ((!Number.isFinite(uncompressedSize) || uncompressedSize <= 0) - && Number.isFinite(compressedSize) - && compressedSize > 0 - && crc !== 0) { - throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); - } + + let outputPath = baseOutputPath; + let outputKey = pathSetKey(outputPath); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // TOCTOU note: There is a small race between existsSync and writeFileSync below. // This is acceptable here because zip extraction is single-threaded and we need // the exists check to implement skip/rename conflict resolution semantics. - if (fs.existsSync(outputPath)) { + if (usedOutputs.has(outputKey) || fs.existsSync(outputPath)) { if (mode === "skip") { continue; } if (mode === "rename") { - const parsed = path.parse(outputPath); - let n = 1; - let candidate = outputPath; - while (fs.existsSync(candidate)) { + const parsed = path.parse(baseOutputPath); + const counterKey = pathSetKey(baseOutputPath); + let n = renameCounters.get(counterKey) || 1; + let candidate = baseOutputPath; + let candidateKey = outputKey; + while (n <= 10000) { candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`); + candidateKey = pathSetKey(candidate); + if (!usedOutputs.has(candidateKey) && !fs.existsSync(candidate)) { + break; + } n += 1; } + if (n > 10000) { + throw new Error(`ZIP-Rename-Limit erreicht für ${entry.entryName}`); + } + renameCounters.set(counterKey, n + 1); if (signal?.aborted) { throw new Error("aborted:extract"); } - fs.writeFileSync(candidate, entry.getData()); - continue; + outputPath = candidate; + outputKey = candidateKey; } } + if (signal?.aborted) { throw new Error("aborted:extract"); } - fs.writeFileSync(outputPath, entry.getData()); + const data = entry.getData(); + if (data.length > memoryLimitBytes) { + const entryMb = Math.ceil(data.length / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + if (data.length > Math.max(uncompressedSize, compressedSize) * 20) { + throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`); + } + fs.writeFileSync(outputPath, data); + usedOutputs.add(outputKey); } } @@ -795,7 +871,7 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director if (/\.rar$/i.test(fileName)) { const stem = escapeRegex(fileName.replace(/\.rar$/i, "")); addMatching(new RegExp(`^${stem}\\.rar$`, "i")); - addMatching(new RegExp(`^${stem}\\.r\\d{2}$`, "i")); + addMatching(new RegExp(`^${stem}\\.r\\d{2,3}$`, "i")); return Array.from(targets); } @@ -859,11 +935,39 @@ function cleanupArchives(sourceFiles: string[], cleanupMode: CleanupMode): numbe } let removed = 0; + + const moveToTrashLike = (filePath: string): boolean => { + try { + const parsed = path.parse(filePath); + const trashDir = path.join(parsed.dir, ".rd-trash"); + fs.mkdirSync(trashDir, { recursive: true }); + let index = 0; + while (index <= 10000) { + const suffix = index === 0 ? "" : `-${index}`; + const candidate = path.join(trashDir, `${parsed.base}.${Date.now()}${suffix}`); + if (!fs.existsSync(candidate)) { + fs.renameSync(filePath, candidate); + return true; + } + index += 1; + } + } catch { + // ignore + } + return false; + }; + for (const filePath of targets) { try { if (!fs.existsSync(filePath)) { continue; } + if (cleanupMode === "trash") { + if (moveToTrashLike(filePath)) { + removed += 1; + } + continue; + } fs.rmSync(filePath, { force: true }); removed += 1; } catch { @@ -877,13 +981,13 @@ function hasAnyFilesRecursive(rootDir: string): boolean { if (!fs.existsSync(rootDir)) { return false; } - const deadline = Date.now() + 70; + const deadline = Date.now() + 220; let inspectedDirs = 0; const stack = [rootDir]; while (stack.length > 0) { inspectedDirs += 1; if (inspectedDirs > 8000 || Date.now() > deadline) { - return true; + return hasAnyEntries(rootDir); } const current = stack.pop() as string; let entries: fs.Dirent[] = []; @@ -1086,7 +1190,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (!shouldFallbackToExternalZip(error)) { throw error; } - const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal); @@ -1140,7 +1244,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } else { if (!options.skipPostCleanup) { const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values()); - const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode); + const sourceAndTargetEqual = pathSetKey(path.resolve(options.packageDir)) === pathSetKey(path.resolve(options.targetDir)); + const removedArchives = sourceAndTargetEqual + ? 0 + : cleanupArchives(cleanupSources, options.cleanupMode); + if (sourceAndTargetEqual && options.cleanupMode !== "none") { + logger.warn(`Archive-Cleanup übersprungen (Quelle=Ziel): ${options.packageDir}`); + } if (options.cleanupMode !== "none") { logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); } @@ -1183,7 +1293,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } - emitProgress(candidates.length, "", "done"); + emitProgress(extracted, "", "done"); logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); diff --git a/src/main/logger.ts b/src/main/logger.ts index f53f7c3..5d4b91f 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -57,8 +57,10 @@ function flushSyncPending(): void { pendingLines = []; pendingChars = 0; + rotateIfNeeded(logFilePath); const primary = appendLine(logFilePath, chunk); if (fallbackLogFilePath) { + rotateIfNeeded(fallbackLogFilePath); const fallback = appendLine(fallbackLogFilePath, chunk); if (!primary.ok && !fallback.ok) { writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); diff --git a/src/main/main.ts b/src/main/main.ts index 8485bee..08b845e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -14,6 +14,16 @@ function validateString(value: unknown, name: string): string { } return value; } + +function validatePlainObject(value: unknown, name: string): Record<string, unknown> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} muss ein Objekt sein`); + } + return value as Record<string, unknown>; +} + +const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; +const RENAME_PACKAGE_MAX_CHARS = 240; function validateStringArray(value: unknown, name: string): string[] { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { throw new Error(`${name} muss ein String-Array sein`); @@ -121,7 +131,21 @@ function extractLinksFromText(text: string): string[] { } function normalizeClipboardText(text: string): string { - return String(text || "").slice(0, CLIPBOARD_MAX_TEXT_CHARS); + const normalized = String(text || ""); + if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) { + return normalized; + } + const truncated = normalized.slice(0, CLIPBOARD_MAX_TEXT_CHARS); + const lastBreak = Math.max( + truncated.lastIndexOf("\n"), + truncated.lastIndexOf("\r"), + truncated.lastIndexOf("\t"), + truncated.lastIndexOf(" ") + ); + if (lastBreak >= Math.floor(CLIPBOARD_MAX_TEXT_CHARS * 0.7)) { + return truncated.slice(0, lastBreak); + } + return truncated; } function startClipboardWatcher(): void { @@ -193,13 +217,21 @@ function registerIpcHandlers(): void { } }); ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => { - const result = controller.updateSettings(partial ?? {}); + const validated = validatePlainObject(partial ?? {}, "partial"); + const result = controller.updateSettings(validated as Partial<AppSettings>); updateClipboardWatcher(); updateTray(); return result; }); ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { + validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); + if (payload.packageName !== undefined) { + validateString(payload.packageName, "packageName"); + } + if (payload.duplicatePolicy !== undefined && payload.duplicatePolicy !== "keep" && payload.duplicatePolicy !== "skip" && payload.duplicatePolicy !== "overwrite") { + throw new Error("duplicatePolicy muss 'keep', 'skip' oder 'overwrite' sein"); + } return controller.addLinks(payload); }); ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { @@ -227,6 +259,9 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { validateString(packageId, "packageId"); validateString(newName, "newName"); + if (newName.length > RENAME_PACKAGE_MAX_CHARS) { + throw new Error(`newName zu lang (max ${RENAME_PACKAGE_MAX_CHARS} Zeichen)`); + } return controller.renamePackage(packageId, newName); }); ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { @@ -244,6 +279,10 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue()); ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { validateString(json, "json"); + const bytes = Buffer.byteLength(json, "utf8"); + if (bytes > IMPORT_QUEUE_MAX_BYTES) { + throw new Error(`Queue-Import zu groß (max ${IMPORT_QUEUE_MAX_BYTES} Bytes)`); + } return controller.importQueue(json); }); ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 5a61123..fe44928 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -1,6 +1,8 @@ import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { compactErrorText, sleep } from "./utils"; +const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28"; + export interface UnrestrictedLink { fileName: string; directUrl: string; @@ -16,6 +18,33 @@ function retryDelay(attempt: number): number { return Math.min(5000, 400 * 2 ** attempt); } +function readHttpStatusFromErrorText(text: string): number { + const match = String(text || "").match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRetryableErrorText(text: string): boolean { + const status = readHttpStatusFromErrorText(text); + if (status === 429 || status >= 500) { + return true; + } + const lower = String(text || "").toLowerCase(); + return lower.includes("timeout") + || lower.includes("network") + || lower.includes("fetch failed") + || lower.includes("aborted") + || lower.includes("econnreset") + || lower.includes("enotfound") + || lower.includes("etimedout"); +} + +function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + if (!signal) { + return AbortSignal.timeout(timeoutMs); + } + return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); +} + function looksLikeHtmlResponse(contentType: string, body: string): boolean { const type = String(contentType || "").toLowerCase(); if (type.includes("text/html") || type.includes("application/xhtml+xml")) { @@ -39,7 +68,7 @@ export class RealDebridClient { this.token = token; } - public async unrestrictLink(link: string): Promise<UnrestrictedLink> { + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { try { @@ -49,10 +78,10 @@ export class RealDebridClient { headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.12" + "User-Agent": DEBRID_USER_AGENT }, body, - signal: AbortSignal.timeout(30000) + signal: withTimeoutSignal(signal, 30000) }); const text = await response.text(); @@ -91,7 +120,10 @@ export class RealDebridClient { }; } catch (error) { lastError = compactErrorText(error); - if (attempt >= REQUEST_RETRIES) { + if (signal?.aborted || /aborted/i.test(lastError)) { + break; + } + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { break; } await sleep(retryDelay(attempt)); diff --git a/src/main/storage.ts b/src/main/storage.ts index 7139c79..f1d50d4 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -57,11 +57,20 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] { return normalized; } +function normalizeAbsoluteDir(value: unknown, fallback: string): string { + const text = asText(value); + if (/^\/[\s\S]+/.test(text)) { + return text.replace(/\\/g, "/"); + } + if (!text || !path.isAbsolute(text)) { + return path.resolve(fallback); + } + return path.resolve(text); +} + export function normalizeSettings(settings: AppSettings): AppSettings { const defaults = defaultSettings(); const normalized: AppSettings = { - ...defaults, - ...settings, token: asText(settings.token), megaLogin: asText(settings.megaLogin), megaPassword: asText(settings.megaPassword), @@ -69,23 +78,30 @@ export function normalizeSettings(settings: AppSettings): AppSettings { allDebridToken: asText(settings.allDebridToken), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"), rememberToken: Boolean(settings.rememberToken), + providerPrimary: settings.providerPrimary, + providerSecondary: settings.providerSecondary, + providerTertiary: settings.providerTertiary, autoProviderFallback: Boolean(settings.autoProviderFallback), - outputDir: asText(settings.outputDir) || defaults.outputDir, + outputDir: normalizeAbsoluteDir(settings.outputDir, defaults.outputDir), packageName: asText(settings.packageName), autoExtract: Boolean(settings.autoExtract), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), - extractDir: asText(settings.extractDir) || defaults.extractDir, + extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), createExtractSubfolder: Boolean(settings.createExtractSubfolder), hybridExtract: Boolean(settings.hybridExtract), + cleanupMode: settings.cleanupMode, + extractConflictMode: settings.extractConflictMode, removeLinkFilesAfterExtract: Boolean(settings.removeLinkFilesAfterExtract), removeSamplesAfterExtract: Boolean(settings.removeSamplesAfterExtract), enableIntegrityCheck: Boolean(settings.enableIntegrityCheck), autoResumeOnStart: Boolean(settings.autoResumeOnStart), autoReconnect: Boolean(settings.autoReconnect), maxParallel: clampNumber(settings.maxParallel, defaults.maxParallel, 1, 50), + reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), + completedCleanupPolicy: settings.completedCleanupPolicy, speedLimitEnabled: Boolean(settings.speedLimitEnabled), speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), - reconnectWaitSeconds: clampNumber(settings.reconnectWaitSeconds, defaults.reconnectWaitSeconds, 10, 600), + speedLimitMode: settings.speedLimitMode, autoUpdateCheck: Boolean(settings.autoUpdateCheck), updateRepo: asText(settings.updateRepo) || defaults.updateRepo, clipboardWatch: Boolean(settings.clipboardWatch), @@ -103,6 +119,12 @@ export function normalizeSettings(settings: AppSettings): AppSettings { if (!VALID_FALLBACK_PROVIDERS.has(normalized.providerTertiary)) { normalized.providerTertiary = "none"; } + if (normalized.providerSecondary === normalized.providerPrimary) { + normalized.providerSecondary = "none"; + } + if (normalized.providerTertiary === normalized.providerPrimary || normalized.providerTertiary === normalized.providerSecondary) { + normalized.providerTertiary = "none"; + } if (!VALID_CLEANUP_MODES.has(normalized.cleanupMode)) { normalized.cleanupMode = defaults.cleanupMode; } @@ -264,9 +286,16 @@ function normalizeLoadedSession(raw: unknown): SessionState { } const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; + const seenOrder = new Set<string>(); const packageOrder = rawOrder .map((entry) => asText(entry)) - .filter((id) => id in packagesById); + .filter((id) => { + if (!(id in packagesById) || seenOrder.has(id)) { + return false; + } + seenOrder.add(id); + return true; + }); for (const packageId of Object.keys(packagesById)) { if (!packageOrder.includes(packageId)) { packageOrder.push(packageId); @@ -332,6 +361,10 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void } } +function sessionTempPath(sessionFile: string, kind: "sync" | "async"): string { + return `${sessionFile}.${kind}.tmp`; +} + export function saveSettings(paths: StoragePaths, settings: AppSettings): void { ensureBaseDir(paths.baseDir); // Create a backup of the existing config before overwriting @@ -396,7 +429,7 @@ export function loadSession(paths: StoragePaths): SessionState { export function saveSession(paths: StoragePaths, session: SessionState): void { ensureBaseDir(paths.baseDir); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); - const tempPath = `${paths.sessionFile}.tmp`; + const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(tempPath, paths.sessionFile); } @@ -406,7 +439,7 @@ let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null; async function writeSessionPayload(paths: StoragePaths, payload: string): Promise<void> { await fs.promises.mkdir(paths.baseDir, { recursive: true }); - const tempPath = `${paths.sessionFile}.tmp`; + const tempPath = sessionTempPath(paths.sessionFile, "async"); await fsp.writeFile(tempPath, payload, "utf8"); try { await fsp.rename(tempPath, paths.sessionFile); diff --git a/src/main/update.ts b/src/main/update.ts index 97edafc..401d716 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -208,16 +208,15 @@ async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise< }, signal: timeout.signal }); + const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS); + return { + ok: response.ok, + status: response.status, + payload + }; } finally { timeout.clear(); } - - const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS); - return { - ok: response.ok, - status: response.status, - payload - }; } function uniqueStrings(values: string[]): string[] { @@ -440,6 +439,18 @@ async function downloadFile(url: string, targetPath: string): Promise<void> { try { await pipeline(source, target); + } catch (error) { + try { + source.destroy(); + } catch { + // ignore + } + try { + target.destroy(); + } catch { + // ignore + } + throw error; } finally { clearIdleTimer(); source.off("data", onSourceData); diff --git a/src/main/utils.ts b/src/main/utils.ts index d9787d3..19d986a 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -43,8 +43,9 @@ export function sanitizeFilename(name: string): string { } const parsed = path.parse(normalized); - if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) { - normalized = `${parsed.name}_${parsed.ext}`; + const reservedBase = (parsed.name.split(".")[0] || parsed.name).toLowerCase(); + if (WINDOWS_RESERVED_BASENAMES.has(reservedBase)) { + normalized = `${parsed.name.replace(/^([^.]*)/, "$1_")}${parsed.ext}`; } return normalized || "Paket"; @@ -70,14 +71,25 @@ export function extractHttpLinksFromText(text: string): string[] { for (const match of matches) { let candidate = String(match || "").trim(); - while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) { - if (candidate.endsWith(")")) { + while (candidate.length > 0) { + const lastChar = candidate[candidate.length - 1]; + if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) { + break; + } + if (lastChar === ")") { const openCount = (candidate.match(/\(/g) || []).length; const closeCount = (candidate.match(/\)/g) || []).length; if (closeCount <= openCount) { break; } } + if (lastChar === "]") { + const openCount = (candidate.match(/\[/g) || []).length; + const closeCount = (candidate.match(/\]/g) || []).length; + if (closeCount <= openCount) { + break; + } + } candidate = candidate.slice(0, -1); } if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) { @@ -123,7 +135,7 @@ export function filenameFromUrl(url: string): string { const rawName = queryName || path.basename(parsed.pathname || ""); const decoded = safeDecodeURIComponent(rawName || "").trim(); const normalized = decoded - .replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2})\.html$/i, ".$1") + .replace(/\.(rar|zip|7z|tar|gz|bz2|xz|iso|part\d+\.rar|r\d{2,3})\.html$/i, ".$1") .replace(/\.(mp4|mkv|avi|mp3|flac|srt)\.html$/i, ".$1"); return sanitizeFilename(normalized || "download.bin"); } catch { @@ -206,6 +218,9 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName: } export function ensureDirPath(baseDir: string, packageName: string): string { + if (!path.isAbsolute(baseDir)) { + throw new Error("baseDir muss ein absoluter Pfad sein"); + } return path.join(baseDir, sanitizeFilename(packageName)); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8947a80..bd0cbb6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -27,6 +27,13 @@ interface StartConflictPromptState { applyToAll: boolean; } +interface ConfirmPromptState { + title: string; + message: string; + confirmLabel: string; + danger?: boolean; +} + const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalFiles: 0, @@ -126,6 +133,7 @@ export function App(): ReactElement { { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } ]); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); + const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs); const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef<Tab>(tab); const packageOrderRef = useRef<string[]>([]); @@ -136,10 +144,14 @@ export function App(): ReactElement { const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const actionBusyRef = useRef(false); + const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const mountedRef = useRef(true); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); + const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); + const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -147,6 +159,10 @@ export function App(): ReactElement { activeCollectorTabRef.current = activeCollectorTab; }, [activeCollectorTab]); + useEffect(() => { + collectorTabsRef.current = collectorTabs; + }, [collectorTabs]); + useEffect(() => { activeTabRef.current = tab; }, [tab]); @@ -155,14 +171,14 @@ export function App(): ReactElement { packageOrderRef.current = snapshot.session.packageOrder; }, [snapshot.session.packageOrder]); - const showToast = (message: string, timeoutMs = 2200): void => { + const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } toastTimerRef.current = setTimeout(() => { setStatusToast(""); toastTimerRef.current = null; }, timeoutMs); - }; + }, []); useEffect(() => { let unsubscribe: (() => void) | null = null; @@ -222,13 +238,20 @@ export function App(): ReactElement { }); }); return () => { + mountedRef.current = false; if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } + if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } if (startConflictResolverRef.current) { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; resolver(null); } + if (confirmResolverRef.current) { + const resolver = confirmResolverRef.current; + confirmResolverRef.current = null; + resolver(false); + } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } }; @@ -290,7 +313,7 @@ export function App(): ReactElement { map.set(id, index); }); return map; - }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder]); + }, [downloadsTabActive, snapshot.session.packageOrder]); const itemsByPackage = useMemo(() => { if (!downloadsTabActive) { @@ -311,14 +334,19 @@ export function App(): ReactElement { return; } setCollapsedPackages((prev) => { - const next: Record<string, boolean> = {}; + const next: Record<string, boolean> = { ...prev }; const defaultCollapsed = totalPackageCount >= 24; - for (const packageId of packageIdsForView) { + for (const packageId of snapshot.session.packageOrder) { next[packageId] = prev[packageId] ?? defaultCollapsed; } + for (const packageId of Object.keys(next)) { + if (!snapshot.session.packages[packageId]) { + delete next[packageId]; + } + } return next; }); - }, [downloadsTabActive, packageOrderKey, totalPackageCount, packageIdsForView]); + }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); const hiddenPackageCount = shouldLimitPackageRendering ? Math.max(0, totalPackageCount - packages.length) @@ -399,6 +427,9 @@ export function App(): ReactElement { ]); const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => { + if (!mountedRef.current) { + return; + } if (result.error) { if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } return; @@ -407,9 +438,16 @@ export function App(): ReactElement { if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } return; } - const approved = window.confirm(`Update verfügbar: ${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`); + const approved = await askConfirmPrompt({ + title: "Update verfügbar", + message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, + confirmLabel: "Jetzt installieren" + }); if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } const install = await window.rd.installUpdate(); + if (!mountedRef.current) { + return; + } if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; } showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); }; @@ -460,6 +498,22 @@ export function App(): ReactElement { }); }; + const closeConfirmPrompt = (confirmed: boolean): void => { + const resolver = confirmResolverRef.current; + confirmResolverRef.current = null; + setConfirmPrompt(null); + if (resolver) { + resolver(confirmed); + } + }; + + const askConfirmPrompt = (prompt: ConfirmPromptState): Promise<boolean> => { + return new Promise((resolve) => { + confirmResolverRef.current = resolve; + setConfirmPrompt(prompt); + }); + }; + const onStartDownloads = async (): Promise<void> => { await performQuickAction(async () => { if (configuredProviders.length === 0) { @@ -515,11 +569,14 @@ export function App(): ReactElement { const onAddLinks = async (): Promise<void> => { await performQuickAction(async () => { + const activeId = activeCollectorTabRef.current; + const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; + const rawText = active?.text ?? ""; const persisted = await persistDraftSettings(); - const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: persisted.packageName }); + const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); - setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t)); + setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); } else { showToast("Keine gültigen Links gefunden"); } @@ -572,7 +629,10 @@ export function App(): ReactElement { const a = document.createElement("a"); a.href = url; a.download = "rd-queue-export.json"; + a.style.display = "none"; + document.body.appendChild(a); a.click(); + a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60_000); showToast("Queue exportiert"); }, (error) => { @@ -585,14 +645,30 @@ export function App(): ReactElement { return; } + setActionBusy(true); + const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; + + const releasePickerBusy = (): void => { + setActionBusy(actionBusyRef.current); + }; + + const onWindowFocus = (): void => { + window.removeEventListener("focus", onWindowFocus); + if (!input.files || input.files.length === 0) { + releasePickerBusy(); + } + }; + input.onchange = async () => { const file = input.files?.[0]; if (!file) { + releasePickerBusy(); return; } + releasePickerBusy(); await performQuickAction(async () => { const text = await file.text(); const result = await window.rd.importQueue(text); @@ -601,6 +677,8 @@ export function App(): ReactElement { showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); }); }; + + window.addEventListener("focus", onWindowFocus, { once: true }); input.click(); }; @@ -644,9 +722,17 @@ export function App(): ReactElement { showToast(`Fehler: ${String(error)}`, 2600); } } finally { - setTimeout(() => { + if (actionUnlockTimerRef.current) { + clearTimeout(actionUnlockTimerRef.current); + } + actionUnlockTimerRef.current = setTimeout(() => { + if (!mountedRef.current) { + actionUnlockTimerRef.current = null; + return; + } actionBusyRef.current = false; setActionBusy(false); + actionUnlockTimerRef.current = null; }, 80); } }; @@ -659,6 +745,7 @@ export function App(): ReactElement { const target = direction === "up" ? idx - 1 : idx + 1; if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; + setDownloadsSortDescending(false); packageOrderRef.current = order; void window.rd.reorderPackages(order); }, []); @@ -671,18 +758,22 @@ export function App(): ReactElement { if (unchanged) { return; } + setDownloadsSortDescending(false); packageOrderRef.current = nextOrder; void window.rd.reorderPackages(nextOrder); }, []); const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; - const name = `Tab ${collectorTabs.length + 1}`; - setCollectorTabs((prev) => [...prev, { id, name, text: "" }]); + setCollectorTabs((prev) => { + const name = `Tab ${prev.length + 1}`; + return [...prev, { id, name, text: "" }]; + }); setActiveCollectorTab(id); }; const removeCollectorTab = (id: string): void => { + let fallbackId = ""; setCollectorTabs((prev) => { if (prev.length <= 1) { return prev; @@ -693,11 +784,13 @@ export function App(): ReactElement { } const next = prev.filter((tabEntry) => tabEntry.id !== id); if (activeCollectorTabRef.current === id) { - const fallback = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; - setActiveCollectorTab(fallback); + fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; } return next; }); + if (fallbackId) { + setActiveCollectorTab(fallbackId); + } }; const onPackageDragStart = useCallback((packageId: string) => { @@ -812,11 +905,18 @@ export function App(): ReactElement { className="btn" disabled={actionBusy} onClick={() => { - const confirmed = window.confirm("Wirklich alle Einträge aus der Queue löschen?"); - if (!confirmed) { - return; - } - void performQuickAction(() => window.rd.clearAll()); + void performQuickAction(async () => { + const confirmed = await askConfirmPrompt({ + title: "Queue löschen", + message: "Wirklich alle Einträge aus der Queue löschen?", + confirmLabel: "Alles löschen", + danger: true + }); + if (!confirmed) { + return; + } + await window.rd.clearAll(); + }); }} > Alles leeren @@ -893,7 +993,7 @@ export function App(): ReactElement { </button> <button className={`btn${downloadsSortDescending ? " btn-active" : ""}`} - disabled={packages.length < 2} + disabled={totalPackageCount < 2} onClick={() => { const nextDescending = !downloadsSortDescending; setDownloadsSortDescending(nextDescending); @@ -939,7 +1039,13 @@ export function App(): ReactElement { editingName={editingName} collapsed={collapsedPackages[pkg.id] ?? false} onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }} - onFinishEdit={(name) => { setEditingPackageId(null); if (name.trim()) { void window.rd.renamePackage(pkg.id, name); } }} + onFinishEdit={(name) => { + setEditingPackageId(null); + const nextName = name.trim(); + if (nextName && nextName !== pkg.name.trim()) { + void window.rd.renamePackage(pkg.id, nextName); + } + }} onEditChange={setEditingName} onToggleCollapse={() => { setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) })); @@ -1132,6 +1238,24 @@ export function App(): ReactElement { )} </main> + {confirmPrompt && ( + <div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}> + <div className="modal-card" onClick={(event) => event.stopPropagation()}> + <h3>{confirmPrompt.title}</h3> + <p>{confirmPrompt.message}</p> + <div className="modal-actions"> + <button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button> + <button + className={confirmPrompt.danger ? "btn danger" : "btn"} + onClick={() => closeConfirmPrompt(true)} + > + {confirmPrompt.confirmLabel} + </button> + </div> + </div> + </div> + )} + {startConflictPrompt && ( <div className="modal-backdrop" onClick={() => closeStartConflictPrompt(null)}> <div className="modal-card" onClick={(event) => event.stopPropagation()}> @@ -1289,21 +1413,15 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { return false; } - if (prev.items.length !== next.items.length) { + if (prev.pkg.itemIds.length !== next.pkg.itemIds.length) { return false; } - if (prev.onCancel !== next.onCancel - || prev.onMoveUp !== next.onMoveUp - || prev.onMoveDown !== next.onMoveDown - || prev.onToggle !== next.onToggle - || prev.onRemoveItem !== next.onRemoveItem - || prev.onStartEdit !== next.onStartEdit - || prev.onFinishEdit !== next.onFinishEdit - || prev.onEditChange !== next.onEditChange - || prev.onToggleCollapse !== next.onToggleCollapse - || prev.onDragStart !== next.onDragStart - || prev.onDrop !== next.onDrop - || prev.onDragEnd !== next.onDragEnd) { + for (let index = 0; index < prev.pkg.itemIds.length; index += 1) { + if (prev.pkg.itemIds[index] !== next.pkg.itemIds[index]) { + return false; + } + } + if (prev.items.length !== next.items.length) { return false; } for (let index = 0; index < prev.items.length; index += 1) {