From 0b7c658c8faba66614067f70227940b9f8de2f90 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 3 Mar 2026 14:36:13 +0100 Subject: [PATCH] Add Account Manager + fix Hybrid-Extract premature extraction - Account Manager: table UI with add/remove/check for all 4 providers (Real-Debrid, Mega-Debrid, BestDebrid, AllDebrid) - Backend: checkRealDebridAccount, checkAllDebridAccount, checkBestDebridAccount - Hybrid-Extract fix: check item.fileName for queued items without targetPath, disable disk-fallback for multi-part archives, extend disk-fallback to catch active downloads by fileName match (prevents CRC errors on incomplete files) Co-Authored-By: Claude Opus 4.6 --- src/main/app-controller.ts | 53 ++++++++++++ src/main/download-manager.ts | 40 ++++++++- src/main/extractor.ts | 17 ++-- src/main/main.ts | 14 ++- src/renderer/App.tsx | 162 ++++++++++++++++++++++++++++------- src/renderer/styles.css | 64 ++++++++++++++ 6 files changed, 307 insertions(+), 43 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 1689733..a09a13d 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -26,6 +26,7 @@ import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSes import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { startDebugServer, stopDebugServer } from "./debug-server"; import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto"; +import { compactErrorText } from "./utils"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -332,6 +333,58 @@ export class AppController { return this.megaWebFallback.getAccountInfo(); } + public async checkRealDebridAccount(): Promise { + try { + const response = await fetch("https://api.real-debrid.com/rest/1.0/user", { + headers: { Authorization: `Bearer ${this.settings.token}` } + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { provider: "realdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: `HTTP ${response.status}: ${compactErrorText(text)}` }; + } + const data = await response.json() as Record; + const username = String(data.username ?? ""); + const type = String(data.type ?? ""); + const expiration = data.expiration ? new Date(String(data.expiration)) : null; + const daysRemaining = expiration ? Math.max(0, Math.round((expiration.getTime() - Date.now()) / 86400000)) : null; + const points = typeof data.points === "number" ? data.points : null; + return { provider: "realdebrid", username, accountType: type === "premium" ? "Premium" : type, daysRemaining, loyaltyPoints: points as number | null }; + } catch (err) { + return { provider: "realdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) }; + } + } + + public async checkAllDebridAccount(): Promise { + try { + const response = await fetch("https://api.alldebrid.com/v4/user", { + headers: { Authorization: `Bearer ${this.settings.allDebridToken}` } + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: `HTTP ${response.status}: ${compactErrorText(text)}` }; + } + const data = await response.json() as Record; + const userData = (data.data as Record | undefined)?.user as Record | undefined; + if (!userData) { + return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Ungültige API-Antwort" }; + } + const username = String(userData.username ?? ""); + const isPremium = Boolean(userData.isPremium); + const premiumUntil = typeof userData.premiumUntil === "number" ? userData.premiumUntil : 0; + const daysRemaining = premiumUntil > 0 ? Math.max(0, Math.round((premiumUntil * 1000 - Date.now()) / 86400000)) : null; + return { provider: "alldebrid", username, accountType: isPremium ? "Premium" : "Free", daysRemaining, loyaltyPoints: null }; + } catch (err) { + return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) }; + } + } + + public async checkBestDebridAccount(): Promise { + if (!this.settings.bestToken.trim()) { + return { provider: "bestdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Kein Token konfiguriert" }; + } + return { provider: "bestdebrid", username: "(Token konfiguriert)", accountType: "Konfiguriert", daysRemaining: null, loyaltyPoints: null }; + } + public addToHistory(entry: HistoryEntry): void { addHistoryEntry(this.storagePaths, entry); } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index a1f28ef..9eed2af 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4992,12 +4992,25 @@ export class DownloadManager extends EventEmitter { const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); if (allPartsCompleted) { + const candidateBase = path.basename(candidate).toLowerCase(); const hasUnstartedParts = [...pendingPaths].some((pendingPath) => { const pendingName = path.basename(pendingPath).toLowerCase(); - const candidateStem = path.basename(candidate).toLowerCase(); - return this.looksLikeArchivePart(pendingName, candidateStem); + return this.looksLikeArchivePart(pendingName, candidateBase); }); - if (hasUnstartedParts) { + // Also check items without targetPath (queued items that only have fileName) + const hasMatchingPendingItems = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") { + return false; + } + if (item.fileName && !item.targetPath) { + if (this.looksLikeArchivePart(item.fileName.toLowerCase(), candidateBase)) { + return true; + } + } + return false; + }); + if (hasUnstartedParts || hasMatchingPendingItems) { continue; } ready.add(pathKey(candidate)); @@ -5007,6 +5020,11 @@ export class DownloadManager extends EventEmitter { // Disk-fallback: if all parts exist on disk but some items lack "completed" status, // allow extraction if none of those parts are actively downloading/validating. // This handles items that finished downloading but whose status was not updated. + // Skip disk-fallback entirely for multi-part archives — only allPartsCompleted should handle those. + const isMultiPart = /\.part0*1\.rar$/i.test(path.basename(candidate)); + if (isMultiPart) { + continue; + } const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part))); let allMissingExistOnDisk = true; for (const part of missingParts) { @@ -5031,6 +5049,22 @@ export class DownloadManager extends EventEmitter { if (anyActivelyProcessing) { continue; } + // Also check fileName for items without targetPath (queued/downloading items) + const candidateBaseFb = path.basename(candidate).toLowerCase(); + const hasMatchingPendingFb = pkg.itemIds.some((itemId) => { + const item = this.session.items[itemId]; + if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") { + return false; + } + const nameToCheck = item.fileName?.toLowerCase() || (item.targetPath ? path.basename(item.targetPath).toLowerCase() : ""); + if (!nameToCheck) { + return false; + } + return nameToCheck === candidateBaseFb || this.looksLikeArchivePart(nameToCheck, candidateBaseFb); + }); + if (hasMatchingPendingFb) { + continue; + } logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`); ready.add(pathKey(candidate)); } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index b02e2a8..ac8bbfa 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -499,7 +499,7 @@ function extractorThreadSwitch(hybridMode = false): string { return `-mt${threadCount}`; } -function lowerExtractProcessPriority(childPid: number | undefined): void { +function lowerExtractProcessPriority(childPid: number | undefined, label = ""): void { if (process.platform !== "win32") { return; } @@ -511,6 +511,9 @@ function lowerExtractProcessPriority(childPid: number | undefined): void { // IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction // doesn't starve other processes. I/O priority stays Normal (like JDownloader 2). os.setPriority(pid, os.constants.priority.PRIORITY_LOW); + if (label) { + logger.info(`Prozess-Priorität: CPU=Idle, I/O=Normal (PID ${pid}, ${label})`); + } } catch { // ignore: priority lowering is best-effort } @@ -580,7 +583,7 @@ function runExtractCommand( let settled = false; let output = ""; const child = spawn(command, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid); + lowerExtractProcessPriority(child.pid, `legacy/${path.basename(command).replace(/\.exe$/i, "")}`); let timeoutId: NodeJS.Timeout | null = null; let timedOutByWatchdog = false; let abortedBySignal = false; @@ -897,7 +900,7 @@ function runJvmExtractCommand( let stderrBuffer = ""; const child = spawn(layout.javaCommand, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid); + lowerExtractProcessPriority(child.pid, "7zjbinding/single-thread"); const flushLines = (rawChunk: string, fromStdErr = false): void => { if (!rawChunk) { @@ -1179,7 +1182,7 @@ async function runExternalExtract( ); if (jvmResult.ok) { - logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${path.basename(archivePath)}`); + logger.info(`Entpackt via ${jvmResult.backend || "jvm"} [CPU=Idle, I/O=Normal, single-thread]: ${path.basename(archivePath)}`); return jvmResult.usedPassword; } if (jvmResult.aborted) { @@ -1219,10 +1222,12 @@ async function runExternalExtract( hybridMode ); const extractorName = path.basename(command).replace(/\.exe$/i, ""); + const threadInfo = extractorThreadSwitch(hybridMode); + const modeLabel = hybridMode ? "hybrid" : "normal"; if (jvmFailureReason) { - logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${path.basename(archivePath)}`); + logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}] (nach JVM-Fehler): ${path.basename(archivePath)}`); } else { - logger.info(`Entpackt via legacy/${extractorName}: ${path.basename(archivePath)}`); + logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}]: ${path.basename(archivePath)}`); } return password; } finally { diff --git a/src/main/main.ts b/src/main/main.ts index e516bc8..89a9aa2 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -386,10 +386,18 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => { validateString(provider, "provider"); - if (provider === "megadebrid") { - return controller.checkMegaAccount(); + switch (provider) { + case "realdebrid": + return controller.checkRealDebridAccount(); + case "megadebrid": + return controller.checkMegaAccount(); + case "bestdebrid": + return controller.checkBestDebridAccount(); + case "alldebrid": + return controller.checkAllDebridAccount(); + default: + return { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Nicht unterstützt" }; } - return { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Nicht unterstützt" }; }); ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3320ab7..520ecf6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -93,6 +93,15 @@ const providerLabels: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" }; +const allProviders: DebridProvider[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]; + +const providerCredentialFields: Record = { + realdebrid: [{ key: "token", label: "API Token", type: "password" }], + megadebrid: [{ key: "megaLogin", label: "Login", type: "text" }, { key: "megaPassword", label: "Passwort", type: "password" }], + bestdebrid: [{ key: "bestToken", label: "API Token", type: "password" }], + alldebrid: [{ key: "allDebridToken", label: "API Key", type: "password" }] +}; + function extractHoster(url: string): string { try { const host = new URL(url).hostname.replace(/^www\./, ""); @@ -443,8 +452,11 @@ export function App(): ReactElement { const latestStateRef = useRef(null); const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); - const [megaAccountInfo, setMegaAccountInfo] = useState(null); - const [megaAccountLoading, setMegaAccountLoading] = useState(false); + const [accountInfoMap, setAccountInfoMap] = useState>({}); + const [accountCheckLoading, setAccountCheckLoading] = useState>(new Set()); + const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const [addAccountProvider, setAddAccountProvider] = useState(""); + const [addAccountFields, setAddAccountFields] = useState>({}); const [dragOver, setDragOver] = useState(false); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); @@ -832,6 +844,8 @@ export function App(): ReactElement { return list; }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]); + const unconfiguredProviders = useMemo(() => allProviders.filter((p) => !configuredProviders.includes(p)), [configuredProviders]); + const primaryProviderValue: DebridProvider = useMemo(() => { if (configuredProviders.includes(settingsDraft.providerPrimary)) { return settingsDraft.providerPrimary; @@ -1226,6 +1240,42 @@ export function App(): ReactElement { setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); }; + const checkSingleAccount = (provider: DebridProvider): void => { + setAccountCheckLoading((prev) => new Set(prev).add(provider)); + window.rd.checkAccount(provider).then((info) => { + setAccountInfoMap((prev) => ({ ...prev, [provider]: info })); + }).catch(() => { + setAccountInfoMap((prev) => ({ ...prev, [provider]: { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Verbindungsfehler" } })); + }).finally(() => { + setAccountCheckLoading((prev) => { const next = new Set(prev); next.delete(provider); return next; }); + }); + }; + + const checkAllAccounts = (): void => { + for (const provider of configuredProviders) { + checkSingleAccount(provider); + } + }; + + const removeAccount = (provider: DebridProvider): void => { + for (const field of providerCredentialFields[provider]) { + setText(field.key, ""); + } + setAccountInfoMap((prev) => { const next = { ...prev }; delete next[provider]; return next; }); + }; + + const saveAddAccountModal = (): void => { + if (!addAccountProvider) { + return; + } + for (const field of providerCredentialFields[addAccountProvider]) { + setText(field.key, addAccountFields[field.key] || ""); + } + setShowAddAccountModal(false); + setAddAccountProvider(""); + setAddAccountFields({}); + }; + const performQuickAction = async ( action: () => Promise, onError?: (error: unknown) => void @@ -2387,36 +2437,62 @@ export function App(): ReactElement { {settingsSubTab === "accounts" && (

Accounts

- - setText("token", e.target.value)} /> - - setText("megaLogin", e.target.value)} /> - - setText("megaPassword", e.target.value)} /> - - {megaAccountInfo && !megaAccountInfo.error && ( -
✓ {megaAccountInfo.username} — {megaAccountInfo.accountType}{megaAccountInfo.daysRemaining !== null ? ` — ${megaAccountInfo.daysRemaining} Tage` : ""}{megaAccountInfo.loyaltyPoints !== null ? ` — ${megaAccountInfo.loyaltyPoints} Treuepunkte` : ""}
- )} - {megaAccountInfo?.error && ( -
✗ {megaAccountInfo.error}
- )} - - setText("bestToken", e.target.value)} /> - - setText("allDebridToken", e.target.value)} /> - {configuredProviders.length === 0 && ( -
Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.
+ {configuredProviders.length === 0 ? ( +
Keine Accounts konfiguriert
+ ) : ( + + + + + + + + + + + + {configuredProviders.map((provider) => { + const info = accountInfoMap[provider]; + const loading = accountCheckLoading.has(provider); + let statusClass = "account-status-unknown"; + let statusText = "Nicht geprüft"; + if (loading) { + statusClass = "account-status-loading"; + statusText = "Prüfe…"; + } else if (info?.error) { + statusClass = "account-status-error"; + statusText = "Fehler"; + } else if (info && (info.accountType === "Premium" || info.accountType === "premium")) { + statusClass = "account-status-ok"; + statusText = "Premium"; + } else if (info && info.accountType) { + statusClass = "account-status-configured"; + statusText = info.accountType; + } + return ( + + + + + + + + ); + })} + +
HosterStatusBenutzernameVerfallsdatumAktionen
{providerLabels[provider]}{statusText}{info?.error ? {info.error} : null}{info?.username || "—"}{info?.daysRemaining !== null && info?.daysRemaining !== undefined ? `${info.daysRemaining} Tage` : "—"} + + +
)} +
+ + +
{configuredProviders.length >= 1 && (
{ setAddAccountProvider(e.target.value as DebridProvider); setAddAccountFields({}); }}> + {unconfiguredProviders.map((p) => ())} + +
+ {addAccountProvider && providerCredentialFields[addAccountProvider].map((field) => ( +
+ + setAddAccountFields((prev) => ({ ...prev, [field.key]: e.target.value }))} /> +
+ ))} +
+ + +
+
+ + )} + {confirmPrompt && (
closeConfirmPrompt(false)}>
event.stopPropagation()}> diff --git a/src/renderer/styles.css b/src/renderer/styles.css index ed26aec..1cc3653 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1483,6 +1483,70 @@ td { border-color: rgba(244, 63, 94, 0.35); } +.account-table { + table-layout: fixed; + margin-top: 4px; +} + +.account-table th:first-child, +.account-table td:first-child { + width: 22%; +} + +.account-col-hoster { + font-weight: 600; +} + +.account-status { + display: inline-block; + padding: 2px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} + +.account-status-ok { + background: rgba(74, 222, 128, 0.15); + color: #4ade80; +} + +.account-status-configured { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.account-status-error { + background: rgba(244, 63, 94, 0.12); + color: var(--danger); +} + +.account-status-loading { + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); +} + +.account-status-unknown { + background: color-mix(in srgb, var(--muted) 15%, transparent); + color: var(--muted); +} + +.account-toolbar { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.account-actions-cell { + display: flex; + gap: 6px; +} + +.account-actions-cell .btn { + padding: 4px 8px; + font-size: 12px; +} + .schedule-row { display: grid; grid-template-columns: 56px auto 56px auto 92px auto auto auto;