diff --git a/package-lock.json b/package-lock.json index 55d84df..bc7247a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.28", + "version": "1.4.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.28", + "version": "1.4.29", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 0cdae5b..fc24180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.28", + "version": "1.4.29", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index b8f8d79..edd096d 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -5,7 +5,8 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; const API_TIMEOUT_MS = 30000; -const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28"; +const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; +const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4"; @@ -28,6 +29,13 @@ interface DebridServiceOptions { megaWebUnrestrict?: MegaWebUnrestrictor; } +function cloneSettings(settings: AppSettings): AppSettings { + return { + ...settings, + bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })) + }; +} + type BestDebridRequest = { url: string; useAuthHeader: boolean; @@ -50,6 +58,33 @@ function retryDelay(attempt: number): number { return Math.min(5000, 400 * 2 ** attempt); } +function parseRetryAfterMs(value: string | null): number { + const text = String(value || "").trim(); + if (!text) { + return 0; + } + + const asSeconds = Number(text); + if (Number.isFinite(asSeconds) && asSeconds >= 0) { + return Math.min(120000, Math.floor(asSeconds * 1000)); + } + + const asDate = Date.parse(text); + if (Number.isFinite(asDate)) { + return Math.min(120000, Math.max(0, asDate - Date.now())); + } + + return 0; +} + +function retryDelayForResponse(response: Response, attempt: number): number { + if (response.status !== 429) { + return retryDelay(attempt); + } + const fromHeader = parseRetryAfterMs(response.headers.get("retry-after")); + return fromHeader > 0 ? fromHeader : retryDelay(attempt); +} + function readHttpStatusFromErrorText(text: string): number { const match = String(text || "").match(/HTTP\s+(\d{3})/i); return match ? Number(match[1]) : 0; @@ -226,7 +261,7 @@ export function normalizeResolvedFilename(value: string): string { .replace(/^download\s+file\s+/i, "") .replace(/\s*[-|]\s*rapidgator.*$/i, "") .trim(); - if (!candidate || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) { + if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) { return ""; } return candidate; @@ -253,10 +288,9 @@ export function extractRapidgatorFilenameFromHtml(html: string): string { const patterns = [ /]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i, /]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i, - /([^<]+)<\/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 + /<title>([^<]{1,260})<\/title>/i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i, + /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i ]; for (const pattern of patterns) { @@ -314,6 +348,48 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); } +async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> { + const body = response.body; + if (!body) { + return ""; + } + + const reader = body.getReader(); + const chunks: Buffer[] = []; + let readBytes = 0; + + try { + while (readBytes < maxBytes) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } + + const { done, value } = await reader.read(); + if (done || !value || value.byteLength === 0) { + break; + } + + const remaining = maxBytes - readBytes; + const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value; + chunks.push(Buffer.from(slice)); + readBytes += slice.byteLength; + } + } finally { + try { + await reader.cancel(); + } catch { + // ignore + } + try { + reader.releaseLock(); + } catch { + // ignore + } + } + + return Buffer.concat(chunks).toString("utf8"); +} + async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> { if (!isRapidgatorLink(link)) { return ""; @@ -340,13 +416,14 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr }); if (!response.ok) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { - await sleepWithSignal(retryDelay(attempt), signal); + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); continue; } return ""; } const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const contentLength = Number(response.headers.get("content-length") || NaN); if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml") @@ -355,8 +432,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr && !contentType.includes("application/xml")) { return ""; } + if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) { + return ""; + } - const html = await response.text(); + const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal); const fromHtml = extractRapidgatorFilenameFromHtml(html); if (fromHtml) { return fromHtml; @@ -399,16 +479,22 @@ class MegaDebridClient { this.megaWebUnrestrict = megaWebUnrestrict; } - public async unrestrictLink(link: string): Promise<UnrestrictedLink> { + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { if (!this.megaWebUnrestrict) { throw new Error("Mega-Web-Fallback nicht verfügbar"); } let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } const web = await this.megaWebUnrestrict(link).catch((error) => { lastError = compactErrorText(error); return null; }); + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } if (web?.directUrl) { web.retriesUsed = attempt - 1; return web; @@ -420,7 +506,7 @@ class MegaDebridClient { lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer"; } if (attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); } } throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen"); @@ -434,13 +520,13 @@ class BestDebridClient { this.token = token; } - public async unrestrictLink(link: string): Promise<UnrestrictedLink> { + public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> { const requests = buildBestDebridRequests(link, this.token); let lastError = ""; for (const request of requests) { try { - return await this.tryRequest(request, link); + return await this.tryRequest(request, link, signal); } catch (error) { lastError = compactErrorText(error); } @@ -449,7 +535,7 @@ class BestDebridClient { throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen"); } - private async tryRequest(request: BestDebridRequest, originalLink: string): Promise<UnrestrictedLink> { + private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> { let lastError = ""; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { try { @@ -463,7 +549,7 @@ class BestDebridClient { const response = await fetch(request.url, { method: "GET", headers, - signal: AbortSignal.timeout(API_TIMEOUT_MS) + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) }); const text = await response.text(); const parsed = parseJson(text); @@ -472,7 +558,7 @@ class BestDebridClient { if (!response.ok) { const reason = parseError(response.status, text, payload); if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); continue; } throw new Error(reason); @@ -480,13 +566,14 @@ class BestDebridClient { const directUrl = pickString(payload, ["download", "debridLink", "link"]); if (directUrl) { + let parsedDirect: URL; try { - const parsedDirect = new URL(directUrl); - if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") { - throw new Error("invalid_protocol"); - } + parsedDirect = new URL(directUrl); } catch { - throw new Error("BestDebrid Antwort enthält ungültige Download-URL"); + throw new Error("BestDebrid Antwort enthält keine gültige Download-URL"); + } + if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") { + throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`); } const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink); const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]); @@ -506,10 +593,13 @@ class BestDebridClient { throw new Error("BestDebrid Antwort ohne Download-Link"); } catch (error) { lastError = compactErrorText(error); + if (signal?.aborted || /aborted/i.test(lastError)) { + break; + } if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { break; } - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); } } throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, "")); @@ -523,7 +613,7 @@ class AllDebridClient { this.token = token; } - public async getLinkInfos(links: string[]): Promise<Map<string, string>> { + public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> { const result = new Map<string, string>(); const canonicalToInput = new Map<string, string>(); const uniqueLinks: string[] = []; @@ -542,6 +632,9 @@ class AllDebridClient { } for (let index = 0; index < uniqueLinks.length; index += 32) { + if (signal?.aborted) { + throw new Error("aborted:debrid"); + } const chunk = uniqueLinks.slice(index, index + 32); const body = new URLSearchParams(); for (const link of chunk) { @@ -562,7 +655,7 @@ class AllDebridClient { "User-Agent": DEBRID_USER_AGENT }, body, - signal: AbortSignal.timeout(API_TIMEOUT_MS) + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) }); text = await response.text(); @@ -570,7 +663,7 @@ class AllDebridClient { if (!response.ok) { const reason = parseError(response.status, text, payload); if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); continue; } throw new Error(reason); @@ -594,10 +687,13 @@ class AllDebridClient { break; } catch (error) { const errorText = compactErrorText(error); + if (signal?.aborted || /aborted/i.test(errorText)) { + throw error; + } if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) { throw error; } - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); } } @@ -607,6 +703,11 @@ class AllDebridClient { const data = asRecord(payload?.data); const infos = Array.isArray(data?.infos) ? data.infos : []; + const hasAnyLinkedInfo = infos.some((entry) => { + const info = asRecord(entry); + return Boolean(pickString(info, ["link"])); + }); + const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo; for (let i = 0; i < infos.length; i += 1) { const info = asRecord(infos[i]); if (!info) { @@ -621,7 +722,9 @@ class AllDebridClient { const byResponse = canonicalToInput.get(canonicalLink(responseLink)); const byIndex = chunk.length === 1 ? chunk[0] - : ""; + : allowPositionalFallback + ? chunk[i] + : ""; const original = byResponse || byIndex; if (!original) { continue; @@ -633,7 +736,7 @@ class AllDebridClient { return result; } - 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 { @@ -645,7 +748,7 @@ class AllDebridClient { "User-Agent": DEBRID_USER_AGENT }, body: new URLSearchParams({ link }), - signal: AbortSignal.timeout(API_TIMEOUT_MS) + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) }); const text = await response.text(); const payload = asRecord(parseJson(text)); @@ -653,7 +756,7 @@ class AllDebridClient { if (!response.ok) { const reason = parseError(response.status, text, payload); if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); continue; } throw new Error(reason); @@ -687,10 +790,13 @@ class AllDebridClient { }; } catch (error) { lastError = compactErrorText(error); + if (signal?.aborted || /aborted/i.test(lastError)) { + break; + } if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { break; } - await sleep(retryDelay(attempt)); + await sleepWithSignal(retryDelay(attempt), signal); } } @@ -704,12 +810,12 @@ export class DebridService { private options: DebridServiceOptions; public constructor(settings: AppSettings, options: DebridServiceOptions = {}) { - this.settings = settings; + this.settings = cloneSettings(settings); this.options = options; } public setSettings(next: AppSettings): void { - this.settings = next; + this.settings = cloneSettings(next); } public async resolveFilenames( @@ -717,7 +823,7 @@ export class DebridService { onResolved?: (link: string, fileName: string) => void, signal?: AbortSignal ): Promise<Map<string, string>> { - const settings = { ...this.settings }; + const settings = cloneSettings(this.settings); const allDebridClient = new AllDebridClient(settings.allDebridToken); const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link))); if (unresolved.length === 0) { @@ -740,7 +846,7 @@ export class DebridService { const token = settings.allDebridToken.trim(); if (token) { try { - const infos = await allDebridClient.getLinkInfos(unresolved); + const infos = await allDebridClient.getLinkInfos(unresolved, signal); for (const [link, fileName] of infos.entries()) { reportResolved(link, fileName); } @@ -755,21 +861,11 @@ export class DebridService { 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, signal, settings); - reportResolved(link, unrestricted.fileName || ""); - } catch { - // ignore final fallback errors - } - }); - return clean; } public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> { - const settings = settingsSnapshot ? { ...settingsSnapshot } : { ...this.settings }; + const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); const order = toProviderOrder( settings.providerPrimary, settings.providerSecondary, @@ -855,11 +951,11 @@ export class DebridService { return new RealDebridClient(settings.token).unrestrictLink(link, signal); } if (provider === "megadebrid") { - return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link); + return new MegaDebridClient(this.options.megaWebUnrestrict).unrestrictLink(link, signal); } if (provider === "alldebrid") { - return new AllDebridClient(settings.allDebridToken).unrestrictLink(link); + return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); } - return new BestDebridClient(settings.bestToken).unrestrictLink(link); + return new BestDebridClient(settings.bestToken).unrestrictLink(link, signal); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d548696..3b1f8d9 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -153,7 +153,7 @@ function parseContentDispositionFilename(contentDisposition: string | null): str function isArchiveLikePath(filePath: string): boolean { const lower = path.basename(filePath).toLowerCase(); - return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip|z\d{2}|7z|7z\.\d{3})$/i.test(lower); + return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower); } function isFetchFailure(errorText: string): boolean { @@ -543,6 +543,7 @@ export class DownloadManager extends EventEmitter { delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); this.retryAfterByItem.delete(itemId); + this.dropItemContribution(itemId); if (!hasActiveTask) { this.releaseTargetPath(itemId); } @@ -742,7 +743,7 @@ export class DownloadManager extends EventEmitter { totalBytes: null, progressPercent: 0, fileName, - targetPath: path.join(outputDir, fileName), + targetPath: "", resumable: true, attempts: 0, lastError: "", @@ -750,6 +751,7 @@ export class DownloadManager extends EventEmitter { createdAt: nowMs(), updatedAt: nowMs() }; + this.assignItemTargetPath(item, path.join(outputDir, fileName)); packageEntry.itemIds.push(itemId); this.session.items[itemId] = item; this.itemCount += 1; @@ -901,7 +903,7 @@ export class DownloadManager extends EventEmitter { item.lastError = ""; item.fullStatus = "Wartet"; item.updatedAt = nowMs(); - item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))); + this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)))); this.runOutcomes.delete(itemId); this.itemContributedBytes.delete(itemId); this.retryAfterByItem.delete(itemId); @@ -974,7 +976,7 @@ export class DownloadManager extends EventEmitter { continue; } item.fileName = normalized; - item.targetPath = path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized); + this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized)); item.updatedAt = nowMs(); changed = true; changedForLink = true; @@ -1551,11 +1553,6 @@ export class DownloadManager extends EventEmitter { const hasPending = items.some((item) => ( item.status === "queued" || item.status === "reconnect_wait" - || item.status === "validating" - || item.status === "downloading" - || item.status === "paused" - || item.status === "extracting" - || item.status === "integrity_check" )); if (hasPending) { pkg.status = pkg.enabled ? "queued" : "paused"; @@ -1716,18 +1713,30 @@ export class DownloadManager extends EventEmitter { this.runOutcomes.set(itemId, status); } + private dropItemContribution(itemId: string): void { + const contributed = this.itemContributedBytes.get(itemId) || 0; + if (contributed > 0) { + this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - contributed); + } + this.itemContributedBytes.delete(itemId); + } + private claimTargetPath(itemId: string, preferredPath: string, allowExistingFile = false): string { + const preferredKey = pathKey(preferredPath); const existingClaim = this.claimedTargetPathByItem.get(itemId); if (existingClaim) { - const owner = this.reservedTargetPaths.get(pathKey(existingClaim)); + const existingKey = pathKey(existingClaim); + const owner = this.reservedTargetPaths.get(existingKey); if (owner === itemId) { - return existingClaim; + if (existingKey === preferredKey) { + return existingClaim; + } + this.reservedTargetPaths.delete(existingKey); } this.claimedTargetPathByItem.delete(itemId); } const parsed = path.parse(preferredPath); - const preferredKey = pathKey(preferredPath); const maxIndex = 10000; for (let index = 0; index <= maxIndex; index += 1) { const candidate = index === 0 @@ -1764,6 +1773,19 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.delete(itemId); } + private assignItemTargetPath(item: DownloadItem, targetPath: string): string { + const rawTargetPath = String(targetPath || "").trim(); + if (!rawTargetPath) { + this.releaseTargetPath(item.id); + item.targetPath = ""; + return ""; + } + const normalizedTargetPath = path.resolve(rawTargetPath); + const claimed = this.claimTargetPath(item.id, normalizedTargetPath); + item.targetPath = claimed; + return claimed; + } + private abortPostProcessing(reason: string): void { for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) { if (!controller.signal.aborted) { @@ -1943,6 +1965,8 @@ export class DownloadManager extends EventEmitter { this.packagePostProcessTasks.delete(packageId); for (const itemId of itemIds) { this.retryAfterByItem.delete(itemId); + this.releaseTargetPath(itemId); + this.dropItemContribution(itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } @@ -2215,6 +2239,8 @@ export class DownloadManager extends EventEmitter { item.attempts = 0; active.abortController = new AbortController(); active.abortReason = "none"; + // Caller returns immediately after this; startItem().finally releases the active slot, + // so the retry backoff never blocks a worker. this.retryAfterByItem.set(item.id, nowMs() + waitMs); } @@ -2306,6 +2332,12 @@ export class DownloadManager extends EventEmitter { let done = false; while (!done && item.attempts < maxAttempts) { item.attempts += 1; + if (item.status !== "downloading") { + item.status = "downloading"; + item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; + item.updatedAt = nowMs(); + this.emitState(); + } const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes); active.resumable = result.resumable; if (!active.resumable && !active.nonResumableCounted) { @@ -2416,6 +2448,7 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.progressPercent = 0; item.totalBytes = null; + this.dropItemContribution(item.id); } else if (reason === "stop") { item.status = "cancelled"; item.fullStatus = "Gestoppt"; @@ -2424,6 +2457,7 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.progressPercent = 0; item.totalBytes = null; + this.dropItemContribution(item.id); } } else if (reason === "shutdown") { item.status = "queued"; @@ -2466,6 +2500,7 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; + this.dropItemContribution(item.id); item.status = "failed"; this.recordRunOutcome(item.id, "failed"); item.lastError = errorText; @@ -2997,6 +3032,11 @@ export class DownloadManager extends EventEmitter { } if (item.status === "completed" && hasZeroByteArchive) { + const maxCompletedZeroByteAutoRetries = Math.max(2, REQUEST_RETRIES); + if (item.retries >= maxCompletedZeroByteAutoRetries) { + continue; + } + item.retries += 1; this.queueItemForRetry(item, { hardReset: true, reason: "Wartet (Auto-Retry: 0B-Datei)" @@ -3033,6 +3073,7 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.totalBytes = null; item.progressPercent = 0; + this.dropItemContribution(item.id); } item.status = "queued"; @@ -3327,12 +3368,12 @@ export class DownloadManager extends EventEmitter { if (/\.zip\.001$/i.test(entryPointName)) { const stem = entryPointName.replace(/\.zip\.001$/i, "").toLowerCase(); const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped}\\.zip(\\.\\d{3})?$`, "i").test(fileName); + return new RegExp(`^${escaped}\\.zip(\\.\\d+)?$`, "i").test(fileName); } if (/\.7z\.001$/i.test(entryPointName)) { const stem = entryPointName.replace(/\.7z\.001$/i, "").toLowerCase(); const escaped = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`^${escaped}\\.7z(\\.\\d{3})?$`, "i").test(fileName); + return new RegExp(`^${escaped}\\.7z(\\.\\d+)?$`, "i").test(fileName); } return false; } @@ -3651,7 +3692,8 @@ export class DownloadManager extends EventEmitter { } private applyPackageDoneCleanup(packageId: string): void { - if (this.settings.completedCleanupPolicy !== "package_done") { + const policy = this.settings.completedCleanupPolicy; + if (policy !== "package_done" && policy !== "immediate") { return; } @@ -3660,6 +3702,13 @@ export class DownloadManager extends EventEmitter { return; } + if (policy === "immediate") { + for (const itemId of [...pkg.itemIds]) { + this.applyCompletedCleanupPolicy(packageId, itemId); + } + return; + } + const allCompleted = pkg.itemIds.every((itemId) => { const item = this.session.items[itemId]; return !item || item.status === "completed"; @@ -3691,6 +3740,8 @@ export class DownloadManager extends EventEmitter { } } pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + this.releaseTargetPath(itemId); + this.dropItemContribution(itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); this.retryAfterByItem.delete(itemId); diff --git a/src/main/link-parser.ts b/src/main/link-parser.ts index 0ae6d4f..c70fb7b 100644 --- a/src/main/link-parser.ts +++ b/src/main/link-parser.ts @@ -6,7 +6,9 @@ export function mergePackageInputs(packages: ParsedPackageInput[]): ParsedPackag for (const pkg of packages) { const name = sanitizeFilename(pkg.name || inferPackageNameFromLinks(pkg.links)); const list = grouped.get(name) ?? []; - list.push(...pkg.links); + for (const link of pkg.links) { + list.push(link); + } grouped.set(name, list); } return Array.from(grouped.entries()).map(([name, links]) => ({ diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index fe44928..2cf75b5 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -1,7 +1,7 @@ import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { compactErrorText, sleep } from "./utils"; -const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.28"; +const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; export interface UnrestrictedLink { fileName: string; @@ -18,6 +18,33 @@ function retryDelay(attempt: number): number { return Math.min(5000, 400 * 2 ** attempt); } +function parseRetryAfterMs(value: string | null): number { + const text = String(value || "").trim(); + if (!text) { + return 0; + } + + const asSeconds = Number(text); + if (Number.isFinite(asSeconds) && asSeconds >= 0) { + return Math.min(120000, Math.floor(asSeconds * 1000)); + } + + const asDate = Date.parse(text); + if (Number.isFinite(asDate)) { + return Math.min(120000, Math.max(0, asDate - Date.now())); + } + + return 0; +} + +function retryDelayForResponse(response: Response, attempt: number): number { + if (response.status !== 429) { + return retryDelay(attempt); + } + const fromHeader = parseRetryAfterMs(response.headers.get("retry-after")); + return fromHeader > 0 ? fromHeader : retryDelay(attempt); +} + function readHttpStatusFromErrorText(text: string): number { const match = String(text || "").match(/HTTP\s+(\d{3})/i); return match ? Number(match[1]) : 0; @@ -89,7 +116,7 @@ export class RealDebridClient { if (!response.ok) { const parsed = parseErrorBody(response.status, text, contentType); if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt)); + await sleep(retryDelayForResponse(response, attempt)); continue; } throw new Error(parsed); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index bd0cbb6..a8bf410 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -123,6 +123,7 @@ export function App(): ReactElement { const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings); const [settingsDirty, setSettingsDirty] = useState(false); const settingsDirtyRef = useRef(false); + const settingsDraftRevisionRef = useRef(0); const latestStateRef = useRef<UiSnapshot | null>(null); const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); @@ -334,17 +335,22 @@ export function App(): ReactElement { return; } setCollapsedPackages((prev) => { + let changed = false; const next: Record<string, boolean> = { ...prev }; const defaultCollapsed = totalPackageCount >= 24; for (const packageId of snapshot.session.packageOrder) { - next[packageId] = prev[packageId] ?? defaultCollapsed; + if (!(packageId in prev)) { + next[packageId] = defaultCollapsed; + changed = true; + } } for (const packageId of Object.keys(next)) { if (!snapshot.session.packages[packageId]) { delete next[packageId]; + changed = true; } } - return next; + return changed ? next : prev; }); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); @@ -472,10 +478,13 @@ export function App(): ReactElement { }; const persistDraftSettings = async (): Promise<AppSettings> => { + const revisionAtStart = settingsDraftRevisionRef.current; const result = await window.rd.updateSettings(normalizedSettingsDraft); - setSettingsDraft(result); - settingsDirtyRef.current = false; - setSettingsDirty(false); + if (settingsDraftRevisionRef.current === revisionAtStart) { + setSettingsDraft(result); + settingsDirtyRef.current = false; + setSettingsDirty(false); + } return result; }; @@ -663,6 +672,7 @@ export function App(): ReactElement { }; input.onchange = async () => { + window.removeEventListener("focus", onWindowFocus); const file = input.files?.[0]; if (!file) { releasePickerBusy(); @@ -683,22 +693,26 @@ export function App(): ReactElement { }; const setBool = (key: keyof AppSettings, value: boolean): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setText = (key: keyof AppSettings, value: string): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, value: number): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setSpeedLimitMbps = (value: number): void => { const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); @@ -747,8 +761,10 @@ export function App(): ReactElement { [order[idx], order[target]] = [order[target], order[idx]]; setDownloadsSortDescending(false); packageOrderRef.current = order; - void window.rd.reorderPackages(order); - }, []); + void window.rd.reorderPackages(order).catch((error) => { + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { const currentOrder = packageOrderRef.current; @@ -760,8 +776,10 @@ export function App(): ReactElement { } setDownloadsSortDescending(false); packageOrderRef.current = nextOrder; - void window.rd.reorderPackages(nextOrder); - }, []); + void window.rd.reorderPackages(nextOrder).catch((error) => { + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; @@ -810,8 +828,54 @@ export function App(): ReactElement { draggedPackageIdRef.current = null; }, []); + const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => { + setEditingPackageId(packageId); + setEditingName(packageName); + }, []); + + const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { + setEditingPackageId(null); + const normalized = nextName.trim(); + if (normalized && normalized !== currentName.trim()) { + void window.rd.renamePackage(packageId, normalized).catch((error) => { + showToast(`Umbenennen fehlgeschlagen: ${String(error)}`, 2400); + }); + } + }, [showToast]); + + const onPackageToggleCollapse = useCallback((packageId: string): void => { + setCollapsedPackages((prev) => ({ ...prev, [packageId]: !(prev[packageId] ?? false) })); + }, []); + + const onPackageCancel = useCallback((packageId: string): void => { + void window.rd.cancelPackage(packageId).catch((error) => { + showToast(`Paket-Löschung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const onPackageMoveUp = useCallback((packageId: string): void => { + movePackage(packageId, "up"); + }, [movePackage]); + + const onPackageMoveDown = useCallback((packageId: string): void => { + movePackage(packageId, "down"); + }, [movePackage]); + + const onPackageToggle = useCallback((packageId: string): void => { + void window.rd.togglePackage(packageId).catch((error) => { + showToast(`Paket-Umschalten fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + + const onPackageRemoveItem = useCallback((itemId: string): void => { + void window.rd.removeItem(itemId).catch((error) => { + showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [showToast]); + const schedules = settingsDraft.bandwidthSchedules ?? []; const addSchedule = (): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ @@ -820,6 +884,7 @@ export function App(): ReactElement { })); }; const removeSchedule = (idx: number): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ @@ -828,6 +893,7 @@ export function App(): ReactElement { })); }; const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { + settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ @@ -1038,25 +1104,17 @@ export function App(): ReactElement { isEditing={editingPackageId === pkg.id} editingName={editingName} collapsed={collapsedPackages[pkg.id] ?? false} - onStartEdit={() => { setEditingPackageId(pkg.id); setEditingName(pkg.name); }} - onFinishEdit={(name) => { - setEditingPackageId(null); - const nextName = name.trim(); - if (nextName && nextName !== pkg.name.trim()) { - void window.rd.renamePackage(pkg.id, nextName); - } - }} + onStartEdit={onPackageStartEdit} + onFinishEdit={onPackageFinishEdit} onEditChange={setEditingName} - onToggleCollapse={() => { - setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) })); - }} - onCancel={() => { void window.rd.cancelPackage(pkg.id); }} - onMoveUp={() => movePackage(pkg.id, "up")} - onMoveDown={() => movePackage(pkg.id, "down")} - onToggle={() => { void window.rd.togglePackage(pkg.id); }} - onRemoveItem={(itemId) => { void window.rd.removeItem(itemId); }} - onDragStart={() => onPackageDragStart(pkg.id)} - onDrop={() => onPackageDrop(pkg.id)} + onToggleCollapse={onPackageToggleCollapse} + onCancel={onPackageCancel} + onMoveUp={onPackageMoveUp} + onMoveDown={onPackageMoveDown} + onToggle={onPackageToggle} + onRemoveItem={onPackageRemoveItem} + onDragStart={onPackageDragStart} + onDrop={onPackageDrop} onDragEnd={onPackageDragEnd} /> ))} @@ -1309,17 +1367,17 @@ interface PackageCardProps { isEditing: boolean; editingName: string; collapsed: boolean; - onStartEdit: () => void; - onFinishEdit: (name: string) => void; + onStartEdit: (packageId: string, packageName: string) => void; + onFinishEdit: (packageId: string, currentName: string, nextName: string) => void; onEditChange: (name: string) => void; - onToggleCollapse: () => void; - onCancel: () => void; - onMoveUp: () => void; - onMoveDown: () => void; - onToggle: () => void; + onToggleCollapse: (packageId: string) => void; + onCancel: (packageId: string) => void; + onMoveUp: (packageId: string) => void; + onMoveDown: (packageId: string) => void; + onToggle: (packageId: string) => void; onRemoveItem: (itemId: string) => void; - onDragStart: () => void; - onDrop: () => void; + onDragStart: (packageId: string) => void; + onDrop: (packageId: string) => void; onDragEnd: () => void; } @@ -1331,27 +1389,27 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs const progress = Math.floor((done / total) * 100); const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => { - if (e.key === "Enter") { onFinishEdit(editingName); } - if (e.key === "Escape") { onFinishEdit(pkg.name); } + if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); } + if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); } }; return ( <article className={`package-card${pkg.enabled ? "" : " disabled-pkg"}`} draggable - onDragStart={(event) => { event.stopPropagation(); onDragStart(); }} + onDragStart={(event) => { event.stopPropagation(); onDragStart(pkg.id); }} onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }} - onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(); }} + onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop(pkg.id); }} onDragEnd={(event) => { event.stopPropagation(); onDragEnd(); }} > <header> <div className="pkg-info"> <div className="pkg-name-row"> - <input type="checkbox" checked={pkg.enabled} onChange={onToggle} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> + <input type="checkbox" checked={pkg.enabled} onChange={() => onToggle(pkg.id)} title={pkg.enabled ? "Paket aktiv" : "Paket deaktiviert"} /> {isEditing ? ( - <input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(editingName)} onKeyDown={onKeyDown} autoFocus /> + <input className="rename-input" value={editingName} onChange={(e) => onEditChange(e.target.value)} onBlur={() => onFinishEdit(pkg.id, pkg.name, editingName)} onKeyDown={onKeyDown} autoFocus /> ) : ( - <h4 onDoubleClick={onStartEdit} title="Doppelklick zum Umbenennen">{pkg.name}</h4> + <h4 onDoubleClick={() => onStartEdit(pkg.id, pkg.name)} title="Doppelklick zum Umbenennen">{pkg.name}</h4> )} </div> <span>{done}/{total} fertig {failed > 0 && `· ${failed} Fehler `}{cancelled > 0 && `· ${cancelled} abgebrochen `} @@ -1359,11 +1417,11 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs </span> </div> <div className="pkg-actions"> - <button className="btn" onClick={onToggleCollapse}>{collapsed ? "Ausklappen" : "Einklappen"}</button> - <button className="btn" disabled={isFirst} onClick={onMoveUp} title="Nach oben">▲</button> - <button className="btn" disabled={isLast} onClick={onMoveDown} title="Nach unten">▼</button> - <button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={onToggle}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button> - <button className="btn danger" onClick={onCancel}>Paket löschen</button> + <button className="btn" onClick={() => onToggleCollapse(pkg.id)}>{collapsed ? "Ausklappen" : "Einklappen"}</button> + <button className="btn" disabled={isFirst} onClick={() => onMoveUp(pkg.id)} title="Nach oben">▲</button> + <button className="btn" disabled={isLast} onClick={() => onMoveDown(pkg.id)} title="Nach unten">▼</button> + <button className={`btn${pkg.enabled ? "" : " btn-active"}`} onClick={() => onToggle(pkg.id)}>{pkg.enabled ? "Paket stoppen" : "Paket starten"}</button> + <button className="btn danger" onClick={() => onCancel(pkg.id)}>Paket löschen</button> </div> </header> <div className="progress"><div style={{ width: `${progress}%` }} /></div> diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 368b8b9..29f1a21 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -442,7 +442,7 @@ describe("debrid service", () => { expect(resolved.get(link)).toBe("Bulletproof.S01E01.German.DL.DD20.Synced.720p.AmazonHD.h264-GDR.part01.rar"); }); - it("falls back to provider unrestrict for unresolved filename scan", async () => { + it("does not unrestrict non-rapidgator links during filename scan", async () => { const settings = { ...defaultSettings(), token: "rd-token", @@ -455,6 +455,7 @@ describe("debrid service", () => { const linkFromPage = "https://rapidgator.net/file/11111111111111111111111111111111"; const linkFromProvider = "https://hoster.example/file/22222222222222222222222222222222"; + let unrestrictCalls = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; @@ -467,6 +468,7 @@ describe("debrid service", () => { } if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + unrestrictCalls += 1; const body = init?.body; const bodyText = body instanceof URLSearchParams ? body.toString() : String(body || ""); const linkValue = new URLSearchParams(bodyText).get("link") || ""; @@ -492,10 +494,10 @@ describe("debrid service", () => { }); expect(resolved.get(linkFromPage)).toBe("from-page.part1.rar"); - expect(resolved.get(linkFromProvider)).toBe("from-provider.part2.rar"); + expect(resolved.has(linkFromProvider)).toBe(false); + expect(unrestrictCalls).toBe(0); expect(events).toEqual(expect.arrayContaining([ - { link: linkFromPage, fileName: "from-page.part1.rar" }, - { link: linkFromProvider, fileName: "from-provider.part2.rar" } + { link: linkFromPage, fileName: "from-page.part1.rar" } ])); }); @@ -533,7 +535,7 @@ describe("debrid service", () => { expect(unrestrictCalls).toBe(0); }); - it("does not map AllDebrid filename infos by index when response link is missing", async () => { + it("maps AllDebrid filename infos by index when response link is missing", async () => { const settings = { ...defaultSettings(), token: "", @@ -572,7 +574,9 @@ describe("debrid service", () => { const service = new DebridService(settings); const resolved = await service.resolveFilenames([linkA, linkB]); - expect(resolved.size).toBe(0); + expect(resolved.get(linkA)).toBe("wrong-a.mkv"); + expect(resolved.get(linkB)).toBe("wrong-b.mkv"); + expect(resolved.size).toBe(2); }); it("retries AllDebrid filename infos after transient server error", async () => { @@ -738,6 +742,11 @@ describe("extractRapidgatorFilenameFromHtml", () => { expect(extractRapidgatorFilenameFromHtml("")).toBe(""); }); + it("ignores broad body text that is not a labeled filename", () => { + const html = "<html><body>Please download file now from mirror.mkv</body></html>"; + expect(extractRapidgatorFilenameFromHtml(html)).toBe(""); + }); + it("extracts from File name label in page body", () => { const html = '<html><body>File name: <b>Show.S02E03.720p.part01.rar</b></body></html>'; expect(extractRapidgatorFilenameFromHtml(html)).toBe("Show.S02E03.720p.part01.rar");