diff --git a/package-lock.json b/package-lock.json index 9b61890..966d425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.5", + "version": "1.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.5", + "version": "1.4.6", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index edc7488..94d536f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.5", + "version": "1.4.6", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index edb229c..e2d7d44 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -175,6 +175,8 @@ export class DownloadManager extends EventEmitter { private packagePostProcessTasks = new Map>(); + private packagePostProcessAbortControllers = new Map(); + private reservedTargetPaths = new Map(); private claimedTargetPathByItem = new Map(); @@ -189,6 +191,8 @@ export class DownloadManager extends EventEmitter { private lastSchedulerHeartbeatAt = 0; + private lastReconnectMarkAt = 0; + public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { super(); this.settings = settings; @@ -435,6 +439,7 @@ export class DownloadManager extends EventEmitter { public clearAll(): void { this.stop(); + this.abortPostProcessing("clear_all"); this.session.packageOrder = []; this.session.packages = {}; this.session.items = {}; @@ -446,6 +451,7 @@ export class DownloadManager extends EventEmitter { this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.packagePostProcessTasks.clear(); + this.packagePostProcessAbortControllers.clear(); this.packagePostProcessQueue = Promise.resolve(); this.summary = null; this.persistNow(); @@ -764,6 +770,9 @@ export class DownloadManager extends EventEmitter { if (!pkg || pkg.cancelled || pkg.status !== "completed") { continue; } + if (this.packagePostProcessTasks.has(packageId)) { + continue; + } const items = pkg.itemIds .map((itemId) => this.session.items[itemId]) @@ -995,6 +1004,7 @@ export class DownloadManager extends EventEmitter { this.session.summaryText = ""; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.lastReconnectMarkAt = 0; this.speedEvents = []; this.speedBytesLastWindow = 0; this.summary = null; @@ -1008,6 +1018,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.abortPostProcessing("stop"); for (const active of this.activeTasks.values()) { active.abortReason = "stop"; active.abortController.abort("stop"); @@ -1022,6 +1033,7 @@ export class DownloadManager extends EventEmitter { this.session.paused = false; this.session.reconnectUntil = 0; this.session.reconnectReason = ""; + this.abortPostProcessing("shutdown"); let requeuedItems = 0; for (const active of this.activeTasks.values()) { @@ -1050,6 +1062,18 @@ export class DownloadManager extends EventEmitter { } } + for (const item of Object.values(this.session.items)) { + if (item.status === "completed" && /^Entpacken/i.test(item.fullStatus || "")) { + item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + item.updatedAt = nowMs(); + const pkg = this.session.packages[item.packageId]; + if (pkg) { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + } + } + this.speedEvents = []; this.speedBytesLastWindow = 0; this.runItemIds.clear(); @@ -1288,22 +1312,55 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.delete(itemId); } + private abortPostProcessing(reason: string): void { + for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) { + if (!controller.signal.aborted) { + controller.abort(reason); + } + + const pkg = this.session.packages[packageId]; + if (!pkg) { + continue; + } + + if (pkg.status === "extracting" || pkg.status === "integrity_check") { + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + } + + for (const itemId of pkg.itemIds) { + const item = this.session.items[itemId]; + if (!item || item.status !== "completed") { + continue; + } + if (/^Entpacken/i.test(item.fullStatus || "")) { + item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + item.updatedAt = nowMs(); + } + } + } + } + private runPackagePostProcessing(packageId: string): Promise { const existing = this.packagePostProcessTasks.get(packageId); if (existing) { return existing; } + const abortController = new AbortController(); + this.packagePostProcessAbortControllers.set(packageId, abortController); + const task = this.packagePostProcessQueue .catch(() => undefined) .then(async () => { - await this.handlePackagePostProcessing(packageId); + await this.handlePackagePostProcessing(packageId, abortController.signal); }) .catch((error) => { logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`); }) .finally(() => { this.packagePostProcessTasks.delete(packageId); + this.packagePostProcessAbortControllers.delete(packageId); this.persistSoon(); this.emitState(); }); @@ -1393,8 +1450,15 @@ export class DownloadManager extends EventEmitter { } if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) { - this.markQueuedAsReconnectWait(); - await sleep(200); + const markNow = nowMs(); + if (markNow - this.lastReconnectMarkAt >= 900) { + this.lastReconnectMarkAt = markNow; + const changed = this.markQueuedAsReconnectWait(); + if (!changed) { + this.emitState(); + } + } + await sleep(220); continue; } @@ -1431,6 +1495,7 @@ export class DownloadManager extends EventEmitter { const until = nowMs() + this.settings.reconnectWaitSeconds * 1000; this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until); this.session.reconnectReason = reason; + this.lastReconnectMarkAt = 0; for (const active of this.activeTasks.values()) { if (active.resumable) { @@ -1443,7 +1508,8 @@ export class DownloadManager extends EventEmitter { this.emitState(); } - private markQueuedAsReconnectWait(): void { + private markQueuedAsReconnectWait(): boolean { + let changed = false; for (const item of Object.values(this.session.items)) { const pkg = this.session.packages[item.packageId]; if (!pkg || pkg.cancelled || !pkg.enabled) { @@ -1453,9 +1519,13 @@ export class DownloadManager extends EventEmitter { item.status = "reconnect_wait"; item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`; item.updatedAt = nowMs(); + changed = true; } } - this.emitState(); + if (changed) { + this.emitState(); + } + return changed; } private findNextQueuedItem(): { packageId: string; itemId: string } | null { @@ -1923,6 +1993,16 @@ export class DownloadManager extends EventEmitter { let written = writeMode === "a" ? existingBytes : 0; let windowBytes = 0; let windowStarted = nowMs(); + const itemCount = Object.keys(this.session.items).length; + const uiUpdateIntervalMs = itemCount >= 1500 + ? 650 + : itemCount >= 700 + ? 420 + : itemCount >= 250 + ? 280 + : 170; + let lastUiEmitAt = 0; + let lastProgressPercent = item.progressPercent; const waitDrain = (): Promise => new Promise((resolve, reject) => { const onDrain = (): void => { @@ -2027,8 +2107,14 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = written; item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0; item.fullStatus = `Download läuft (${providerLabel(item.provider)})`; - item.updatedAt = nowMs(); - this.emitState(); + const nowTick = nowMs(); + const progressChanged = item.progressPercent !== lastProgressPercent; + if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) { + item.updatedAt = nowTick; + this.emitState(); + lastUiEmitAt = nowTick; + lastProgressPercent = item.progressPercent; + } } } finally { await new Promise((resolve, reject) => { @@ -2274,11 +2360,14 @@ export class DownloadManager extends EventEmitter { } } - private async handlePackagePostProcessing(packageId: string): Promise { + private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise { const pkg = this.session.packages[packageId]; if (!pkg || pkg.cancelled) { return; } + if (signal?.aborted) { + return; + } const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const success = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; @@ -2299,9 +2388,13 @@ export class DownloadManager extends EventEmitter { this.emitState(); const updateExtractingStatus = (text: string): void => { + const updatedAt = nowMs(); for (const entry of completedItems) { + if (entry.fullStatus === text) { + continue; + } entry.fullStatus = text; - entry.updatedAt = nowMs(); + entry.updatedAt = updatedAt; } }; @@ -2317,10 +2410,19 @@ export class DownloadManager extends EventEmitter { removeLinks: this.settings.removeLinkFilesAfterExtract, removeSamples: this.settings.removeSamplesAfterExtract, passwordList: this.settings.archivePasswordList, + signal, onProgress: (progress) => { const label = progress.phase === "done" ? "Entpacken 100%" - : `Entpacken ${progress.percent}% (${progress.current}/${progress.total})`; + : (() => { + const archive = progress.archiveName ? ` · ${progress.archiveName}` : ""; + const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000 + ? ` · ${Math.floor(progress.elapsedMs / 1000)}s` + : ""; + const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); + return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`; + })(); updateExtractingStatus(label); this.emitState(); } @@ -2343,6 +2445,19 @@ export class DownloadManager extends EventEmitter { pkg.status = "completed"; } } catch (error) { + const reasonRaw = String(error || ""); + if (reasonRaw.includes("aborted:extract")) { + for (const entry of completedItems) { + if (/^Entpacken/i.test(entry.fullStatus || "")) { + entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + } + entry.updatedAt = nowMs(); + } + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`); + return; + } const reason = compactErrorText(error); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); for (const entry of completedItems) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index b15a8fa..afc73a3 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -20,6 +20,7 @@ export interface ExtractOptions { removeLinks: boolean; removeSamples: boolean; passwordList?: string; + signal?: AbortSignal; onProgress?: (update: ExtractProgressUpdate) => void; } @@ -28,9 +29,18 @@ export interface ExtractProgressUpdate { total: number; percent: number; archiveName: string; + archivePercent?: number; + elapsedMs?: number; phase: "extracting" | "done"; } +const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024; +const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json"; + +type ExtractResumeState = { + completedArchives: string[]; +}; + function findArchiveCandidates(packageDir: string): string[] { const files = fs.readdirSync(packageDir, { withFileTypes: true }) .filter((entry) => entry.isFile()) @@ -59,6 +69,77 @@ function cleanErrorText(text: string): string { return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240); } +function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_BUFFER): string { + const next = `${base}${chunk}`; + if (next.length <= maxLen) { + return next; + } + return next.slice(next.length - maxLen); +} + +function parseProgressPercent(chunk: string): number | null { + const text = String(chunk || ""); + const regex = /(?:^|\D)(\d{1,3})%/g; + let match: RegExpExecArray | null = regex.exec(text); + let latest: number | null = null; + while (match) { + const value = Number(match[1]); + if (Number.isFinite(value) && value >= 0 && value <= 100) { + latest = value; + } + match = regex.exec(text); + } + return latest; +} + +function shouldPreferExternalZip(archivePath: string): boolean { + try { + const stat = fs.statSync(archivePath); + return stat.size >= 64 * 1024 * 1024; + } catch { + return true; + } +} + +function extractProgressFilePath(packageDir: string): string { + return path.join(packageDir, EXTRACT_PROGRESS_FILE); +} + +function readExtractResumeState(packageDir: string): Set { + const progressPath = extractProgressFilePath(packageDir); + if (!fs.existsSync(progressPath)) { + return new Set(); + } + try { + const payload = JSON.parse(fs.readFileSync(progressPath, "utf8")) as Partial; + const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : []; + return new Set(names.map((value) => String(value || "").trim()).filter(Boolean)); + } catch { + return new Set(); + } +} + +function writeExtractResumeState(packageDir: string, completedArchives: Set): void { + const progressPath = extractProgressFilePath(packageDir); + const payload: ExtractResumeState = { + completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b)) + }; + fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8"); +} + +function clearExtractResumeState(packageDir: string): void { + try { + fs.rmSync(extractProgressFilePath(packageDir), { force: true }); + } catch { + // ignore + } +} + +function isExtractAbortError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("aborted:extract") || text.includes("extract_aborted"); +} + function archivePasswords(listInput: string): string[] { const custom = String(listInput || "") .split(/\r?\n/g) @@ -109,48 +190,81 @@ function isNoExtractorError(errorText: string): boolean { type ExtractSpawnResult = { ok: boolean; missingCommand: boolean; + aborted: boolean; errorText: string; }; -function runExtractCommand(command: string, args: string[]): Promise { +function runExtractCommand( + command: string, + args: string[], + onChunk?: (chunk: string) => void, + signal?: AbortSignal +): Promise { + if (signal?.aborted) { + return Promise.resolve({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" }); + } + return new Promise((resolve) => { let settled = false; let output = ""; const child = spawn(command, args, { windowsHide: true }); - child.stdout.on("data", (chunk) => { - output += String(chunk || ""); - }); - child.stderr.on("data", (chunk) => { - output += String(chunk || ""); - }); - - child.on("error", (error) => { + const finish = (result: ExtractSpawnResult): void => { if (settled) { return; } settled = true; + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + } + resolve(result); + }; + + const onAbort = signal + ? (): void => { + try { + child.kill(); + } catch { + // ignore + } + finish({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" }); + } + : null; + if (signal && onAbort) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + child.stdout.on("data", (chunk) => { + const text = String(chunk || ""); + output = appendLimited(output, text); + onChunk?.(text); + }); + child.stderr.on("data", (chunk) => { + const text = String(chunk || ""); + output = appendLimited(output, text); + onChunk?.(text); + }); + + child.on("error", (error) => { const text = cleanErrorText(String(error)); - resolve({ + finish({ ok: false, missingCommand: text.toLowerCase().includes("enoent"), + aborted: false, errorText: text }); }); child.on("close", (code) => { - if (settled) { - return; - } - settled = true; if (code === 0 || code === 1) { - resolve({ ok: true, missingCommand: false, errorText: "" }); + finish({ ok: true, missingCommand: false, aborted: false, errorText: "" }); return; } const cleaned = cleanErrorText(output); - resolve({ + finish({ ok: false, missingCommand: false, + aborted: false, errorText: cleaned || `Exit Code ${String(code ?? "?")}` }); }); @@ -208,7 +322,9 @@ async function runExternalExtract( archivePath: string, targetDir: string, conflictMode: ConflictMode, - passwordCandidates: string[] + passwordCandidates: string[], + onArchiveProgress?: (percent: number) => void, + signal?: AbortSignal ): Promise { const command = await resolveExtractorCommand(); const passwords = passwordCandidates; @@ -216,13 +332,35 @@ async function runExternalExtract( fs.mkdirSync(targetDir, { recursive: true }); + let announcedStart = false; + let bestPercent = 0; + for (const password of passwords) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } + if (!announcedStart) { + announcedStart = true; + onArchiveProgress?.(0); + } const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password); - const result = await runExtractCommand(command, args); + const result = await runExtractCommand(command, args, (chunk) => { + const parsed = parseProgressPercent(chunk); + if (parsed === null || parsed <= bestPercent) { + return; + } + bestPercent = parsed; + onArchiveProgress?.(bestPercent); + }, signal); if (result.ok) { + onArchiveProgress?.(100); return; } + if (result.aborted) { + throw new Error("aborted:extract"); + } + if (result.missingCommand) { resolvedExtractorCommand = null; resolveFailureReason = NO_EXTRACTOR_MESSAGE; @@ -467,9 +605,14 @@ function removeEmptyDirectoryTree(rootDir: string): number { } export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } + const candidates = findArchiveCandidates(options.packageDir); logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); if (candidates.length === 0) { + clearExtractResumeState(options.packageDir); logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); return { extracted: 0, failed: 0, lastError: "" }; } @@ -477,68 +620,148 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const conflictMode = effectiveConflictMode(options.conflictMode); const passwordCandidates = archivePasswords(options.passwordList || ""); const beforeFingerprint = captureDirFingerprint(options.targetDir); - let extracted = 0; + const resumeCompleted = readExtractResumeState(options.packageDir); + const resumeCompletedAtStart = resumeCompleted.size; + const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath))); + for (const archiveName of Array.from(resumeCompleted.values())) { + if (!candidateNames.has(archiveName)) { + resumeCompleted.delete(archiveName); + } + } + if (resumeCompleted.size > 0) { + writeExtractResumeState(options.packageDir, resumeCompleted); + } else { + clearExtractResumeState(options.packageDir); + } + + const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath))); + let extracted = resumeCompleted.size; let failed = 0; let lastError = ""; - const extractedArchives: string[] = []; + const extractedArchives = new Set(); + for (const archivePath of candidates) { + if (resumeCompleted.has(path.basename(archivePath))) { + extractedArchives.add(archivePath); + } + } - const emitProgress = (current: number, archiveName: string, phase: "extracting" | "done"): void => { + const emitProgress = ( + current: number, + archiveName: string, + phase: "extracting" | "done", + archivePercent?: number, + elapsedMs?: number + ): void => { if (!options.onProgress) { return; } const total = Math.max(1, candidates.length); - const percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100))); - options.onProgress({ current, total, percent, archiveName, phase }); + let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100))); + if (phase !== "done") { + const boundedCurrent = Math.max(0, Math.min(total, current)); + const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0))); + percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100))); + } + options.onProgress({ + current, + total, + percent, + archiveName, + archivePercent, + elapsedMs, + phase + }); }; - emitProgress(0, "", "extracting"); + emitProgress(extracted, "", "extracting"); - for (const archivePath of candidates) { + for (const archivePath of pendingCandidates) { + if (options.signal?.aborted) { + throw new Error("aborted:extract"); + } const archiveName = path.basename(archivePath); - emitProgress(extracted + failed, archiveName, "extracting"); + const archiveStartedAt = Date.now(); + let archivePercent = 0; + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0); + const pulseTimer = setInterval(() => { + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + }, 1100); logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`); try { const ext = path.extname(archivePath).toLowerCase(); if (ext === ".zip") { - try { - extractZipArchive(archivePath, options.targetDir, options.conflictMode); - } catch { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); + const preferExternal = shouldPreferExternalZip(archivePath); + if (preferExternal) { + try { + 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); + } catch (error) { + if (isNoExtractorError(String(error))) { + extractZipArchive(archivePath, options.targetDir, options.conflictMode); + } else { + throw error; + } + } + } else { + try { + extractZipArchive(archivePath, options.targetDir, options.conflictMode); + archivePercent = 100; + } catch { + 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); + } } } else { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); + 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); } extracted += 1; - extractedArchives.push(archivePath); + extractedArchives.add(archivePath); + resumeCompleted.add(archiveName); + writeExtractResumeState(options.packageDir, resumeCompleted); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); - emitProgress(extracted + failed, archiveName, "extracting"); + archivePercent = 100; + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); } catch (error) { failed += 1; const errorText = String(error); + if (isExtractAbortError(errorText)) { + throw new Error("aborted:extract"); + } lastError = errorText; logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`); - emitProgress(extracted + failed, archiveName, "extracting"); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); if (isNoExtractorError(errorText)) { const remaining = candidates.length - (extracted + failed); if (remaining > 0) { failed += remaining; - emitProgress(candidates.length, archiveName, "extracting"); + emitProgress(candidates.length, archiveName, "extracting", 0, Date.now() - archiveStartedAt); } break; } + } finally { + clearInterval(pulseTimer); } } if (extracted > 0) { const afterFingerprint = captureDirFingerprint(options.targetDir); const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint); - if (!changedOutput && conflictMode !== "skip") { + const hadResumeProgress = resumeCompletedAtStart > 0; + if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) { lastError = "Keine entpackten Dateien erkannt"; failed += extracted; extracted = 0; logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`); } else { - const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode); + const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values()); + const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode); if (options.cleanupMode !== "none") { logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`); } @@ -551,6 +774,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`); } + if (failed === 0 && resumeCompleted.size >= candidates.length) { + clearExtractResumeState(options.packageDir); + } + if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) { const removedDirs = removeEmptyDirectoryTree(options.packageDir); if (removedDirs > 0) { @@ -568,6 +795,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } + if (failed > 0) { + if (resumeCompleted.size > 0) { + writeExtractResumeState(options.packageDir, resumeCompleted); + } else { + clearExtractResumeState(options.packageDir); + } + } + emitProgress(candidates.length, "", "done"); logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f6704ff..d563a13 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import type { AppSettings, AppTheme, @@ -97,6 +97,7 @@ export function App(): ReactElement { ]); const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const activeCollectorTabRef = useRef(activeCollectorTab); + const activeTabRef = useRef(tab); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); @@ -114,6 +115,10 @@ export function App(): ReactElement { activeCollectorTabRef.current = activeCollectorTab; }, [activeCollectorTab]); + useEffect(() => { + activeTabRef.current = tab; + }, [tab]); + useEffect(() => { settingsDirtyRef.current = settingsDirty; }, [settingsDirty]); @@ -146,6 +151,22 @@ export function App(): ReactElement { unsubscribe = window.rd.onStateUpdate((state) => { latestStateRef.current = state; if (stateFlushTimerRef.current) { return; } + + const itemCount = Object.keys(state.session.items).length; + let flushDelay = itemCount >= 1500 + ? 850 + : itemCount >= 700 + ? 620 + : itemCount >= 250 + ? 420 + : 180; + if (!state.session.running) { + flushDelay = Math.min(flushDelay, 260); + } + if (activeTabRef.current !== "downloads") { + flushDelay = Math.max(flushDelay, 320); + } + stateFlushTimerRef.current = setTimeout(() => { stateFlushTimerRef.current = null; if (latestStateRef.current) { @@ -156,7 +177,7 @@ export function App(): ReactElement { } latestStateRef.current = null; } - }, 220); + }, flushDelay); }); unsubClipboard = window.rd.onClipboardDetected((links) => { showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000); @@ -182,7 +203,7 @@ export function App(): ReactElement { const packages = useMemo(() => snapshot.session.packageOrder .map((id: string) => snapshot.session.packages[id]) - .filter(Boolean), [snapshot]); + .filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]); const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]); @@ -643,7 +664,7 @@ export function App(): ReactElement { } } return map; - }, [snapshot]); + }, [snapshot.session.items]); return (
void; } -function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { +const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { const done = items.filter((item) => item.status === "completed").length; const failed = items.filter((item) => item.status === "failed").length; const cancelled = items.filter((item) => item.status === "cancelled").length; @@ -1130,4 +1151,45 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi } ); -} +}, (prev, next) => { + if (prev.pkg.id !== next.pkg.id) { + return false; + } + if (prev.pkg.updatedAt !== next.pkg.updatedAt + || prev.pkg.status !== next.pkg.status + || prev.pkg.enabled !== next.pkg.enabled + || prev.pkg.name !== next.pkg.name) { + return false; + } + if (prev.packageSpeed !== next.packageSpeed + || prev.isFirst !== next.isFirst + || prev.isLast !== next.isLast + || prev.isEditing !== next.isEditing + || prev.collapsed !== next.collapsed) { + return false; + } + if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) { + return false; + } + if (prev.items.length !== next.items.length) { + return false; + } + for (let index = 0; index < prev.items.length; index += 1) { + const a = prev.items[index]; + const b = next.items[index]; + if (!a || !b) { + return false; + } + if (a.id !== b.id + || a.updatedAt !== b.updatedAt + || a.status !== b.status + || a.progressPercent !== b.progressPercent + || a.speedBps !== b.speedBps + || a.retries !== b.retries + || a.provider !== b.provider + || a.fullStatus !== b.fullStatus) { + return false; + } + } + return true; +}); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index bd9c6c5..ed3a0d9 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -2807,6 +2807,69 @@ describe("download manager", () => { } }); + it("marks extracting items as resumable extraction on shutdown", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + + const session = emptySession(); + const packageId = "extract-stop-pkg"; + const itemId = "extract-stop-item"; + const createdAt = Date.now() - 20_000; + const outputDir = path.join(root, "downloads", "extract-stop"); + const extractDir = path.join(root, "extract", "extract-stop"); + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "extract-stop", + outputDir, + extractDir, + status: "extracting", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/extract-stop", + provider: "realdebrid", + status: "completed", + retries: 0, + speedBps: 0, + downloadedBytes: 123, + totalBytes: 123, + progressPercent: 100, + fileName: "extract-stop.part01.rar", + targetPath: path.join(outputDir, "extract-stop.part01.rar"), + resumable: true, + attempts: 1, + lastError: "", + fullStatus: "Entpacken 40%", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: true + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + manager.prepareForShutdown(); + const snapshot = manager.getSnapshot(); + expect(snapshot.session.packages[packageId]?.status).toBe("queued"); + expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)"); + }); + it("recovers pending extraction on startup for completed package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 110e379..3a1375c 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -274,4 +274,61 @@ describe("extractor", () => { expect(result.failed).toBe(1); expect(fs.existsSync(targetDir)).toBe(false); }); + + it("resumes extraction from persisted progress file", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zipA = new AdmZip(); + zipA.addFile("a.txt", Buffer.from("a")); + zipA.writeZip(path.join(packageDir, "a.zip")); + + const zipB = new AdmZip(); + zipB.addFile("b.txt", Buffer.from("b")); + zipB.writeZip(path.join(packageDir, "b.zip")); + + fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["a.zip"] }), "utf8"); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(2); + expect(result.failed).toBe(0); + expect(fs.existsSync(path.join(targetDir, "b.txt"))).toBe(true); + expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(false); + }); + + it("aborts extraction immediately when abort signal is set", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("file.txt", Buffer.from("x")); + zip.writeZip(path.join(packageDir, "file.zip")); + + const controller = new AbortController(); + controller.abort(); + + await expect(extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false, + signal: controller.signal + })).rejects.toThrow("aborted:extract"); + }); });