Account-Rotation: Login/Premium-Badges + Live-Rotations-Panel + "Alle pruefen"

- Pro Mega-Debrid-Account UND Debrid-Link-Key im Bearbeiten-Dialog: Badge mit
  Login-Gueltigkeit + Premium-Restlaufzeit (connectUser vip_end / account/infos premiumLeft)
- "Alle pruefen"-Button oben rechts; prueft alle Accounts (Concurrency-Cap 4),
  Ergebnis persistiert (debridAccountStatuses), ueberlebt Neustart
- Rotations-Verlauf-Panel: zeigt live welcher Account/Key versucht wurde + warum
  gewechselt (Ring-Buffer -> Snapshot -> UI), statt nur "Link-Umwandlung erneut"
- Bug A: Mega-Debrid Per-Account-Verbrauch wurde nie erfasst (Heute/Insgesamt immer 0)
- Bug B: isProviderConfigured erkannte reine megaCredentials-Multi-Config nicht
- Neu: account-check.ts (standalone), CHECK_DEBRID_ACCOUNTS IPC, 13 Tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-05-30 21:19:23 +02:00
co-authored by Claude Opus 4.8
parent 748c07a531
commit 3977184fd4
15 changed files with 885 additions and 126 deletions
+108 -4
View File
@@ -1,6 +1,6 @@
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
import type {
AllDebridHostInfo,
AppSettings,
@@ -866,6 +866,7 @@ const emptySnapshot = (): UiSnapshot => ({
megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0
},
@@ -1121,6 +1122,37 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef
return info.note || "Nicht verfügbar";
}
function formatCheckedAgo(checkedAt: number): string {
const deltaMs = Date.now() - checkedAt;
if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben";
const min = Math.floor(deltaMs / 60000);
if (min < 1) return "gerade eben";
if (min < 60) return `vor ${min} Min`;
const hours = Math.floor(min / 60);
if (hours < 24) return `vor ${hours} Std`;
const days = Math.floor(hours / 24);
return `vor ${days} Tag${days === 1 ? "" : "en"}`;
}
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string }): string {
switch (ev.event) {
case "OK": return "erfolgreich";
case "FAILED": {
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
const nx = ev.next && ev.next !== "ENDE" ? `${ev.next}` : "";
return `fehlgeschlagen${cd}${nx}`;
}
case "FATAL": return "abgebrochen (fataler Fehler)";
case "SKIP_COOLDOWN": return "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
case "PROVIDER_WIDE": return "Provider-weiter Fehler, restliche Keys übersprungen";
case "TRANSPORT_CASCADE": return "Netzwerk-Kaskade, restliche Keys übersprungen";
default: return ev.event;
}
}
function getDebridLinkKeyStatusDisplay(
key: DebridLinkAccountKeyEntry,
info: DebridLinkHostLimitInfo | null | undefined
@@ -1569,6 +1601,7 @@ export function App(): ReactElement {
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
@@ -1893,6 +1926,12 @@ export function App(): ReactElement {
unsubscribe = window.rd.onStateUpdate((wireState) => {
// Merge delta payloads into the master snapshot. Full payloads replace
// the master entirely (initial sync + periodic 30s resync).
// NOTE: `settings` and `rotationEvents` are NOT delta-filtered — every emit
// (full or delta) carries the complete `settings` object and recent
// rotationEvents. The account-validity badges read
// `snapshot.settings.debridAccountStatuses` and the rotation panel reads
// `snapshot.rotationEvents`; if `settings` is ever delta-optimized, both
// must keep flowing on every emit or those views go stale.
let merged: UiSnapshot;
const master = masterSnapshotRef.current;
if (wireState.payloadKind === "delta" && master) {
@@ -2597,6 +2636,24 @@ export function App(): ReactElement {
}
};
const checkAllAccounts = useCallback(async (): Promise<void> => {
setAccountCheckBusy(true);
try {
const statuses = await window.rd.checkDebridAccounts();
if (!statuses || statuses.length === 0) {
showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200);
} else {
const valid = statuses.filter((st) => st.valid).length;
const premium = statuses.filter((st) => st.isPremium).length;
showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
}
} catch (error) {
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
} finally {
setAccountCheckBusy(false);
}
}, [showToast]);
const openCreateAccountDialog = (): void => {
setAccountDialogSearch("");
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
@@ -4868,9 +4925,14 @@ export function App(): ReactElement {
<h3>Accounts</h3>
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
</div>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
Account hinzufügen
</button>
<div className="account-board-header-actions">
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"}
</button>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
Account hinzufügen
</button>
</div>
</div>
<div className="account-board-summary">
@@ -5035,6 +5097,31 @@ export function App(): ReactElement {
)}
</div>
<div className="settings-section card">
<div className="account-board-header">
<div>
<h3>Rotations-Verlauf</h3>
<div className="hint">Zeigt, welcher Account/Key zuletzt für die Link-Umwandlung versucht wurde und warum gewechselt wurde.</div>
</div>
</div>
<div className="rotation-panel">
{(!snapshot?.rotationEvents || snapshot.rotationEvents.length === 0) ? (
<div className="rotation-empty">Noch keine Rotations-Ereignisse. Sobald ein Account/Key bei der Link-Umwandlung fehlschlägt oder gewechselt wird, erscheint es hier.</div>
) : (
snapshot.rotationEvents.map((ev) => (
<div key={ev.id} className={`rotation-event ${ev.level}`}>
<span className="rotation-time">{new Date(ev.at).toLocaleTimeString()}</span>
<span className="rotation-body">
<strong>{ev.provider} · {ev.accountLabel}</strong>{" "}
{rotationEventText(ev)}
{ev.reason ? <span className="rotation-reason"> ({ev.reason})</span> : null}
</span>
</div>
))
)}
</div>
</div>
<div className="settings-section card">
<h3>Hoster-Reihenfolge</h3>
<div className="hint">
@@ -5600,6 +5687,15 @@ export function App(): ReactElement {
<div className="account-dl-key-meta">
<strong>Account {index + 1}</strong>
<span>{maskMegaDebridLogin(account.login)}</span>
{(() => {
const st = snapshot?.settings?.debridAccountStatuses?.[getMegaDebridAccountId(account.login)];
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
const checkedAgo = formatCheckedAgo(st.checkedAt);
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${checkedAgo}`;
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Login ungültig</span>;
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Login OK · kein Premium</span>;
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
})()}
</div>
<button
className="btn danger"
@@ -5672,6 +5768,14 @@ export function App(): ReactElement {
<div className="account-dl-key-meta">
<strong>{key.label}</strong>
<span>{key.masked}</span>
{(() => {
const st = snapshot?.settings?.debridAccountStatuses?.[key.id];
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${formatCheckedAgo(st.checkedAt)}`;
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Key ungültig</span>;
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Key OK · kein Premium</span>;
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
})()}
</div>
<input
inputMode="decimal"
+40
View File
@@ -3138,3 +3138,43 @@ td {
grid-column: span 1;
}
}
/* ── Account validity + premium badges (account check) ───────────────── */
.account-board-header-actions { display: flex; gap: 8px; align-items: center; }
.account-validity-badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
border: 1px solid transparent;
white-space: nowrap;
}
.account-validity-badge.ok { color: #1c1206; background: linear-gradient(90deg, #7bd88f, #4fb96a); border-color: #4fb96a; }
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
/* ── Live account-rotation panel ─────────────────────────────────────── */
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
.rotation-event {
display: grid;
grid-template-columns: 72px 1fr;
gap: 8px;
align-items: baseline;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
border-left: 3px solid var(--line, #4a4032);
background: rgba(255,255,255,0.02);
}
.rotation-event.WARN { border-left-color: #f2c14e; }
.rotation-event.ERROR { border-left-color: #d9534f; }
.rotation-event.INFO { border-left-color: #4fb96a; }
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
.rotation-event .rotation-body strong { font-weight: 600; }
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }