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
parent 748c07a531
commit 3977184fd4
15 changed files with 885 additions and 126 deletions

220
src/main/account-check.ts Normal file
View File

@ -0,0 +1,220 @@
import type { AppSettings, DebridAccountStatus } from "../shared/types";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
import { logger } from "./logger";
import { compactErrorText } from "./utils";
/**
* Account-Validity + Premium-Check fuer Multi-Account-Provider.
*
* Standalone (eigene fetch-Calls, kein Import aus debrid.ts) damit es ohne
* Zirkular-Abhaengigkeit von der "Check all"-IPC und beim Programmstart genutzt
* werden kann.
*
* Verifizierte API-Felder (Live-Probe):
* - Mega-Debrid connectUser -> { response_code:"ok", token, vip_end (Unix-ts), email }
* - Debrid-Link /account/infos -> { success, value: { accountType, premiumLeft (s), username } }
*/
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const CHECK_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const CHECK_TIMEOUT_MS = 20000;
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
const timeout = AbortSignal.timeout(ms);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function parseJsonSafe(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function formatRemaining(premiumUntilMs: number | null, now: number): string {
if (premiumUntilMs == null) {
return "Premium-Status unbekannt";
}
if (premiumUntilMs <= 0) {
return "Kein Premium";
}
const remainingMs = premiumUntilMs - now;
if (remainingMs <= 0) {
return "Premium abgelaufen";
}
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
if (days >= 1) {
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
}
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
return `Premium noch ${hours} Std`;
}
/** Check a single Mega-Debrid account via connectUser. */
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: account.id,
provider: "megadebrid",
label: account.label,
maskedLogin: account.maskedLogin,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": CHECK_USER_AGENT },
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.response_code !== "ok") {
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
return { ...base, message: `Ungueltiger Login: ${reason}` };
}
// vip_end is a Unix timestamp (seconds). 0 / missing => no premium.
const vipEndRaw = Number(payload.vip_end || 0);
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
const isPremium = premiumUntilMs > now;
const email = String(payload.email || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs,
email,
message: formatRemaining(premiumUntilMs, now)
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
/** Check a single Debrid-Link API key via /account/infos. */
export async function checkDebridLinkKey(
key: DebridLinkApiKeyEntry,
signal?: AbortSignal,
now = Date.now()
): Promise<DebridAccountStatus> {
const base: DebridAccountStatus = {
accountId: key.id,
provider: "debridlink",
label: key.label,
maskedLogin: key.masked,
valid: false,
isPremium: false,
premiumUntilMs: null,
message: "",
checkedAt: now
};
try {
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
headers: {
Authorization: `Bearer ${key.token}`,
"User-Agent": CHECK_USER_AGENT
},
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
});
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
// 401 = bad/expired token
if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
}
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
}
if (payload.success === false) {
const reason = String(payload.error || "Key abgelehnt");
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
}
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
// premiumLeft = seconds of premium remaining. accountType>0 also indicates premium.
const premiumLeftSec = Number(value.premiumLeft || 0);
const accountType = Number(value.accountType || 0);
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
const isPremium = premiumUntilMs > now || accountType > 0;
const username = String(value.username || "").trim() || undefined;
return {
...base,
valid: true,
isPremium,
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
email: username,
message: premiumUntilMs > 0
? formatRemaining(premiumUntilMs, now)
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
};
} catch (error) {
const errText = compactErrorText(error);
const aborted = signal?.aborted || /aborted/i.test(errText);
return {
...base,
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
};
}
}
/** Check ALL configured multi-account credentials (Mega-Debrid accounts +
* Debrid-Link keys) concurrently. Returns one status per account id. */
export async function checkAllDebridAccounts(
settings: AppSettings,
signal?: AbortSignal
): Promise<DebridAccountStatus[]> {
const now = Date.now();
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
// Each task is a thunk so we can throttle concurrency. Firing all accounts at
// once (e.g. 9+ Debrid-Link keys) can trip provider rate-limits and produce
// false "invalid" badges, so cap at CHECK_CONCURRENCY parallel checks.
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
];
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
logger.info(
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
);
return results;
}
const CHECK_CONCURRENCY = 4;
/** Run thunks with a bounded number in flight, preserving result order. */
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(taskFns.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
while (nextIndex < taskFns.length) {
const current = nextIndex;
nextIndex += 1;
results[current] = await taskFns[current]();
}
};
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
await Promise.all(workers);
return results;
}

View File

@ -1,5 +1,6 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { RotationEvent } from "../shared/types";
/** Dedicated log file for multi-account/key rotation events: /** Dedicated log file for multi-account/key rotation events:
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt * Mega-Debrid account selection, Debrid-Link key selection, per-attempt
@ -9,6 +10,70 @@ import path from "node:path";
type RotationLevel = "INFO" | "WARN" | "ERROR"; type RotationLevel = "INFO" | "WARN" | "ERROR";
/** In-memory ring buffer of the most recent rotation events so the UI can show
* a live "which account was tried and why it failed" panel the same events
* written to account-rotation.log, but surfaced to the renderer via snapshot. */
const ROTATION_EVENT_RING_MAX = 60;
const rotationEventRing: RotationEvent[] = [];
let rotationEventSeq = 0;
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
/** Register a callback fired whenever a new rotation event is recorded (used by
* the download-manager to push a fresh snapshot to the UI immediately). */
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
rotationEventListener = listener;
}
/** Returns the recent rotation events, newest first. */
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
const slice = rotationEventRing.slice(-limit);
slice.reverse();
return slice;
}
/** Events that are noise for the UI panel (per-attempt TEST markers). The panel
* focuses on outcomes: OK / FAILED / FATAL / skips. */
function isUiRelevantRotationEvent(event: string): boolean {
return event !== "TEST";
}
function pushRotationEvent(
level: RotationLevel,
provider: string,
accountLabel: string,
event: string,
fields?: Record<string, unknown>,
at = Date.now()
): void {
if (!isUiRelevantRotationEvent(event)) {
return;
}
rotationEventSeq += 1;
const entry: RotationEvent = {
id: `rot_${at}_${rotationEventSeq}`,
at,
level,
provider,
accountLabel,
event,
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
category: fields && fields.category != null ? String(fields.category) : undefined,
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
next: fields && fields.next != null ? String(fields.next) : undefined
};
rotationEventRing.push(entry);
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
}
if (rotationEventListener) {
try {
rotationEventListener(entry);
} catch {
// never let a UI push break the rotation flow
}
}
}
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024); const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14); const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
@ -108,6 +173,8 @@ export function logAccountRotation(
event: string, event: string,
fields?: Record<string, unknown> fields?: Record<string, unknown>
): void { ): void {
// Surface to the UI ring buffer regardless of whether the file log is ready.
pushRotationEvent(level, provider, accountLabel, event, fields);
if (!rotationLogPath) { if (!rotationLogPath) {
return; return;
} }

View File

@ -5,6 +5,7 @@ import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
@ -23,6 +24,7 @@ import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts } from "./account-check";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
@ -374,6 +376,19 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
} }
/** Check login validity + premium expiry for ALL configured multi-account
* credentials (Mega-Debrid accounts + Debrid-Link keys), persist the result
* into settings (so badges survive restart), and return the statuses. */
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
valid: statuses.filter((s) => s.valid).length,
premium: statuses.filter((s) => s.isPremium).length
});
return statuses;
}
public async checkUpdates(): Promise<UpdateCheckResult> { public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo); const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) { if (!result.error) {

View File

@ -123,6 +123,7 @@ export function defaultSettings(): AppSettings {
megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {}, megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {}, megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0 scheduledStartEpochMs: 0
}; };

View File

@ -19,12 +19,13 @@ import {
SessionState, SessionState,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot UiSnapshot, DebridAccountStatus } from "../shared/types";
} from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { import {
addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyDailyUsageBytes,
addDebridLinkApiKeyTotalUsageBytes, addDebridLinkApiKeyTotalUsageBytes,
addMegaDebridAccountDailyUsageBytes,
addMegaDebridAccountTotalUsageBytes,
addProviderDailyUsageBytes, addProviderDailyUsageBytes,
addProviderTotalUsageBytes, addProviderTotalUsageBytes,
getProviderUsageDayKey, getProviderUsageDayKey,
@ -55,6 +56,7 @@ import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, Meg
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { logger } from "./logger"; import { logger } from "./logger";
import { getRecentRotationEvents, setRotationEventListener } from "./account-rotation-log";
import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log";
import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log";
import { logRenameEvent as writeRenameLogEvent } from "./rename-log"; import { logRenameEvent as writeRenameLogEvent } from "./rename-log";
@ -1808,8 +1810,26 @@ export class DownloadManager extends EventEmitter {
this.recoverPostProcessingOnStartup(); this.recoverPostProcessingOnStartup();
this.checkExistingRapidgatorLinks(); this.checkExistingRapidgatorLinks();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`));
// Push a fresh snapshot to the UI whenever a rotation event is recorded so
// the live rotation panel updates immediately. The listener is module-global,
// so guard against firing on a torn-down manager after shutdown.
setRotationEventListener(() => {
if (this.rotationListenerActive === false) {
return;
}
try {
// Forced emit: rotation happens during the idle link-resolve phase (no
// downloads running), where the normal emit cadence can be starved. The
// forced path has a 120ms floor — the right cadence for a live log panel.
this.emitState(true);
} catch {
// never let a UI push break the rotation flow
}
});
} }
private rotationListenerActive = true;
public getPackageLogPath(packageId: string): string | null { public getPackageLogPath(packageId: string): string | null {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (pkg) { if (pkg) {
@ -2042,6 +2062,17 @@ export class DownloadManager extends EventEmitter {
} }
} }
public applyDebridAccountStatuses(statuses: DebridAccountStatus[]): void {
const map: Record<string, DebridAccountStatus> = { ...(this.settings.debridAccountStatuses || {}) };
for (const status of statuses) {
map[status.accountId] = status;
}
this.settings.debridAccountStatuses = map;
this.invalidateSettingsSnapshotCache();
void saveSettingsAsync(this.storagePaths, this.settings).catch((err) => logger.warn(`saveSettingsAsync Fehler (account-status): ${compactErrorText(err as Error)}`));
this.emitState();
}
public setSettings(next: AppSettings): void { public setSettings(next: AppSettings): void {
const previous = this.settings; const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
@ -2324,6 +2355,7 @@ export class DownloadManager extends EventEmitter {
: null; : null;
return { return {
rotationEvents: getRecentRotationEvents(40),
settings: snapshotSettings, settings: snapshotSettings,
session: snapshotSession, session: snapshotSession,
summary: snapshotSummary, summary: snapshotSummary,
@ -5643,6 +5675,7 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void { public prepareForShutdown(): void {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.rotationListenerActive = false;
this.clearPersistTimer(); this.clearPersistTimer();
if (this.stateEmitTimer) { if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer); clearTimeout(this.stateEmitTimer);
@ -7781,6 +7814,15 @@ export class DownloadManager extends EventEmitter {
this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes;
this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes; this.settings.debridLinkApiKeyTotalUsageBytes = nextKeyTotalUsage.debridLinkApiKeyTotalUsageBytes;
} }
// Bug-Fix: Mega-Debrid Per-Account-Verbrauch wurde nie erfasst (nur Debrid-Link),
// sodass die "Heute"/"Insgesamt"-Statistik pro Mega-Account immer 0 anzeigte.
if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && providerAccountId) {
const nextAcctUsage = addMegaDebridAccountDailyUsageBytes(this.settings, providerAccountId, byteDelta);
const nextAcctTotalUsage = addMegaDebridAccountTotalUsageBytes(this.settings, providerAccountId, byteDelta);
this.settings.providerDailyUsageDay = nextAcctUsage.providerDailyUsageDay;
this.settings.megaDebridAccountDailyUsageBytes = nextAcctUsage.megaDebridAccountDailyUsageBytes;
this.settings.megaDebridAccountTotalUsageBytes = nextAcctTotalUsage.megaDebridAccountTotalUsageBytes;
}
} }
private isProviderConfigured(provider: DebridProvider): boolean { private isProviderConfigured(provider: DebridProvider): boolean {
@ -7796,12 +7838,12 @@ export class DownloadManager extends EventEmitter {
return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
} }
if (effectiveProvider === "megadebrid-api") { if (effectiveProvider === "megadebrid-api") {
return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim() const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|| this.settings.megaDebridApiEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
} }
if (effectiveProvider === "megadebrid-web") { if (effectiveProvider === "megadebrid-web") {
return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim() const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|| this.settings.megaDebridWebEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled));
} }
if (effectiveProvider === "bestdebrid") { if (effectiveProvider === "bestdebrid") {
return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim());

View File

@ -644,6 +644,10 @@ function registerIpcHandlers(): void {
return controller.getDebridLinkHostLimits(); return controller.getDebridLinkHostLimits();
}); });
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
return controller.checkDebridAccounts();
});
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
const options = { const options = {
properties: ["openFile"] as Array<"openFile">, properties: ["openFile"] as Array<"openFile">,

View File

@ -3,7 +3,7 @@ import fsp from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import { defaultSettings } from "./constants"; import { defaultSettings } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
@ -230,6 +230,39 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re
return result; return result;
} }
function normalizeDebridAccountStatuses(
value: unknown,
megaIds: string[],
debridLinkIds: string[]
): Record<string, DebridAccountStatus> {
const allowed = new Set([...megaIds, ...debridLinkIds]);
const result: Record<string, DebridAccountStatus> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
if (!allowed.has(key) || !raw || typeof raw !== "object") {
continue;
}
const entry = raw as Partial<DebridAccountStatus>;
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
continue;
}
result[key] = {
accountId: entry.accountId,
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
label: String(entry.label || ""),
maskedLogin: String(entry.maskedLogin || ""),
valid: Boolean(entry.valid),
isPremium: Boolean(entry.isPremium),
premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null,
email: typeof entry.email === "string" ? entry.email : undefined,
message: String(entry.message || ""),
checkedAt: entry.checkedAt
};
}
}
return result;
}
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] { function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
if (!Array.isArray(raw)) { if (!Array.isArray(raw)) {
return []; return [];
@ -452,6 +485,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds) ? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
: {}, : {},
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds), megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
}; };

View File

@ -3,6 +3,7 @@ import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
@ -75,6 +76,7 @@ const api: ElectronApi = {
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),

View File

@ -1,6 +1,6 @@
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { 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 { import type {
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
@ -866,6 +866,7 @@ const emptySnapshot = (): UiSnapshot => ({
megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyLimitBytes: {},
megaDebridAccountDailyUsageBytes: {}, megaDebridAccountDailyUsageBytes: {},
megaDebridAccountTotalUsageBytes: {}, megaDebridAccountTotalUsageBytes: {},
debridAccountStatuses: {},
providerDailyUsageDay: getProviderUsageDayKey(), providerDailyUsageDay: getProviderUsageDayKey(),
scheduledStartEpochMs: 0 scheduledStartEpochMs: 0
}, },
@ -1121,6 +1122,37 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef
return info.note || "Nicht verfügbar"; 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( function getDebridLinkKeyStatusDisplay(
key: DebridLinkAccountKeyEntry, key: DebridLinkAccountKeyEntry,
info: DebridLinkHostLimitInfo | null | undefined info: DebridLinkHostLimitInfo | null | undefined
@ -1569,6 +1601,7 @@ export function App(): ReactElement {
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
@ -1893,6 +1926,12 @@ export function App(): ReactElement {
unsubscribe = window.rd.onStateUpdate((wireState) => { unsubscribe = window.rd.onStateUpdate((wireState) => {
// Merge delta payloads into the master snapshot. Full payloads replace // Merge delta payloads into the master snapshot. Full payloads replace
// the master entirely (initial sync + periodic 30s resync). // 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; let merged: UiSnapshot;
const master = masterSnapshotRef.current; const master = masterSnapshotRef.current;
if (wireState.payloadKind === "delta" && master) { 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 => { const openCreateAccountDialog = (): void => {
setAccountDialogSearch(""); setAccountDialogSearch("");
setAccountDialog(createAccountDialogState("create", null, settingsDraft)); setAccountDialog(createAccountDialogState("create", null, settingsDraft));
@ -4868,9 +4925,14 @@ export function App(): ReactElement {
<h3>Accounts</h3> <h3>Accounts</h3>
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div> <div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
</div> </div>
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}> <div className="account-board-header-actions">
Account hinzufügen <button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
</button> {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>
<div className="account-board-summary"> <div className="account-board-summary">
@ -5035,6 +5097,31 @@ export function App(): ReactElement {
)} )}
</div> </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"> <div className="settings-section card">
<h3>Hoster-Reihenfolge</h3> <h3>Hoster-Reihenfolge</h3>
<div className="hint"> <div className="hint">
@ -5600,6 +5687,15 @@ export function App(): ReactElement {
<div className="account-dl-key-meta"> <div className="account-dl-key-meta">
<strong>Account {index + 1}</strong> <strong>Account {index + 1}</strong>
<span>{maskMegaDebridLogin(account.login)}</span> <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> </div>
<button <button
className="btn danger" className="btn danger"
@ -5672,6 +5768,14 @@ export function App(): ReactElement {
<div className="account-dl-key-meta"> <div className="account-dl-key-meta">
<strong>{key.label}</strong> <strong>{key.label}</strong>
<span>{key.masked}</span> <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> </div>
<input <input
inputMode="decimal" inputMode="decimal"

View File

@ -3138,3 +3138,43 @@ td {
grid-column: span 1; 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); }

View File

@ -55,6 +55,7 @@ export const IPC_CHANNELS = {
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
RETRY_EXTRACTION: "queue:retry-extraction", RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now", EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package", RESET_PACKAGE: "queue:reset-package",

View File

@ -2,6 +2,7 @@ import type {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridAccountStatus,
DebugSetupCheckResult, DebugSetupCheckResult,
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
@ -72,6 +73,7 @@ export interface ElectronApi {
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>; getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>; getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
retryExtraction: (packageId: string) => Promise<void>; retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>; extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>; resetPackage: (packageId: string) => Promise<void>;

View File

@ -53,6 +53,28 @@ export interface DownloadStats {
runtimeMeasuredAt: number; runtimeMeasuredAt: number;
} }
/** Result of a login/premium validity check for a single multi-account
* credential (Mega-Debrid account or Debrid-Link API key). Persisted in
* settings so the badges survive an app restart, refreshed by the "Check all"
* button or whenever an account is used. */
export interface DebridAccountStatus {
accountId: string;
provider: "megadebrid" | "debridlink";
label: string;
maskedLogin: string;
/** Login worked (credentials accepted by the provider). */
valid: boolean;
/** Currently a paying/premium account. */
isPremium: boolean;
/** Epoch ms when premium expires; null = unknown, 0 = no premium. */
premiumUntilMs: number | null;
email?: string;
/** Human-readable one-line summary for the badge tooltip. */
message: string;
/** Epoch ms of the last check. */
checkedAt: number;
}
export interface AppSettings { export interface AppSettings {
token: string; token: string;
realDebridUseWebLogin: boolean; realDebridUseWebLogin: boolean;
@ -135,6 +157,9 @@ export interface AppSettings {
megaDebridAccountDailyLimitBytes: Record<string, number>; megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>; megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>; megaDebridAccountTotalUsageBytes: Record<string, number>;
/** Last known login/premium status per multi-account credential (id to status).
* Keyed by Mega-Debrid / Debrid-Link account ids; refreshed by the account check. */
debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string; providerDailyUsageDay: string;
scheduledStartEpochMs: number; scheduledStartEpochMs: number;
} }
@ -217,6 +242,23 @@ export interface ContainerImportResult {
source: "dlc"; source: "dlc";
} }
/** A single account/key rotation event surfaced to the UI so the user sees
* exactly which account was tried and why it failed (not just a generic
* "Link-Umwandlung erneut"). Mirrors what is written to account-rotation.log. */
export interface RotationEvent {
id: string;
at: number;
level: "INFO" | "WARN" | "ERROR";
provider: string;
accountLabel: string;
/** OK | FAILED | FATAL | SKIP_COOLDOWN | SKIP_DISABLED | SKIP_DAILY_LIMIT | TEST | ... */
event: string;
reason?: string;
category?: string;
cooldownSec?: number;
next?: string;
}
export interface UiSnapshot { export interface UiSnapshot {
settings: AppSettings; settings: AppSettings;
session: SessionState; session: SessionState;
@ -239,6 +281,9 @@ export interface UiSnapshot {
removedItemIds?: string[]; removedItemIds?: string[];
/** Package IDs to remove from the renderer's master state when payloadKind="delta". */ /** Package IDs to remove from the renderer's master state when payloadKind="delta". */
removedPackageIds?: string[]; removedPackageIds?: string[];
/** Most-recent account/key rotation events (newest first), for the live
* rotation panel. Always sent on full snapshots. */
rotationEvents?: RotationEvent[];
} }
export interface AddLinksPayload { export interface AddLinksPayload {

View File

@ -1,5 +1,25 @@
# Lessons # Lessons
## 2026-05-30 — Nicht in chaotische Parallel-Tool-Batches verfallen (User-Korrektur: "bist du in nem endless loop")
**Muster:** Bei einem großen Multi-File-Edit habe ich Dutzende Tool-Calls (Bash-Probes,
Reads, Edits, Python-Inline-Skripte, mehrfache tsc-Läufe) in EINEN Message-Block gepackt.
Resultat: Ein einzelner Fehler/Cancel hat die ganze parallele Kette abgebrochen, Edits
landeten halb, ich verlor den Überblick welche Änderung wirklich auf Disk war, und es
wirkte wie eine Endlosschleife. Dazu: wegwerf-`scripts/_*.py`/`_*.txt` als Workaround
gegen Output-Encoding statt der dedizierten Tools.
**Regel:**
- Edits über mehrere Dateien **sequenziell, einer nach dem anderen**, mit kurzer
Verifikation dazwischen — nicht 20 spekulative Calls auf einmal.
- Nach jedem Edit, der fehlschlagen kann (Anchor evtl. nicht eindeutig), das Ergebnis
lesen, bevor der nächste folgt. Edit/Write erroren laut — darauf vertrauen.
- KEINE Wegwerf-Python-Skripte ins Repo schreiben, um Shell-Output zu parsen. `Grep`/
`Read`/`Edit` nutzen. Wenn doch ein Temp nötig ist: nach `os.tmpdir()`, nie nach
`scripts/`, und sofort wieder löschen.
- Verifikation gebündelt am ENDE (1× tsc, 1× build, 1× vitest), nicht 10× zwischendrin.
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur) ## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im **Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im

162
tests/account-check.test.ts Normal file
View File

@ -0,0 +1,162 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
}
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
}
function mockFetchOnce(status: number, body: unknown): void {
const text = typeof body === "string" ? body : JSON.stringify(body);
vi.stubGlobal("fetch", vi.fn(async () => ({
ok: status >= 200 && status < 300,
status,
text: async () => text
})) as unknown as typeof fetch);
}
const NOW = 1_700_000_000_000; // fixed epoch ms
afterEach(() => {
vi.unstubAllGlobals();
});
describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; // +30 days
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(futureSec * 1000);
expect(st.email).toBe("a@b.de");
expect(st.message).toMatch(/Premium noch/);
});
it("reports valid but NOT premium when vip_end is in the past", async () => {
const pastSec = Math.floor(NOW / 1000) - 1000;
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
});
it("reports valid but no premium when vip_end is 0/missing", async () => {
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.premiumUntilMs).toBe(0);
expect(st.message).toMatch(/Kein Premium/);
});
it("reports invalid login when response_code != ok", async () => {
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Ungueltiger Login/);
});
it("reports invalid on HTTP error", async () => {
mockFetchOnce(500, "server error");
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
});
it("never throws on network error — returns a failed status", async () => {
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
});
});
describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60; // 60 days in seconds
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(true);
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
});
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true);
expect(st.isPremium).toBe(false);
expect(st.message).toMatch(/Free/);
});
it("reports invalid key on HTTP 401", async () => {
mockFetchOnce(401, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
expect(st.message).toMatch(/Ungueltiger API-Key/);
});
it("reports invalid key when success=false", async () => {
mockFetchOnce(200, { success: false, error: "badToken" });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(false);
});
});
describe("checkAllDebridAccounts", () => {
it("returns empty array when nothing configured", async () => {
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toEqual([]);
});
it("checks every configured mega account + debrid-link key", async () => {
// All requests succeed as valid premium
const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) {
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
}
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const settings = {
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
megaPassword: "",
debridLinkApiKeys: "key1\nkey2\nkey3"
} as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5); // 2 mega + 3 debrid-link
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true);
});
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
let inFlight = 0;
let maxInFlight = 0;
vi.stubGlobal("fetch", vi.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 5));
inFlight -= 1;
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
}) as unknown as typeof fetch);
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(9);
expect(maxInFlight).toBeLessThanOrEqual(4);
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
});
});