Revert to v1.5.49 base + fix "Ausgewählte Downloads starten"

- Restore all source files from v1.5.49 (proven stable on both servers)
- Add startPackages() IPC method that starts only specified packages
- Fix context menu "Ausgewählte Downloads starten" to use startPackages()
  instead of start() which was starting ALL enabled packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-03 17:53:39 +01:00
parent ca4392fa8b
commit 2ef3983049
22 changed files with 187 additions and 921 deletions

View File

@ -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<String, RandomAccessFile> openRafs = new HashMap<String, RandomAccessFile>();
// 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);

View File

@ -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<AppSettings>): Partial<AppSettings> {
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<void> {
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<string, unknown>;
const sensitiveFields: Record<string, string> = {};
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<string, unknown>;
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<ProviderAccountInfo> {
return this.megaWebFallback.getAccountInfo();
}
public async checkRealDebridAccount(): Promise<ProviderAccountInfo> {
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<string, unknown>;
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<ProviderAccountInfo> {
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<string, unknown>;
const userData = (data.data as Record<string, unknown> | undefined)?.user as Record<string, unknown> | 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<ProviderAccountInfo> {
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);
}

View File

@ -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<DownloadItem, "targetPath" | "fileName" | "totalBytes">, 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<void> {
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<void> {
if (this.session.running) {
return;
@ -4978,7 +5035,6 @@ export class DownloadManager extends EventEmitter {
}
const completedPaths = new Set<string>();
const completedItemsByPath = new Map<string, DownloadItem>();
const pendingPaths = new Set<string>();
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);
}

View File

@ -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 {

View File

@ -254,31 +254,9 @@ function registerIpcHandlers(): void {
return false;
}
});
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
const validated = validatePlainObject(partial ?? {}, "partial") as Partial<AppSettings>;
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<AppSettings>) => {
const validated = validatePlainObject(partial ?? {}, "partial");
const result = controller.updateSettings(validated as Partial<AppSettings>);
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, () => {

View File

@ -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<ProviderAccountInfo> {
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(/<a[^>]*id=["']user_link["'][^>]*><span>([^<]+)<\/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;

View File

@ -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<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
@ -54,7 +54,6 @@ const api: ElectronApi = {
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
checkAccount: (provider: string): Promise<ProviderAccountInfo> => 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);

View File

@ -10,7 +10,6 @@ import type {
DuplicatePolicy,
HistoryEntry,
PackageEntry,
ProviderAccountInfo,
StartConflictEntry,
UiSnapshot,
UpdateCheckResult,
@ -93,15 +92,6 @@ const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
};
const allProviders: DebridProvider[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid"];
const providerCredentialFields: Record<DebridProvider, { key: keyof AppSettings; label: string; type: string }[]> = {
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<UiSnapshot | null>(null);
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [accountInfoMap, setAccountInfoMap] = useState<Record<string, ProviderAccountInfo>>({});
const [accountCheckLoading, setAccountCheckLoading] = useState<Set<DebridProvider>>(new Set());
const [showAddAccountModal, setShowAddAccountModal] = useState(false);
const [addAccountProvider, setAddAccountProvider] = useState<DebridProvider | "">("");
const [addAccountFields, setAddAccountFields] = useState<Record<string, string>>({});
const [dragOver, setDragOver] = useState(false);
const [editingPackageId, setEditingPackageId] = useState<string | null>(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<unknown>,
onError?: (error: unknown) => void
@ -2437,62 +2384,19 @@ export function App(): ReactElement {
{settingsSubTab === "accounts" && (
<div className="settings-section card">
<h3>Accounts</h3>
{configuredProviders.length === 0 ? (
<div className="empty">Keine Accounts konfiguriert</div>
) : (
<table className="account-table">
<thead>
<tr>
<th>Hoster</th>
<th>Status</th>
<th>Benutzername</th>
<th>Verfallsdatum</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={provider}>
<td className="account-col-hoster">{providerLabels[provider]}</td>
<td><span className={`account-status ${statusClass}`}>{statusText}</span>{info?.error ? <span style={{ marginLeft: 6, fontSize: 12, color: "var(--danger)" }}>{info.error}</span> : null}</td>
<td>{info?.username || "—"}</td>
<td>{info?.daysRemaining !== null && info?.daysRemaining !== undefined ? `${info.daysRemaining} Tage` : "—"}</td>
<td className="account-actions-cell">
<button className="btn" disabled={loading} onClick={() => checkSingleAccount(provider)}>Prüfen</button>
<button className="btn danger" onClick={() => removeAccount(provider)}>Entfernen</button>
</td>
</tr>
);
})}
</tbody>
</table>
<label>Real-Debrid API Token</label>
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
<label>Mega-Debrid Login</label>
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
<label>Mega-Debrid Passwort</label>
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
<label>BestDebrid API Token</label>
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
<label>AllDebrid API Key</label>
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
{configuredProviders.length === 0 && (
<div className="hint">Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.</div>
)}
<div className="account-toolbar">
<button className="btn accent" disabled={unconfiguredProviders.length === 0} onClick={() => {
setAddAccountProvider(unconfiguredProviders[0] || "");
setAddAccountFields({});
setShowAddAccountModal(true);
}}>Hinzufügen</button>
<button className="btn" disabled={configuredProviders.length === 0 || accountCheckLoading.size > 0} onClick={checkAllAccounts}>Alle aktualisieren</button>
</div>
{configuredProviders.length >= 1 && (
<div><label>Hauptaccount</label><select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
@ -2615,30 +2519,6 @@ export function App(): ReactElement {
)}
</main>
{showAddAccountModal && (
<div className="modal-backdrop" onClick={() => setShowAddAccountModal(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Account hinzufügen</h3>
<div>
<label>Provider</label>
<select value={addAccountProvider} onChange={(e) => { setAddAccountProvider(e.target.value as DebridProvider); setAddAccountFields({}); }}>
{unconfiguredProviders.map((p) => (<option key={p} value={p}>{providerLabels[p]}</option>))}
</select>
</div>
{addAccountProvider && providerCredentialFields[addAccountProvider].map((field) => (
<div key={field.key}>
<label>{field.label}</label>
<input type={field.type} value={addAccountFields[field.key] || ""} onChange={(e) => setAddAccountFields((prev) => ({ ...prev, [field.key]: e.target.value }))} />
</div>
))}
<div className="modal-actions">
<button className="btn" onClick={() => setShowAddAccountModal(false)}>Abbrechen</button>
<button className="btn accent" disabled={!addAccountProvider || providerCredentialFields[addAccountProvider as DebridProvider]?.some((f) => !(addAccountFields[f.key] || "").trim())} onClick={saveAddAccountModal}>Speichern</button>
</div>
</div>
</div>
)}
{confirmPrompt && (
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
@ -2764,8 +2644,8 @@ export function App(): ReactElement {
<div className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}>
{(!contextMenu.itemId || multi) && hasPackages && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedIds) { const pkg = snapshot.session.packages[id]; if (pkg && !pkg.enabled) { void window.rd.togglePackage(id); } }
void window.rd.start(); setContextMenu(null);
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
void window.rd.startPackages(pkgIds); setContextMenu(null);
}}>Ausgewählte Downloads starten{multi ? ` (${selectedIds.size})` : ""}</button>
)}
<button className="ctx-menu-item" onClick={() => { void window.rd.start(); setContextMenu(null); }}>Alle Downloads starten</button>

View File

@ -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;

View File

@ -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;

View File

@ -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<StartConflictResolutionResult>;
clearAll: () => Promise<void>;
start: () => Promise<void>;
startPackages: (packageIds: string[]) => Promise<void>;
stop: () => Promise<void>;
togglePause: () => Promise<boolean>;
cancelPackage: (packageId: string) => Promise<void>;
@ -49,7 +49,6 @@ export interface ElectronApi {
getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>;
checkAccount: (provider: string) => Promise<ProviderAccountInfo>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -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;

View File

@ -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<void>;
};
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<void>;
};
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<typeof emptySession>;
findReadyArchiveSets: (pkg: ReturnType<typeof emptySession>["packages"][string]) => Promise<Set<string>>;
};
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);