diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class index 9c99eaf..fcefae8 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$1.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class index 0951869..41ca622 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$Backend.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class index 3770d21..098d94e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ConflictMode.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class index 619418a..f2ccd33 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ExtractionRequest.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class index 1623cea..8c1da8f 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$ProgressTracker.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class index 58bd4c7..603dad7 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipArchiveContext.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class index b6da3eb..10566c8 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$SevenZipVolumeCallback.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class index e5c3d6b..6826c5e 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain$WrongPasswordException.class differ diff --git a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class index a6fa276..ef9eef2 100644 Binary files a/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class and b/resources/extractor-jvm/classes/com/sucukdeluxe/extractor/JBindExtractorMain.class differ diff --git a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java index 7ed46ec..413b830 100644 --- a/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java +++ b/resources/extractor-jvm/src/com/sucukdeluxe/extractor/JBindExtractorMain.java @@ -11,7 +11,6 @@ import net.sf.sevenzipjbinding.IInStream; import net.sf.sevenzipjbinding.ISequentialOutStream; import net.sf.sevenzipjbinding.ICryptoGetTextPassword; import net.sf.sevenzipjbinding.PropID; -import net.sf.sevenzipjbinding.ArchiveFormat; import net.sf.sevenzipjbinding.SevenZip; import net.sf.sevenzipjbinding.SevenZipException; import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream; @@ -43,8 +42,6 @@ public final class JBindExtractorMain { private static final Pattern NUMBERED_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.zip\\.\\d{3}$"); private static final Pattern OLD_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.z\\d{2,3}$"); private static final Pattern SEVEN_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.7z\\.001$"); - private static final Pattern RAR_MULTIPART_RE = Pattern.compile("(?i).*\\.part\\d+\\.rar$"); - private static final Pattern RAR_OLDSPLIT_RE = Pattern.compile("(?i).*\\.r\\d{2,3}$"); private static volatile boolean sevenZipInitialized = false; private JBindExtractorMain() { @@ -329,79 +326,18 @@ public final class JBindExtractorMain { String effectivePassword = password == null ? "" : password; SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword); - // VolumedArchiveInStream is ONLY for .7z.001 split archives. - // It internally checks for the ".7z.001" suffix and rejects everything else. if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) { VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback); IInArchive archive = SevenZip.openInArchive(null, volumed, callback); return new SevenZipArchiveContext(archive, null, volumed, callback); } - // Multi-part RAR (.part1.rar, .part2.rar or old-style .rar/.r01/.r02): - // The first stream MUST be obtained via the callback so the volume name - // tracker is properly initialized. 7z-JBinding uses getProperty(NAME) - // to compute subsequent volume filenames. - boolean isMultiPartRar = RAR_MULTIPART_RE.matcher(nameLower).matches() - || hasOldStyleRarSplits(archiveFile); - - if (isMultiPartRar) { - IInStream inStream = callback.getStream(archiveFile.getAbsolutePath()); - if (inStream == null) { - throw new IOException("Archiv konnte nicht geoeffnet werden: " + archiveFile.getAbsolutePath()); - } - // Try RAR5 first (modern), then RAR4, then auto-detect - Exception lastError = null; - ArchiveFormat[] rarFormats = { ArchiveFormat.RAR5, ArchiveFormat.RAR, null }; - for (ArchiveFormat fmt : rarFormats) { - try { - inStream.seek(0L, 0); - IInArchive archive = SevenZip.openInArchive(fmt, inStream, callback); - return new SevenZipArchiveContext(archive, null, null, callback); - } catch (Exception e) { - lastError = e; - } - } - callback.close(); - throw lastError != null ? lastError : new IOException("Archiv konnte nicht geoeffnet werden"); - } - - // Single-file archives: open directly with auto-detection RandomAccessFile raf = new RandomAccessFile(archiveFile, "r"); RandomAccessFileInStream stream = new RandomAccessFileInStream(raf); IInArchive archive = SevenZip.openInArchive(null, stream, callback); return new SevenZipArchiveContext(archive, stream, null, callback); } - private static boolean hasOldStyleRarSplits(File archiveFile) { - // Old-style RAR splits: main.rar + main.r01, main.r02, ... - String name = archiveFile.getName(); - if (!name.toLowerCase(Locale.ROOT).endsWith(".rar")) { - return false; - } - File parent = archiveFile.getParentFile(); - if (parent == null || !parent.exists()) { - return false; - } - File[] siblings = parent.listFiles(); - if (siblings == null) { - return false; - } - String stem = name.substring(0, name.length() - 4); - for (File sibling : siblings) { - if (!sibling.isFile()) { - continue; - } - String sibName = sibling.getName(); - if (sibName.length() > stem.length() + 1 && sibName.substring(0, stem.length()).equalsIgnoreCase(stem)) { - String suffix = sibName.substring(stem.length()); - if (RAR_OLDSPLIT_RE.matcher(suffix).matches() || suffix.toLowerCase(Locale.ROOT).matches("\\.r\\d{2,3}")) { - return true; - } - } - } - return false; - } - private static boolean isWrongPassword(ZipException error, boolean encrypted) { if (error == null) { return false; @@ -420,10 +356,7 @@ public final class JBindExtractorMain { if (!encrypted || result == null) { return false; } - // Only DATAERROR reliably indicates wrong password. CRCERROR can also mean - // a genuinely corrupt or incomplete archive, and 7z-JBinding sometimes - // falsely reports encrypted=true for non-encrypted RAR files. - return result == ExtractOperationResult.DATAERROR; + return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR; } private static boolean looksLikeWrongPassword(Throwable error, boolean encrypted) { @@ -434,9 +367,7 @@ public final class JBindExtractorMain { if (text.contains("wrong password") || text.contains("falsches passwort")) { return true; } - // Only "data error" suggests wrong password. CRC errors can also mean - // corrupt/incomplete archives, so we don't treat them as password failures. - return encrypted && text.contains("data error"); + return encrypted && (text.contains("crc") || text.contains("data error") || text.contains("checksum")); } private static boolean shouldUseZip4j(File archiveFile) { @@ -840,22 +771,20 @@ public final class JBindExtractorMain { private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable { private final File archiveDir; + private final String firstFileName; private final String password; private final Map openRafs = new HashMap(); - // Must track the LAST opened volume name — 7z-JBinding queries this via - // getProperty(NAME) to compute the next volume filename. - private volatile String currentVolumeName; SevenZipVolumeCallback(File archiveFile, String password) { this.archiveDir = archiveFile.getAbsoluteFile().getParentFile(); - this.currentVolumeName = archiveFile.getName(); + this.firstFileName = archiveFile.getName(); this.password = password == null ? "" : password; } @Override public Object getProperty(PropID propID) { if (propID == PropID.NAME) { - return currentVolumeName; + return firstFileName; } return null; } @@ -874,9 +803,6 @@ public final class JBindExtractorMain { openRafs.put(key, raf); } raf.seek(0L); - // Update current volume name so getProperty(NAME) returns the - // correct value when 7z-JBinding computes the next volume. - currentVolumeName = filename; return new RandomAccessFileInStream(raf); } catch (IOException error) { throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error); diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index a09a13d..c7f41d2 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -1,4 +1,3 @@ -import os from "node:os"; import path from "node:path"; import { app } from "electron"; import { @@ -7,7 +6,6 @@ import { DuplicatePolicy, HistoryEntry, ParsedPackageInput, - ProviderAccountInfo, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -25,8 +23,6 @@ import { MegaWebFallback } from "./mega-web-fallback"; import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage"; 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); @@ -208,6 +204,10 @@ export class AppController { await this.manager.start(); } + public async startPackages(packageIds: string[]): Promise { + await this.manager.startPackages(packageIds); + } + public stop(): void { this.manager.stop(); } @@ -257,16 +257,9 @@ export class AppController { } public exportBackup(): string { - const settingsCopy = { ...this.settings } as Record; - const sensitiveFields: Record = {}; - for (const key of SENSITIVE_KEYS) { - sensitiveFields[key] = String(settingsCopy[key] ?? ""); - delete settingsCopy[key]; - } - const username = os.userInfo().username; - const credentials = encryptCredentials(sensitiveFields, username); + const settings = this.settings; const session = this.manager.getSession(); - return JSON.stringify({ version: 2, settings: settingsCopy, credentials, session }, null, 2); + return JSON.stringify({ version: 1, settings, session }, null, 2); } public importBackup(json: string): { restored: boolean; message: string } { @@ -279,28 +272,7 @@ export class AppController { if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; } - - const version = typeof parsed.version === "number" ? parsed.version : 1; - let settingsObj = parsed.settings as Record; - - if (version >= 2) { - const creds = parsed.credentials as { salt: string; iv: string; tag: string; data: string } | undefined; - if (!creds || !creds.salt || !creds.iv || !creds.tag || !creds.data) { - return { restored: false, message: "Backup v2: Verschlüsselte Zugangsdaten fehlen" }; - } - try { - const username = os.userInfo().username; - const decrypted = decryptCredentials(creds, username); - settingsObj = { ...settingsObj, ...decrypted }; - } catch { - return { - restored: false, - message: "Entschlüsselung fehlgeschlagen. Das Backup wurde mit einem anderen Benutzer erstellt." - }; - } - } - - const restoredSettings = normalizeSettings(settingsObj as AppSettings); + const restoredSettings = normalizeSettings(parsed.settings as AppSettings); this.settings = restoredSettings; saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); @@ -329,62 +301,6 @@ export class AppController { removeHistoryEntry(this.storagePaths, entryId); } - public async checkMegaAccount(): Promise { - 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 1d12c5b..6e2b98f 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -218,35 +218,6 @@ function isArchiveLikePath(filePath: string): boolean { return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower); } -const ITEM_RECOVERY_MIN_BYTES = 10 * 1024; -const ARCHIVE_RECOVERY_MIN_RATIO = 0.995; -const ARCHIVE_RECOVERY_MAX_SLACK_BYTES = 4 * 1024 * 1024; -const FILE_RECOVERY_MIN_RATIO = 0.98; -const FILE_RECOVERY_MAX_SLACK_BYTES = 8 * 1024 * 1024; - -function recoveryExpectedMinSize(filePath: string, totalBytes: number | null | undefined): number { - const knownTotal = Number(totalBytes || 0); - if (!Number.isFinite(knownTotal) || knownTotal <= 0) { - return ITEM_RECOVERY_MIN_BYTES; - } - - const archiveLike = isArchiveLikePath(filePath); - const minRatio = archiveLike ? ARCHIVE_RECOVERY_MIN_RATIO : FILE_RECOVERY_MIN_RATIO; - const maxSlack = archiveLike ? ARCHIVE_RECOVERY_MAX_SLACK_BYTES : FILE_RECOVERY_MAX_SLACK_BYTES; - const ratioBased = Math.floor(knownTotal * minRatio); - const slackBased = Math.max(0, Math.floor(knownTotal) - maxSlack); - return Math.max(ITEM_RECOVERY_MIN_BYTES, Math.max(ratioBased, slackBased)); -} - -function isRecoveredFileSizeSufficient(item: Pick, fileSize: number): boolean { - if (!Number.isFinite(fileSize) || fileSize <= 0) { - return false; - } - const candidatePath = String(item.targetPath || item.fileName || ""); - const minSize = recoveryExpectedMinSize(candidatePath, item.totalBytes); - return fileSize >= minSize; -} - function isFetchFailure(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error"); @@ -2308,6 +2279,92 @@ export class DownloadManager extends EventEmitter { }); } + public async startPackages(packageIds: string[]): Promise { + const targetSet = new Set(packageIds); + + // Enable specified packages if disabled + for (const pkgId of targetSet) { + const pkg = this.session.packages[pkgId]; + if (pkg && !pkg.enabled) { + pkg.enabled = true; + } + } + + // Recover stopped items in specified packages + for (const item of Object.values(this.session.items)) { + if (!targetSet.has(item.packageId)) continue; + if (item.status === "cancelled" && item.fullStatus === "Gestoppt") { + const pkg = this.session.packages[item.packageId]; + if (pkg && !pkg.cancelled && pkg.enabled) { + item.status = "queued"; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.speedBps = 0; + item.updatedAt = nowMs(); + } + } + } + + // If already running, the scheduler will pick up newly enabled items + if (this.session.running) { + // Add new items to runItemIds so the scheduler processes them + for (const item of Object.values(this.session.items)) { + if (!targetSet.has(item.packageId)) continue; + if (item.status === "queued" || item.status === "reconnect_wait") { + this.runItemIds.add(item.id); + this.runPackageIds.add(item.packageId); + } + } + this.persistSoon(); + this.emitState(true); + return; + } + + // Not running: start with only items from specified packages + const runItems = Object.values(this.session.items) + .filter((item) => { + if (!targetSet.has(item.packageId)) return false; + if (item.status !== "queued" && item.status !== "reconnect_wait") return false; + const pkg = this.session.packages[item.packageId]; + return Boolean(pkg && !pkg.cancelled && pkg.enabled); + }); + if (runItems.length === 0) { + this.persistSoon(); + this.emitState(true); + return; + } + this.runItemIds = new Set(runItems.map((item) => item.id)); + this.runPackageIds = new Set(runItems.map((item) => item.packageId)); + this.runOutcomes.clear(); + this.runCompletedPackages.clear(); + this.retryAfterByItem.clear(); + this.session.running = true; + this.session.paused = false; + this.session.runStartedAt = nowMs(); + this.session.totalDownloadedBytes = 0; + this.session.summaryText = ""; + this.session.reconnectUntil = 0; + this.session.reconnectReason = ""; + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.lastGlobalProgressBytes = 0; + this.lastGlobalProgressAt = nowMs(); + this.summary = null; + this.nonResumableActive = 0; + this.persistSoon(); + this.emitState(true); + logger.info(`Start (nur Pakete: ${packageIds.length}): ${runItems.length} Items`); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); + } + public async start(): Promise { if (this.session.running) { return; @@ -4978,7 +5035,6 @@ export class DownloadManager extends EventEmitter { } const completedPaths = new Set(); - const completedItemsByPath = new Map(); const pendingPaths = new Set(); for (const itemId of pkg.itemIds) { const item = this.session.items[itemId]; @@ -4986,9 +5042,7 @@ export class DownloadManager extends EventEmitter { continue; } if (item.status === "completed" && item.targetPath) { - const key = pathKey(item.targetPath); - completedPaths.add(key); - completedItemsByPath.set(key, item); + completedPaths.add(pathKey(item.targetPath)); } else if (item.targetPath) { pendingPaths.add(pathKey(item.targetPath)); } @@ -5024,82 +5078,12 @@ export class DownloadManager extends EventEmitter { const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles); const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part))); if (allPartsCompleted) { - let allPartsLikelyComplete = true; - for (const part of partsOnDisk) { - const completedItem = completedItemsByPath.get(pathKey(part)); - if (!completedItem) { - continue; - } - try { - const stat = fs.statSync(part); - if (isRecoveredFileSizeSufficient(completedItem, stat.size)) { - continue; - } - const minSize = recoveryExpectedMinSize(completedItem.targetPath || completedItem.fileName, completedItem.totalBytes); - logger.info(`Hybrid-Extract: ${path.basename(candidate)} übersprungen – ${path.basename(part)} zu klein (${humanSize(stat.size)}, erwartet mind. ${humanSize(minSize)})`); - allPartsLikelyComplete = false; - break; - } catch { - allPartsLikelyComplete = false; - break; - } - } - if (!allPartsLikelyComplete) { - continue; - } - - const candidateBase = path.basename(candidate).toLowerCase(); - - // For multi-part archives (.part1.rar), check if parts of THIS SPECIFIC archive - // are still pending. We match by archive prefix so E01 parts don't block E02. - const multiMatch = candidateBase.match(/^(.*)\.part0*1\.rar$/i); - if (multiMatch) { - const prefix = multiMatch[1].toLowerCase(); - const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const partPattern = new RegExp(`^${escapedPrefix}\\.part\\d+\\.rar$`, "i"); - const hasRelatedPending = pkg.itemIds.some((itemId) => { - const item = this.session.items[itemId]; - if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") { - return false; - } - // Check fileName (set early from link URL) - if (item.fileName && partPattern.test(item.fileName)) { - return true; - } - // Check targetPath basename (set when download starts) - if (item.targetPath && partPattern.test(path.basename(item.targetPath))) { - return true; - } - // Item has no identity at all — might be an unresolved part, be conservative - if (!item.fileName && !item.targetPath) { - return true; - } - return false; - }); - if (hasRelatedPending) { - logger.info(`Hybrid-Extract: ${path.basename(candidate)} übersprungen – zugehörige Parts noch ausstehend`); - continue; - } - } - const hasUnstartedParts = [...pendingPaths].some((pendingPath) => { const pendingName = path.basename(pendingPath).toLowerCase(); - return this.looksLikeArchivePart(pendingName, candidateBase); + const candidateStem = path.basename(candidate).toLowerCase(); + return this.looksLikeArchivePart(pendingName, candidateStem); }); - // 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) { + if (hasUnstartedParts) { continue; } ready.add(pathKey(candidate)); @@ -5109,11 +5093,6 @@ 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) { @@ -5138,22 +5117,6 @@ 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)); } @@ -5281,9 +5244,17 @@ export class DownloadManager extends EventEmitter { if (progress.phase === "done") { return; } - // Track only currently active archive items; final statuses are set - // after extraction result is known. + // When a new archive starts, mark the previous archive's items as done if (progress.archiveName && progress.archiveName !== lastHybridArchiveName) { + if (lastHybridArchiveName && currentArchiveItems.length > 0) { + const doneAt = nowMs(); + for (const entry of currentArchiveItems) { + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = "Entpackt - Done"; + entry.updatedAt = doneAt; + } + } + } lastHybridArchiveName = progress.archiveName; const resolved = resolveArchiveItems(progress.archiveName); currentArchiveItems = resolved; @@ -5358,8 +5329,12 @@ export class DownloadManager extends EventEmitter { } try { const stat = fs.statSync(item.targetPath); - const minSize = recoveryExpectedMinSize(item.targetPath || item.fileName, item.totalBytes); - if (isRecoveredFileSizeSufficient(item, stat.size)) { + // Require file to be either ≥50% of expected size or at least 10 KB to avoid + // recovering tiny error-response files (e.g. 9-byte "Forbidden" pages). + const minSize = item.totalBytes && item.totalBytes > 0 + ? Math.max(10240, Math.floor(item.totalBytes * 0.5)) + : 10240; + if (stat.size >= minSize) { logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`); item.status = "completed"; item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`; @@ -5493,9 +5468,17 @@ export class DownloadManager extends EventEmitter { signal: extractAbortController.signal, packageId, onProgress: (progress) => { - // Track only currently active archive items; final statuses are set - // after extraction result is known. + // When a new archive starts, mark the previous archive's items as done if (progress.archiveName && progress.archiveName !== lastExtractArchiveName) { + if (lastExtractArchiveName && currentArchiveItems.length > 0) { + const doneAt = nowMs(); + for (const entry of currentArchiveItems) { + if (!isExtractedLabel(entry.fullStatus)) { + entry.fullStatus = "Entpackt - Done"; + entry.updatedAt = doneAt; + } + } + } lastExtractArchiveName = progress.archiveName; currentArchiveItems = resolveArchiveItems(progress.archiveName); } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ca10774..b02e2a8 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, label = ""): void { +function lowerExtractProcessPriority(childPid: number | undefined): void { if (process.platform !== "win32") { return; } @@ -511,9 +511,6 @@ function lowerExtractProcessPriority(childPid: number | undefined, label = ""): // 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 } @@ -583,7 +580,7 @@ function runExtractCommand( let settled = false; let output = ""; const child = spawn(command, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid, `legacy/${path.basename(command).replace(/\.exe$/i, "")}`); + lowerExtractProcessPriority(child.pid); let timeoutId: NodeJS.Timeout | null = null; let timedOutByWatchdog = false; let abortedBySignal = false; @@ -900,7 +897,7 @@ function runJvmExtractCommand( let stderrBuffer = ""; const child = spawn(layout.javaCommand, args, { windowsHide: true }); - lowerExtractProcessPriority(child.pid, "7zjbinding/single-thread"); + lowerExtractProcessPriority(child.pid); const flushLines = (rawChunk: string, fromStdErr = false): void => { if (!rawChunk) { @@ -1169,63 +1166,38 @@ async function runExternalExtract( } logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`); } else { - if (hybridMode) { - try { - const archiveStat = await fs.promises.stat(archivePath); - logger.info(`Hybrid-Extract JVM: ${path.basename(archivePath)} (${(archiveStat.size / 1048576).toFixed(1)} MB)`); - } catch (statErr) { - logger.warn(`Hybrid-Extract JVM: Archiv nicht zugreifbar: ${path.basename(archivePath)} — ${String(statErr)}`); - } - } logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${path.basename(archivePath)}`); - const maxJvmAttempts = hybridMode ? 2 : 1; - for (let jvmAttempt = 1; jvmAttempt <= maxJvmAttempts; jvmAttempt++) { - const jvmResult = await runJvmExtractCommand( - layout, - archivePath, - targetDir, - conflictMode, - passwordCandidates, - onArchiveProgress, - signal, - timeoutMs - ); + const jvmResult = await runJvmExtractCommand( + layout, + archivePath, + targetDir, + conflictMode, + passwordCandidates, + onArchiveProgress, + signal, + timeoutMs + ); - if (jvmResult.ok) { - if (jvmAttempt > 1) { - logger.info(`JVM-Extractor Retry #${jvmAttempt - 1} erfolgreich: ${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) { - throw new Error("aborted:extract"); - } - if (jvmResult.timedOut) { - throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`); - } + if (jvmResult.ok) { + logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${path.basename(archivePath)}`); + return jvmResult.usedPassword; + } + if (jvmResult.aborted) { + throw new Error("aborted:extract"); + } + if (jvmResult.timedOut) { + throw new Error(jvmResult.errorText || `Entpacken Timeout nach ${Math.ceil(timeoutMs / 1000)}s`); + } - jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; - - // In hybrid mode, retry once on "codecs" / "can't be opened" errors — - // these can be caused by transient Windows file locks right after download completion. - const isTransientOpen = jvmFailureReason.includes("codecs") || jvmFailureReason.includes("can't be opened"); - if (hybridMode && isTransientOpen && jvmAttempt < maxJvmAttempts) { - logger.warn(`JVM-Extractor Hybrid-Retry: ${jvmFailureReason} — warte 3s vor Versuch #${jvmAttempt + 1}: ${path.basename(archivePath)}`); - await new Promise((r) => setTimeout(r, 3000)); - continue; - } - - const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD"); - if (backendMode === "jvm" && !isUnsupportedMethod) { - throw new Error(jvmFailureReason); - } - if (isUnsupportedMethod) { - logger.warn(`JVM-Extractor: Komprimierungsmethode nicht unterstützt, fallback auf Legacy: ${path.basename(archivePath)}`); - } else { - logger.warn(`JVM-Extractor Fehler, fallback auf Legacy: ${jvmFailureReason}`); - } - break; + jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen"; + const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD"); + if (backendMode === "jvm" && !isUnsupportedMethod) { + throw new Error(jvmFailureReason); + } + if (isUnsupportedMethod) { + logger.warn(`JVM-Extractor: Komprimierungsmethode nicht unterstützt, fallback auf Legacy: ${path.basename(archivePath)}`); + } else { + logger.warn(`JVM-Extractor Fehler, fallback auf Legacy: ${jvmFailureReason}`); } } } @@ -1247,12 +1219,10 @@ 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} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}] (nach JVM-Fehler): ${path.basename(archivePath)}`); + logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${path.basename(archivePath)}`); } else { - logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}]: ${path.basename(archivePath)}`); + logger.info(`Entpackt via legacy/${extractorName}: ${path.basename(archivePath)}`); } return password; } finally { diff --git a/src/main/main.ts b/src/main/main.ts index 89a9aa2..9b948f8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -254,31 +254,9 @@ function registerIpcHandlers(): void { return false; } }); - ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial) => { - const validated = validatePlainObject(partial ?? {}, "partial") as Partial; - const oldSettings = controller.getSettings(); - const dirKeys = ["outputDir", "extractDir", "mkvLibraryDir"] as const; - for (const key of dirKeys) { - const newVal = validated[key]; - if (typeof newVal === "string" && newVal.trim() && newVal !== oldSettings[key]) { - if (!fs.existsSync(newVal)) { - const msgOpts = { - type: "question" as const, - buttons: ["Ja", "Nein"], - defaultId: 0, - title: "Ordner erstellen?", - message: `Der Ordner existiert nicht:\n${newVal}\n\nSoll er erstellt werden?` - }; - const { response } = mainWindow - ? await dialog.showMessageBox(mainWindow, msgOpts) - : await dialog.showMessageBox(msgOpts); - if (response === 0) { - fs.mkdirSync(newVal, { recursive: true }); - } - } - } - } - const result = controller.updateSettings(validated); + ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial) => { + const validated = validatePlainObject(partial ?? {}, "partial"); + const result = controller.updateSettings(validated as Partial); updateClipboardWatcher(); updateTray(); return result; @@ -310,6 +288,10 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); ipcMain.handle(IPC_CHANNELS.START, () => controller.start()); + ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { + if (!Array.isArray(packageIds)) throw new Error("packageIds muss ein Array sein"); + return controller.startPackages(packageIds); + }); ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop()); ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause()); ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { @@ -384,21 +366,6 @@ function registerIpcHandlers(): void { const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? [] : result.filePaths; }); - ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => { - validateString(provider, "provider"); - 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" }; - } - }); ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); ipcMain.handle(IPC_CHANNELS.RESTART, () => { diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 8df9778..f9c1b04 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -1,4 +1,3 @@ -import { ProviderAccountInfo } from "../shared/types"; import { UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, sleep } from "./utils"; @@ -16,7 +15,6 @@ const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login"; const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid"; const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json"; const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"; -const PROFILE_URL = "https://www.mega-debrid.eu/index.php?page=profil"; function normalizeLink(link: string): string { return link.trim().toLowerCase(); @@ -266,51 +264,6 @@ export class MegaWebFallback { }, signal); } - public async getAccountInfo(): Promise { - return this.runExclusive(async () => { - const creds = this.getCredentials(); - if (!creds.login.trim() || !creds.password.trim()) { - return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Login/Passwort nicht konfiguriert" }; - } - - try { - if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) { - await this.login(creds.login, creds.password); - } - - const res = await fetch(PROFILE_URL, { - method: "GET", - headers: { - "User-Agent": "Mozilla/5.0", - Cookie: this.cookie, - Referer: DEBRID_REFERER - }, - signal: AbortSignal.timeout(30000) - }); - const html = await res.text(); - - const usernameMatch = html.match(/]*id=["']user_link["'][^>]*>([^<]+)<\/span>/i); - const username = usernameMatch?.[1]?.trim() || ""; - - const typeMatch = html.match(/(Premiumuser|Freeuser)\s*-\s*(\d+)\s*Tag/i); - const accountType = typeMatch?.[1] || "Unbekannt"; - const daysRemaining = typeMatch?.[2] ? parseInt(typeMatch[2], 10) : null; - - const pointsMatch = html.match(/(\d+)\s*Treuepunkte/i); - const loyaltyPoints = pointsMatch?.[1] ? parseInt(pointsMatch[1], 10) : null; - - if (!username && !typeMatch) { - this.cookie = ""; - return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Profil konnte nicht gelesen werden (Session ungültig?)" }; - } - - return { provider: "megadebrid", username, accountType, daysRemaining, loyaltyPoints }; - } catch (err) { - return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) }; - } - }); - } - public invalidateSession(): void { this.cookie = ""; this.cookieSetAt = 0; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 751339b..3c435a2 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -4,7 +4,6 @@ import { AppSettings, DuplicatePolicy, HistoryEntry, - ProviderAccountInfo, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -31,6 +30,7 @@ const api: ElectronApi = { ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), clearAll: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), start: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.START), + startPackages: (packageIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds), stop: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.STOP), togglePause: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), cancelPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), @@ -54,7 +54,6 @@ const api: ElectronApi = { getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), - checkAccount: (provider: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT, provider), onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 520ecf6..29e97c0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,7 +10,6 @@ import type { DuplicatePolicy, HistoryEntry, PackageEntry, - ProviderAccountInfo, StartConflictEntry, UiSnapshot, UpdateCheckResult, @@ -93,15 +92,6 @@ 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\./, ""); @@ -452,11 +442,6 @@ export function App(): ReactElement { const latestStateRef = useRef(null); const stateFlushTimerRef = useRef | null>(null); const toastTimerRef = useRef | null>(null); - 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(""); @@ -844,8 +829,6 @@ 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; @@ -1240,42 +1223,6 @@ 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 @@ -2437,62 +2384,19 @@ export function App(): ReactElement { {settingsSubTab === "accounts" && (

Accounts

- {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` : "—"} - - -
+ + setText("token", e.target.value)} /> + + setText("megaLogin", e.target.value)} /> + + setText("megaPassword", e.target.value)} /> + + 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 >= 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()}> @@ -2764,8 +2644,8 @@ export function App(): ReactElement {
e.stopPropagation()}> {(!contextMenu.itemId || multi) && hasPackages && ( )} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 1cc3653..7359290 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1465,88 +1465,6 @@ td { } } -.account-info { - font-size: 13px; - padding: 6px 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--field); -} - -.account-info-success { - color: #4ade80; - border-color: rgba(74, 222, 128, 0.35); -} - -.account-info-error { - color: var(--danger); - 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; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index f22b202..edaf39c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -12,6 +12,7 @@ export const IPC_CHANNELS = { RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", CLEAR_ALL: "queue:clear-all", START: "queue:start", + START_PACKAGES: "queue:start-packages", STOP: "queue:stop", TOGGLE_PAUSE: "queue:toggle-pause", CANCEL_PACKAGE: "queue:cancel-package", @@ -36,6 +37,5 @@ export const IPC_CHANNELS = { EXTRACT_NOW: "queue:extract-now", GET_HISTORY: "history:get", CLEAR_HISTORY: "history:clear", - REMOVE_HISTORY_ENTRY: "history:remove-entry", - CHECK_ACCOUNT: "app:check-account" + REMOVE_HISTORY_ENTRY: "history:remove-entry" } as const; diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 83241ba..ef8d8d2 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -3,7 +3,6 @@ import type { AppSettings, DuplicatePolicy, HistoryEntry, - ProviderAccountInfo, SessionStats, StartConflictEntry, StartConflictResolutionResult, @@ -26,6 +25,7 @@ export interface ElectronApi { resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; clearAll: () => Promise; start: () => Promise; + startPackages: (packageIds: string[]) => Promise; stop: () => Promise; togglePause: () => Promise; cancelPackage: (packageId: string) => Promise; @@ -49,7 +49,6 @@ export interface ElectronApi { getHistory: () => Promise; clearHistory: () => Promise; removeHistoryEntry: (entryId: string) => Promise; - checkAccount: (provider: string) => Promise; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; diff --git a/src/shared/types.ts b/src/shared/types.ts index 8e776aa..9093cdc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -269,15 +269,6 @@ export interface HistoryEntry { outputDir: string; } -export interface ProviderAccountInfo { - provider: DebridProvider; - username: string; - accountType: string; - daysRemaining: number | null; - loyaltyPoints: number | null; - error?: string; -} - export interface HistoryState { entries: HistoryEntry[]; maxEntries: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index ccda779..cfb45f7 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -3636,242 +3636,6 @@ describe("download manager", () => { expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)"); }); - it("does not recover partial archive files as completed", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); - tempDirs.push(root); - - const outputDir = path.join(root, "downloads", "partial-recovery"); - const extractDir = path.join(root, "extract", "partial-recovery"); - fs.mkdirSync(outputDir, { recursive: true }); - - const archivePath = path.join(outputDir, "partial.repack.part1.rar"); - const totalBytes = 1_000_000; - fs.writeFileSync(archivePath, Buffer.alloc(860_000, 1)); - - const session = emptySession(); - const packageId = "partial-recovery-pkg"; - const itemId = "partial-recovery-item"; - const createdAt = Date.now() - 20_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: "partial-recovery", - outputDir, - extractDir, - status: "downloading", - itemIds: [itemId], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - session.items[itemId] = { - id: itemId, - packageId, - url: "https://dummy/partial-recovery", - provider: "megadebrid", - status: "queued", - retries: 0, - speedBps: 0, - downloadedBytes: 0, - totalBytes, - progressPercent: 0, - fileName: path.basename(archivePath), - targetPath: archivePath, - resumable: true, - attempts: 0, - lastError: "", - fullStatus: "Wartet", - createdAt, - updatedAt: createdAt - }; - - const manager = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "downloads"), - extractDir: path.join(root, "extract"), - autoExtract: false - }, - session, - createStoragePaths(path.join(root, "state")) - ); - - const internal = manager as unknown as { - handlePackagePostProcessing: (packageId: string) => Promise; - }; - await internal.handlePackagePostProcessing(packageId); - - const item = manager.getSnapshot().session.items[itemId]; - expect(item?.status).toBe("queued"); - expect(item?.fullStatus).toBe("Wartet"); - }); - - it("recovers near-complete archive files with known size", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); - tempDirs.push(root); - - const outputDir = path.join(root, "downloads", "near-complete-recovery"); - const extractDir = path.join(root, "extract", "near-complete-recovery"); - fs.mkdirSync(outputDir, { recursive: true }); - - const archivePath = path.join(outputDir, "near.complete.part1.rar"); - const totalBytes = 1_000_000; - const fileSize = 996_000; - fs.writeFileSync(archivePath, Buffer.alloc(fileSize, 2)); - - const session = emptySession(); - const packageId = "near-complete-recovery-pkg"; - const itemId = "near-complete-recovery-item"; - const createdAt = Date.now() - 20_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: "near-complete-recovery", - outputDir, - extractDir, - status: "downloading", - itemIds: [itemId], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - session.items[itemId] = { - id: itemId, - packageId, - url: "https://dummy/near-complete-recovery", - provider: "megadebrid", - status: "queued", - retries: 0, - speedBps: 0, - downloadedBytes: 0, - totalBytes, - progressPercent: 0, - fileName: path.basename(archivePath), - targetPath: archivePath, - resumable: true, - attempts: 0, - lastError: "", - fullStatus: "Wartet", - createdAt, - updatedAt: createdAt - }; - - const manager = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "downloads"), - extractDir: path.join(root, "extract"), - autoExtract: false - }, - session, - createStoragePaths(path.join(root, "state")) - ); - - const internal = manager as unknown as { - handlePackagePostProcessing: (packageId: string) => Promise; - }; - await internal.handlePackagePostProcessing(packageId); - - const item = manager.getSnapshot().session.items[itemId]; - expect(item?.status).toBe("completed"); - expect(item?.downloadedBytes).toBe(fileSize); - }); - - it("skips hybrid-ready multipart archives when a completed part is still too small", async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); - tempDirs.push(root); - - const outputDir = path.join(root, "downloads", "hybrid-size-guard"); - const extractDir = path.join(root, "extract", "hybrid-size-guard"); - fs.mkdirSync(outputDir, { recursive: true }); - - const part1 = path.join(outputDir, "show.s01e01.part1.rar"); - const part2 = path.join(outputDir, "show.s01e01.part2.rar"); - fs.writeFileSync(part1, Buffer.alloc(900_000, 3)); - fs.writeFileSync(part2, Buffer.alloc(700_000, 4)); - - const session = emptySession(); - const packageId = "hybrid-size-guard-pkg"; - const createdAt = Date.now() - 20_000; - session.packageOrder = [packageId]; - session.packages[packageId] = { - id: packageId, - name: "hybrid-size-guard", - outputDir, - extractDir, - status: "downloading", - itemIds: ["hybrid-size-guard-item-1", "hybrid-size-guard-item-2"], - cancelled: false, - enabled: true, - createdAt, - updatedAt: createdAt - }; - session.items["hybrid-size-guard-item-1"] = { - id: "hybrid-size-guard-item-1", - packageId, - url: "https://dummy/hybrid-size-guard/1", - provider: "megadebrid", - status: "completed", - retries: 0, - speedBps: 0, - downloadedBytes: 900_000, - totalBytes: 1_000_000, - progressPercent: 100, - fileName: path.basename(part1), - targetPath: part1, - resumable: true, - attempts: 1, - lastError: "", - fullStatus: "Entpacken - Ausstehend", - createdAt, - updatedAt: createdAt - }; - session.items["hybrid-size-guard-item-2"] = { - id: "hybrid-size-guard-item-2", - packageId, - url: "https://dummy/hybrid-size-guard/2", - provider: "megadebrid", - status: "completed", - retries: 0, - speedBps: 0, - downloadedBytes: 700_000, - totalBytes: 700_000, - progressPercent: 100, - fileName: path.basename(part2), - targetPath: part2, - resumable: true, - attempts: 1, - lastError: "", - fullStatus: "Entpacken - Ausstehend", - createdAt, - updatedAt: createdAt - }; - - const manager = new DownloadManager( - { - ...defaultSettings(), - token: "rd-token", - outputDir: path.join(root, "downloads"), - extractDir: path.join(root, "extract"), - autoExtract: true, - hybridExtract: true - }, - session, - createStoragePaths(path.join(root, "state")) - ); - - const internal = manager as unknown as { - session: ReturnType; - findReadyArchiveSets: (pkg: ReturnType["packages"][string]) => Promise>; - }; - const ready = await internal.findReadyArchiveSets(internal.session.packages[packageId]); - expect(ready.size).toBe(0); - }); - it("recovers pending extraction on startup for completed package", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);