From 4fc0ce26f37f13300c0bdfe0182119eeb75bb744 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 27 Feb 2026 16:23:19 +0100 Subject: [PATCH] Ship UI productivity upgrades and extraction progress flow in v1.4.0 --- package.json | 2 +- src/main/constants.ts | 1 + src/main/download-manager.ts | 31 +++- src/main/extractor.ts | 59 ++++++-- src/main/storage.ts | 4 +- src/renderer/App.tsx | 284 +++++++++++++++++++++++++++-------- src/renderer/styles.css | 22 +++ src/shared/types.ts | 1 + tests/extractor.test.ts | 35 +++++ tests/storage.test.ts | 9 ++ 10 files changed, 372 insertions(+), 76 deletions(-) diff --git a/package.json b/package.json index 877a156..3527917 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.3.11", + "version": "1.4.0", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index 9886050..ad387f4 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -33,6 +33,7 @@ export function defaultSettings(): AppSettings { megaPassword: "", bestToken: "", allDebridToken: "", + archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index ff6b650..e578c25 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -654,7 +654,7 @@ export class DownloadManager extends EventEmitter { continue; } - logger.info(`Nachtraegliches Cleanup geprueft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`); + logger.info(`Nachträgliches Cleanup geprüft: pkg=${pkg.name}, targets=${targets.size}, marker=${pkg.itemIds.some((id) => /entpack/i.test(this.session.items[id]?.fullStatus || ""))}`); let removed = 0; for (const targetPath of targets) { @@ -670,20 +670,20 @@ export class DownloadManager extends EventEmitter { } if (removed > 0) { - logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: ${removed} Datei(en) geloescht`); + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: ${removed} Datei(en) gelöscht`); if (!this.directoryHasAnyFiles(pkg.outputDir)) { const removedDirs = this.removeEmptyDirectoryTree(pkg.outputDir); if (removedDirs > 0) { - logger.info(`Nachtraegliches Cleanup entfernte leere Download-Ordner fuer ${pkg.name}: ${removedDirs}`); + logger.info(`Nachträgliches Cleanup entfernte leere Download-Ordner für ${pkg.name}: ${removedDirs}`); } } } else { - logger.info(`Nachtraegliches Archive-Cleanup fuer ${pkg.name}: keine Dateien entfernt`); + logger.info(`Nachträgliches Archive-Cleanup für ${pkg.name}: keine Dateien entfernt`); } } }) .catch((error) => { - logger.warn(`Nachtraegliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); + logger.warn(`Nachträgliches Archive-Cleanup fehlgeschlagen: ${compactErrorText(error)}`); }); } @@ -1900,6 +1900,17 @@ export class DownloadManager extends EventEmitter { if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { pkg.status = "extracting"; this.emitState(); + + const updateExtractingStatus = (text: string): void => { + for (const entry of completedItems) { + entry.fullStatus = text; + entry.updatedAt = nowMs(); + } + }; + + updateExtractingStatus("Entpacken 0%"); + this.emitState(); + try { const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -1907,7 +1918,15 @@ export class DownloadManager extends EventEmitter { cleanupMode: this.settings.cleanupMode, conflictMode: this.settings.extractConflictMode, removeLinks: this.settings.removeLinkFilesAfterExtract, - removeSamples: this.settings.removeSamplesAfterExtract + removeSamples: this.settings.removeSamplesAfterExtract, + passwordList: this.settings.archivePasswordList, + onProgress: (progress) => { + const label = progress.phase === "done" + ? "Entpacken 100%" + : `Entpacken ${progress.percent}% (${progress.current}/${progress.total})`; + updateExtractingStatus(label); + this.emitState(); + } }); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); if (result.failed > 0) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index efb2345..b15a8fa 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -19,6 +19,16 @@ export interface ExtractOptions { conflictMode: ConflictMode; removeLinks: boolean; removeSamples: boolean; + passwordList?: string; + onProgress?: (update: ExtractProgressUpdate) => void; +} + +export interface ExtractProgressUpdate { + current: number; + total: number; + percent: number; + archiveName: string; + phase: "extracting" | "done"; } function findArchiveCandidates(packageDir: string): string[] { @@ -49,12 +59,18 @@ function cleanErrorText(text: string): string { return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240); } -function archivePasswords(): string[] { - const custom = String(process.env.RD_ARCHIVE_PASSWORDS || "") +function archivePasswords(listInput: string): string[] { + const custom = String(listInput || "") + .split(/\r?\n/g) + .map((part) => part.trim()) + .filter(Boolean); + + const fromEnv = String(process.env.RD_ARCHIVE_PASSWORDS || "") .split(/[;,\n]/g) .map((part) => part.trim()) .filter(Boolean); - return Array.from(new Set([...DEFAULT_ARCHIVE_PASSWORDS, ...custom])); + + return Array.from(new Set(["", ...custom, ...fromEnv, ...DEFAULT_ARCHIVE_PASSWORDS])); } function winRarCandidates(): string[] { @@ -188,9 +204,14 @@ async function resolveExtractorCommand(): Promise { throw new Error(resolveFailureReason); } -async function runExternalExtract(archivePath: string, targetDir: string, conflictMode: ConflictMode): Promise { +async function runExternalExtract( + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[] +): Promise { const command = await resolveExtractorCommand(); - const passwords = archivePasswords(); + const passwords = passwordCandidates; let lastError = ""; fs.mkdirSync(targetDir, { recursive: true }); @@ -449,17 +470,32 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ 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) { - logger.info(`Entpacken uebersprungen (keine Archive gefunden): ${options.packageDir}`); + logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); return { extracted: 0, failed: 0, lastError: "" }; } const conflictMode = effectiveConflictMode(options.conflictMode); + const passwordCandidates = archivePasswords(options.passwordList || ""); const beforeFingerprint = captureDirFingerprint(options.targetDir); let extracted = 0; let failed = 0; let lastError = ""; const extractedArchives: string[] = []; + + const emitProgress = (current: number, archiveName: string, phase: "extracting" | "done"): 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 }); + }; + + emitProgress(0, "", "extracting"); + for (const archivePath of candidates) { + const archiveName = path.basename(archivePath); + emitProgress(extracted + failed, archiveName, "extracting"); logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`); try { const ext = path.extname(archivePath).toLowerCase(); @@ -467,23 +503,26 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ try { extractZipArchive(archivePath, options.targetDir, options.conflictMode); } catch { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode); + await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); } } else { - await runExternalExtract(archivePath, options.targetDir, options.conflictMode); + await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates); } extracted += 1; extractedArchives.push(archivePath); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); + emitProgress(extracted + failed, archiveName, "extracting"); } catch (error) { failed += 1; const errorText = String(error); lastError = errorText; logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`); + emitProgress(extracted + failed, archiveName, "extracting"); if (isNoExtractorError(errorText)) { const remaining = candidates.length - (extracted + failed); if (remaining > 0) { failed += remaining; + emitProgress(candidates.length, archiveName, "extracting"); } break; } @@ -497,7 +536,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ lastError = "Keine entpackten Dateien erkannt"; failed += extracted; extracted = 0; - logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgefuehrt.`); + logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`); } else { const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode); if (options.cleanupMode !== "none") { @@ -529,6 +568,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } + emitProgress(candidates.length, "", "done"); + logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`); return { extracted, failed, lastError }; diff --git a/src/main/storage.ts b/src/main/storage.ts index f1f855c..94b67a9 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -55,6 +55,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaPassword: asText(settings.megaPassword), bestToken: asText(settings.bestToken), allDebridToken: asText(settings.allDebridToken), + archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"), rememberToken: Boolean(settings.rememberToken), autoProviderFallback: Boolean(settings.autoProviderFallback), outputDir: asText(settings.outputDir) || defaults.outputDir, @@ -115,7 +116,8 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { megaLogin: "", megaPassword: "", bestToken: "", - allDebridToken: "" + allDebridToken: "", + archivePasswordList: "" }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 988b46c..be6c9f3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -19,6 +19,7 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", megaLogin: "", megaPassword: "", bestToken: "", allDebridToken: "", + archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", providerSecondary: "megadebrid", providerTertiary: "bestdebrid", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, extractDir: "", createExtractSubfolder: true, hybridExtract: true, @@ -46,14 +47,6 @@ const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; -const fallbackProviderOptions: Array<{ value: DebridFallbackProvider; label: string }> = [ - { value: "none", label: "Kein Fallback" }, - { value: "realdebrid", label: providerLabels.realdebrid }, - { value: "megadebrid", label: providerLabels.megadebrid }, - { value: "bestdebrid", label: providerLabels.bestdebrid }, - { value: "alldebrid", label: providerLabels.alldebrid } -]; - function formatSpeedMbps(speedBps: number): string { const mbps = Math.max(0, speedBps) / (1024 * 1024); return `${mbps.toFixed(2)} MB/s`; @@ -85,6 +78,8 @@ export function App(): ReactElement { const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const activeCollectorTabRef = useRef(activeCollectorTab); const draggedPackageIdRef = useRef(null); + const [collapsedPackages, setCollapsedPackages] = useState>({}); + const [downloadSearch, setDownloadSearch] = useState(""); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -148,6 +143,91 @@ export function App(): ReactElement { .map((id: string) => snapshot.session.packages[id]) .filter(Boolean), [snapshot]); + useEffect(() => { + setCollapsedPackages((prev) => { + const next: Record = {}; + for (const pkg of packages) { + next[pkg.id] = prev[pkg.id] ?? false; + } + return next; + }); + }, [packages]); + + const filteredPackages = useMemo(() => { + const query = downloadSearch.trim().toLowerCase(); + if (!query) { + return packages; + } + return packages.filter((pkg) => pkg.name.toLowerCase().includes(query)); + }, [packages, downloadSearch]); + + const allPackagesCollapsed = useMemo(() => ( + packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) + ), [packages, collapsedPackages]); + + const configuredProviders = useMemo(() => { + const list: DebridProvider[] = []; + if (settingsDraft.token.trim()) { + list.push("realdebrid"); + } + if (settingsDraft.megaLogin.trim() && settingsDraft.megaPassword.trim()) { + list.push("megadebrid"); + } + if (settingsDraft.bestToken.trim()) { + list.push("bestdebrid"); + } + if (settingsDraft.allDebridToken.trim()) { + list.push("alldebrid"); + } + return list; + }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]); + + const primaryProviderValue: DebridProvider = useMemo(() => { + if (configuredProviders.includes(settingsDraft.providerPrimary)) { + return settingsDraft.providerPrimary; + } + return configuredProviders[0] ?? "realdebrid"; + }, [configuredProviders, settingsDraft.providerPrimary]); + + const secondaryProviderChoices = useMemo(() => ( + configuredProviders.filter((provider) => provider !== primaryProviderValue) + ), [configuredProviders, primaryProviderValue]); + + const secondaryProviderValue: DebridFallbackProvider = useMemo(() => { + if (secondaryProviderChoices.includes(settingsDraft.providerSecondary as DebridProvider)) { + return settingsDraft.providerSecondary; + } + return "none"; + }, [secondaryProviderChoices, settingsDraft.providerSecondary]); + + const tertiaryProviderChoices = useMemo(() => { + const blocked = new Set([primaryProviderValue]); + if (secondaryProviderValue !== "none") { + blocked.add(secondaryProviderValue); + } + return configuredProviders.filter((provider) => !blocked.has(provider)); + }, [configuredProviders, primaryProviderValue, secondaryProviderValue]); + + const tertiaryProviderValue: DebridFallbackProvider = useMemo(() => { + if (tertiaryProviderChoices.includes(settingsDraft.providerTertiary as DebridProvider)) { + return settingsDraft.providerTertiary; + } + return "none"; + }, [tertiaryProviderChoices, settingsDraft.providerTertiary]); + + const normalizedSettingsDraft: AppSettings = useMemo(() => ({ + ...settingsDraft, + providerPrimary: primaryProviderValue, + providerSecondary: configuredProviders.length >= 2 ? secondaryProviderValue : "none", + providerTertiary: configuredProviders.length >= 3 ? tertiaryProviderValue : "none" + }), [ + settingsDraft, + primaryProviderValue, + configuredProviders.length, + secondaryProviderValue, + tertiaryProviderValue + ]); + const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise => { if (result.error) { if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } @@ -166,11 +246,11 @@ export function App(): ReactElement { const onSaveSettings = async (): Promise => { try { - const result = await window.rd.updateSettings(settingsDraft); + const result = await window.rd.updateSettings(normalizedSettingsDraft); setSettingsDraft(result); applyTheme(result.theme); - showToast("Settings gespeichert", 1800); - } catch (error) { showToast(`Settings konnten nicht gespeichert werden: ${String(error)}`, 2800); } + showToast("Einstellungen gespeichert", 1800); + } catch (error) { showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); } }; const onCheckUpdates = async (): Promise => { @@ -182,7 +262,7 @@ export function App(): ReactElement { const onAddLinks = async (): Promise => { try { - await window.rd.updateSettings(settingsDraft); + await window.rd.updateSettings(normalizedSettingsDraft); const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); @@ -195,7 +275,7 @@ export function App(): ReactElement { try { const files = await window.rd.pickContainers(); if (files.length === 0) { return; } - await window.rd.updateSettings(settingsDraft); + await window.rd.updateSettings(normalizedSettingsDraft); const result = await window.rd.addContainers(files); showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); } catch (error) { showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); } @@ -209,7 +289,7 @@ export function App(): ReactElement { const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; if (dlc.length > 0) { try { - await window.rd.updateSettings(settingsDraft); + await window.rd.updateSettings(normalizedSettingsDraft); const result = await window.rd.addContainers(dlc); showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); } catch (error) { showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); } @@ -258,6 +338,10 @@ export function App(): ReactElement { const setBool = (key: keyof AppSettings, value: boolean): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setText = (key: keyof AppSettings, value: string): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, value: number): void => { setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; + const setSpeedLimitMbps = (value: number): void => { + const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; + setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); + }; const performQuickAction = async (action: () => Promise): Promise => { try { await action(); } catch (error) { showToast(`Fehler: ${String(error)}`, 2600); } @@ -386,32 +470,34 @@ export function App(): ReactElement {
- +
-
- - setNum("speedLimitKbps", Number(e.target.value) || 0)} /> - KB/s - -
@@ -424,7 +510,7 @@ export function App(): ReactElement { - +
@@ -454,25 +540,55 @@ export function App(): ReactElement { {snapshot.session.reconnectReason && ({snapshot.session.reconnectReason})}
)} +
+ + setDownloadSearch(event.target.value)} + placeholder="Pakete durchsuchen..." + /> +
Pakete: {snapshot.stats.totalPackages} Dateien: {snapshot.stats.totalFiles} fertig Gesamt: {humanSize(snapshot.stats.totalDownloaded)}
{packages.length === 0 &&
Noch keine Pakete in der Queue.
} - {packages.map((pkg, idx) => ( + {packages.length > 0 && filteredPackages.length === 0 &&
Keine Pakete passend zur Suche.
} + {filteredPackages.map((pkg) => ( snapshot.session.items[id]).filter(Boolean)} packageSpeed={packageSpeedMap.get(pkg.id) ?? 0} - isFirst={idx === 0} - isLast={idx === packages.length - 1} + isFirst={snapshot.session.packageOrder.indexOf(pkg.id) === 0} + isLast={snapshot.session.packageOrder.indexOf(pkg.id) === snapshot.session.packageOrder.length - 1} isEditing={editingPackageId === pkg.id} 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); } }} onEditChange={setEditingName} + onToggleCollapse={() => { + setCollapsedPackages((prev) => ({ ...prev, [pkg.id]: !(prev[pkg.id] ?? false) })); + }} onCancel={() => { void performQuickAction(() => window.rd.cancelPackage(pkg.id)); }} onMoveUp={() => movePackage(pkg.id, "up")} onMoveDown={() => movePackage(pkg.id, "down")} @@ -494,7 +610,7 @@ export function App(): ReactElement { Kompakt, schnell auffindbar und direkt speicherbar.
- + - +
@@ -519,18 +635,27 @@ export function App(): ReactElement { setText("bestToken", e.target.value)} /> setText("allDebridToken", e.target.value)} /> -
-
setText("providerPrimary", e.target.value)}> + {configuredProviders.map((provider) => ())}
-
setText("providerSecondary", e.target.value)}> + + {secondaryProviderChoices.map((provider) => ())}
-
setText("providerTertiary", e.target.value)}> + + {tertiaryProviderChoices.map((provider) => ())}
-
- + )} + @@ -539,17 +664,24 @@ export function App(): ReactElement {
setText("outputDir", e.target.value)} /> - +
setText("packageName", e.target.value)} />
setText("extractDir", e.target.value)} /> - +
+ +