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:
co-authored by
Claude Opus 4.6
parent
ca4392fa8b
commit
2ef3983049
@@ -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);
|
||||
}
|
||||
|
||||
+116
-133
@@ -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);
|
||||
}
|
||||
|
||||
+34
-64
@@ -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 {
|
||||
|
||||
+7
-40
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user