Add Account Manager + fix Hybrid-Extract premature extraction
- Account Manager: table UI with add/remove/check for all 4 providers (Real-Debrid, Mega-Debrid, BestDebrid, AllDebrid) - Backend: checkRealDebridAccount, checkAllDebridAccount, checkBestDebridAccount - Hybrid-Extract fix: check item.fileName for queued items without targetPath, disable disk-fallback for multi-part archives, extend disk-fallback to catch active downloads by fileName match (prevents CRC errors on incomplete files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e6ec1ed755
commit
0b7c658c8f
@@ -26,6 +26,7 @@ import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSes
|
||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
import { startDebugServer, stopDebugServer } from "./debug-server";
|
||||
import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@@ -332,6 +333,58 @@ export class AppController {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4992,12 +4992,25 @@ export class DownloadManager extends EventEmitter {
|
||||
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
||||
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
|
||||
if (allPartsCompleted) {
|
||||
const candidateBase = path.basename(candidate).toLowerCase();
|
||||
const hasUnstartedParts = [...pendingPaths].some((pendingPath) => {
|
||||
const pendingName = path.basename(pendingPath).toLowerCase();
|
||||
const candidateStem = path.basename(candidate).toLowerCase();
|
||||
return this.looksLikeArchivePart(pendingName, candidateStem);
|
||||
return this.looksLikeArchivePart(pendingName, candidateBase);
|
||||
});
|
||||
if (hasUnstartedParts) {
|
||||
// Also check items without targetPath (queued items that only have fileName)
|
||||
const hasMatchingPendingItems = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
if (item.fileName && !item.targetPath) {
|
||||
if (this.looksLikeArchivePart(item.fileName.toLowerCase(), candidateBase)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasUnstartedParts || hasMatchingPendingItems) {
|
||||
continue;
|
||||
}
|
||||
ready.add(pathKey(candidate));
|
||||
@@ -5007,6 +5020,11 @@ export class DownloadManager extends EventEmitter {
|
||||
// Disk-fallback: if all parts exist on disk but some items lack "completed" status,
|
||||
// allow extraction if none of those parts are actively downloading/validating.
|
||||
// This handles items that finished downloading but whose status was not updated.
|
||||
// Skip disk-fallback entirely for multi-part archives — only allPartsCompleted should handle those.
|
||||
const isMultiPart = /\.part0*1\.rar$/i.test(path.basename(candidate));
|
||||
if (isMultiPart) {
|
||||
continue;
|
||||
}
|
||||
const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part)));
|
||||
let allMissingExistOnDisk = true;
|
||||
for (const part of missingParts) {
|
||||
@@ -5031,6 +5049,22 @@ export class DownloadManager extends EventEmitter {
|
||||
if (anyActivelyProcessing) {
|
||||
continue;
|
||||
}
|
||||
// Also check fileName for items without targetPath (queued/downloading items)
|
||||
const candidateBaseFb = path.basename(candidate).toLowerCase();
|
||||
const hasMatchingPendingFb = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
const nameToCheck = item.fileName?.toLowerCase() || (item.targetPath ? path.basename(item.targetPath).toLowerCase() : "");
|
||||
if (!nameToCheck) {
|
||||
return false;
|
||||
}
|
||||
return nameToCheck === candidateBaseFb || this.looksLikeArchivePart(nameToCheck, candidateBaseFb);
|
||||
});
|
||||
if (hasMatchingPendingFb) {
|
||||
continue;
|
||||
}
|
||||
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`);
|
||||
ready.add(pathKey(candidate));
|
||||
}
|
||||
|
||||
+11
-6
@@ -499,7 +499,7 @@ function extractorThreadSwitch(hybridMode = false): string {
|
||||
return `-mt${threadCount}`;
|
||||
}
|
||||
|
||||
function lowerExtractProcessPriority(childPid: number | undefined): void {
|
||||
function lowerExtractProcessPriority(childPid: number | undefined, label = ""): void {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
@@ -511,6 +511,9 @@ function lowerExtractProcessPriority(childPid: number | undefined): void {
|
||||
// IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction
|
||||
// doesn't starve other processes. I/O priority stays Normal (like JDownloader 2).
|
||||
os.setPriority(pid, os.constants.priority.PRIORITY_LOW);
|
||||
if (label) {
|
||||
logger.info(`Prozess-Priorität: CPU=Idle, I/O=Normal (PID ${pid}, ${label})`);
|
||||
}
|
||||
} catch {
|
||||
// ignore: priority lowering is best-effort
|
||||
}
|
||||
@@ -580,7 +583,7 @@ function runExtractCommand(
|
||||
let settled = false;
|
||||
let output = "";
|
||||
const child = spawn(command, args, { windowsHide: true });
|
||||
lowerExtractProcessPriority(child.pid);
|
||||
lowerExtractProcessPriority(child.pid, `legacy/${path.basename(command).replace(/\.exe$/i, "")}`);
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let timedOutByWatchdog = false;
|
||||
let abortedBySignal = false;
|
||||
@@ -897,7 +900,7 @@ function runJvmExtractCommand(
|
||||
let stderrBuffer = "";
|
||||
|
||||
const child = spawn(layout.javaCommand, args, { windowsHide: true });
|
||||
lowerExtractProcessPriority(child.pid);
|
||||
lowerExtractProcessPriority(child.pid, "7zjbinding/single-thread");
|
||||
|
||||
const flushLines = (rawChunk: string, fromStdErr = false): void => {
|
||||
if (!rawChunk) {
|
||||
@@ -1179,7 +1182,7 @@ async function runExternalExtract(
|
||||
);
|
||||
|
||||
if (jvmResult.ok) {
|
||||
logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via ${jvmResult.backend || "jvm"} [CPU=Idle, I/O=Normal, single-thread]: ${path.basename(archivePath)}`);
|
||||
return jvmResult.usedPassword;
|
||||
}
|
||||
if (jvmResult.aborted) {
|
||||
@@ -1219,10 +1222,12 @@ async function runExternalExtract(
|
||||
hybridMode
|
||||
);
|
||||
const extractorName = path.basename(command).replace(/\.exe$/i, "");
|
||||
const threadInfo = extractorThreadSwitch(hybridMode);
|
||||
const modeLabel = hybridMode ? "hybrid" : "normal";
|
||||
if (jvmFailureReason) {
|
||||
logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}] (nach JVM-Fehler): ${path.basename(archivePath)}`);
|
||||
} else {
|
||||
logger.info(`Entpackt via legacy/${extractorName}: ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}]: ${path.basename(archivePath)}`);
|
||||
}
|
||||
return password;
|
||||
} finally {
|
||||
|
||||
+11
-3
@@ -386,10 +386,18 @@ function registerIpcHandlers(): void {
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => {
|
||||
validateString(provider, "provider");
|
||||
if (provider === "megadebrid") {
|
||||
return controller.checkMegaAccount();
|
||||
switch (provider) {
|
||||
case "realdebrid":
|
||||
return controller.checkRealDebridAccount();
|
||||
case "megadebrid":
|
||||
return controller.checkMegaAccount();
|
||||
case "bestdebrid":
|
||||
return controller.checkBestDebridAccount();
|
||||
case "alldebrid":
|
||||
return controller.checkAllDebridAccount();
|
||||
default:
|
||||
return { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Nicht unterstützt" };
|
||||
}
|
||||
return { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Nicht unterstützt" };
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user