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:
parent
e6ec1ed755
commit
0b7c658c8f
@ -26,6 +26,7 @@ import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSes
|
|||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { startDebugServer, stopDebugServer } from "./debug-server";
|
import { startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto";
|
import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto";
|
||||||
|
import { compactErrorText } from "./utils";
|
||||||
|
|
||||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||||
@ -332,6 +333,58 @@ export class AppController {
|
|||||||
return this.megaWebFallback.getAccountInfo();
|
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 {
|
public addToHistory(entry: HistoryEntry): void {
|
||||||
addHistoryEntry(this.storagePaths, entry);
|
addHistoryEntry(this.storagePaths, entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4992,12 +4992,25 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
||||||
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
|
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
|
||||||
if (allPartsCompleted) {
|
if (allPartsCompleted) {
|
||||||
|
const candidateBase = path.basename(candidate).toLowerCase();
|
||||||
const hasUnstartedParts = [...pendingPaths].some((pendingPath) => {
|
const hasUnstartedParts = [...pendingPaths].some((pendingPath) => {
|
||||||
const pendingName = path.basename(pendingPath).toLowerCase();
|
const pendingName = path.basename(pendingPath).toLowerCase();
|
||||||
const candidateStem = path.basename(candidate).toLowerCase();
|
return this.looksLikeArchivePart(pendingName, candidateBase);
|
||||||
return this.looksLikeArchivePart(pendingName, candidateStem);
|
|
||||||
});
|
});
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
ready.add(pathKey(candidate));
|
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,
|
// 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.
|
// allow extraction if none of those parts are actively downloading/validating.
|
||||||
// This handles items that finished downloading but whose status was not updated.
|
// 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)));
|
const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part)));
|
||||||
let allMissingExistOnDisk = true;
|
let allMissingExistOnDisk = true;
|
||||||
for (const part of missingParts) {
|
for (const part of missingParts) {
|
||||||
@ -5031,6 +5049,22 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (anyActivelyProcessing) {
|
if (anyActivelyProcessing) {
|
||||||
continue;
|
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)`);
|
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`);
|
||||||
ready.add(pathKey(candidate));
|
ready.add(pathKey(candidate));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -499,7 +499,7 @@ function extractorThreadSwitch(hybridMode = false): string {
|
|||||||
return `-mt${threadCount}`;
|
return `-mt${threadCount}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lowerExtractProcessPriority(childPid: number | undefined): void {
|
function lowerExtractProcessPriority(childPid: number | undefined, label = ""): void {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -511,6 +511,9 @@ function lowerExtractProcessPriority(childPid: number | undefined): void {
|
|||||||
// IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction
|
// IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction
|
||||||
// doesn't starve other processes. I/O priority stays Normal (like JDownloader 2).
|
// doesn't starve other processes. I/O priority stays Normal (like JDownloader 2).
|
||||||
os.setPriority(pid, os.constants.priority.PRIORITY_LOW);
|
os.setPriority(pid, os.constants.priority.PRIORITY_LOW);
|
||||||
|
if (label) {
|
||||||
|
logger.info(`Prozess-Priorität: CPU=Idle, I/O=Normal (PID ${pid}, ${label})`);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore: priority lowering is best-effort
|
// ignore: priority lowering is best-effort
|
||||||
}
|
}
|
||||||
@ -580,7 +583,7 @@ function runExtractCommand(
|
|||||||
let settled = false;
|
let settled = false;
|
||||||
let output = "";
|
let output = "";
|
||||||
const child = spawn(command, args, { windowsHide: true });
|
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 timeoutId: NodeJS.Timeout | null = null;
|
||||||
let timedOutByWatchdog = false;
|
let timedOutByWatchdog = false;
|
||||||
let abortedBySignal = false;
|
let abortedBySignal = false;
|
||||||
@ -897,7 +900,7 @@ function runJvmExtractCommand(
|
|||||||
let stderrBuffer = "";
|
let stderrBuffer = "";
|
||||||
|
|
||||||
const child = spawn(layout.javaCommand, args, { windowsHide: true });
|
const child = spawn(layout.javaCommand, args, { windowsHide: true });
|
||||||
lowerExtractProcessPriority(child.pid);
|
lowerExtractProcessPriority(child.pid, "7zjbinding/single-thread");
|
||||||
|
|
||||||
const flushLines = (rawChunk: string, fromStdErr = false): void => {
|
const flushLines = (rawChunk: string, fromStdErr = false): void => {
|
||||||
if (!rawChunk) {
|
if (!rawChunk) {
|
||||||
@ -1179,7 +1182,7 @@ async function runExternalExtract(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (jvmResult.ok) {
|
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;
|
return jvmResult.usedPassword;
|
||||||
}
|
}
|
||||||
if (jvmResult.aborted) {
|
if (jvmResult.aborted) {
|
||||||
@ -1219,10 +1222,12 @@ async function runExternalExtract(
|
|||||||
hybridMode
|
hybridMode
|
||||||
);
|
);
|
||||||
const extractorName = path.basename(command).replace(/\.exe$/i, "");
|
const extractorName = path.basename(command).replace(/\.exe$/i, "");
|
||||||
|
const threadInfo = extractorThreadSwitch(hybridMode);
|
||||||
|
const modeLabel = hybridMode ? "hybrid" : "normal";
|
||||||
if (jvmFailureReason) {
|
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 {
|
} 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;
|
return password;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -386,10 +386,18 @@ function registerIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => {
|
ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => {
|
||||||
validateString(provider, "provider");
|
validateString(provider, "provider");
|
||||||
if (provider === "megadebrid") {
|
switch (provider) {
|
||||||
return controller.checkMegaAccount();
|
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());
|
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||||
|
|
||||||
|
|||||||
@ -93,6 +93,15 @@ const providerLabels: Record<DebridProvider, string> = {
|
|||||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
|
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 {
|
function extractHoster(url: string): string {
|
||||||
try {
|
try {
|
||||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||||
@ -443,8 +452,11 @@ export function App(): ReactElement {
|
|||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [megaAccountInfo, setMegaAccountInfo] = useState<ProviderAccountInfo | null>(null);
|
const [accountInfoMap, setAccountInfoMap] = useState<Record<string, ProviderAccountInfo>>({});
|
||||||
const [megaAccountLoading, setMegaAccountLoading] = useState(false);
|
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 [dragOver, setDragOver] = useState(false);
|
||||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
@ -832,6 +844,8 @@ export function App(): ReactElement {
|
|||||||
return list;
|
return list;
|
||||||
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]);
|
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]);
|
||||||
|
|
||||||
|
const unconfiguredProviders = useMemo(() => allProviders.filter((p) => !configuredProviders.includes(p)), [configuredProviders]);
|
||||||
|
|
||||||
const primaryProviderValue: DebridProvider = useMemo(() => {
|
const primaryProviderValue: DebridProvider = useMemo(() => {
|
||||||
if (configuredProviders.includes(settingsDraft.providerPrimary)) {
|
if (configuredProviders.includes(settingsDraft.providerPrimary)) {
|
||||||
return settingsDraft.providerPrimary;
|
return settingsDraft.providerPrimary;
|
||||||
@ -1226,6 +1240,42 @@ export function App(): ReactElement {
|
|||||||
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
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 (
|
const performQuickAction = async (
|
||||||
action: () => Promise<unknown>,
|
action: () => Promise<unknown>,
|
||||||
onError?: (error: unknown) => void
|
onError?: (error: unknown) => void
|
||||||
@ -2387,36 +2437,62 @@ export function App(): ReactElement {
|
|||||||
{settingsSubTab === "accounts" && (
|
{settingsSubTab === "accounts" && (
|
||||||
<div className="settings-section card">
|
<div className="settings-section card">
|
||||||
<h3>Accounts</h3>
|
<h3>Accounts</h3>
|
||||||
<label>Real-Debrid API Token</label>
|
{configuredProviders.length === 0 ? (
|
||||||
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
|
<div className="empty">Keine Accounts konfiguriert</div>
|
||||||
<label>Mega-Debrid Login</label>
|
) : (
|
||||||
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
|
<table className="account-table">
|
||||||
<label>Mega-Debrid Passwort</label>
|
<thead>
|
||||||
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
|
<tr>
|
||||||
<button className="btn" disabled={megaAccountLoading || !settingsDraft.megaLogin.trim() || !settingsDraft.megaPassword.trim()} onClick={() => {
|
<th>Hoster</th>
|
||||||
setMegaAccountLoading(true);
|
<th>Status</th>
|
||||||
setMegaAccountInfo(null);
|
<th>Benutzername</th>
|
||||||
window.rd.checkAccount("megadebrid").then((info) => {
|
<th>Verfallsdatum</th>
|
||||||
setMegaAccountInfo(info);
|
<th>Aktionen</th>
|
||||||
}).catch(() => {
|
</tr>
|
||||||
setMegaAccountInfo({ provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Verbindungsfehler" });
|
</thead>
|
||||||
}).finally(() => {
|
<tbody>
|
||||||
setMegaAccountLoading(false);
|
{configuredProviders.map((provider) => {
|
||||||
});
|
const info = accountInfoMap[provider];
|
||||||
}}>{megaAccountLoading ? "Prüfe…" : "Account prüfen"}</button>
|
const loading = accountCheckLoading.has(provider);
|
||||||
{megaAccountInfo && !megaAccountInfo.error && (
|
let statusClass = "account-status-unknown";
|
||||||
<div className="account-info account-info-success">✓ {megaAccountInfo.username} — {megaAccountInfo.accountType}{megaAccountInfo.daysRemaining !== null ? ` — ${megaAccountInfo.daysRemaining} Tage` : ""}{megaAccountInfo.loyaltyPoints !== null ? ` — ${megaAccountInfo.loyaltyPoints} Treuepunkte` : ""}</div>
|
let statusText = "Nicht geprüft";
|
||||||
)}
|
if (loading) {
|
||||||
{megaAccountInfo?.error && (
|
statusClass = "account-status-loading";
|
||||||
<div className="account-info account-info-error">✗ {megaAccountInfo.error}</div>
|
statusText = "Prüfe…";
|
||||||
)}
|
} else if (info?.error) {
|
||||||
<label>BestDebrid API Token</label>
|
statusClass = "account-status-error";
|
||||||
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
statusText = "Fehler";
|
||||||
<label>AllDebrid API Key</label>
|
} else if (info && (info.accountType === "Premium" || info.accountType === "premium")) {
|
||||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
statusClass = "account-status-ok";
|
||||||
{configuredProviders.length === 0 && (
|
statusText = "Premium";
|
||||||
<div className="hint">Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.</div>
|
} 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>
|
||||||
)}
|
)}
|
||||||
|
<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 && (
|
{configuredProviders.length >= 1 && (
|
||||||
<div><label>Hauptaccount</label><select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
<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>))}
|
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||||
@ -2539,6 +2615,30 @@ export function App(): ReactElement {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</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 && (
|
{confirmPrompt && (
|
||||||
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
|
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
|
||||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||||
|
|||||||
@ -1483,6 +1483,70 @@ td {
|
|||||||
border-color: rgba(244, 63, 94, 0.35);
|
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 {
|
.schedule-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user