From 3977184fd4bedbff9abbd2a6887d0dd4d40f1cb1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 30 May 2026 21:19:23 +0200 Subject: [PATCH] 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 --- src/main/account-check.ts | 220 +++++++++++++++++++++++++++++++ src/main/account-rotation-log.ts | 67 ++++++++++ src/main/app-controller.ts | 15 +++ src/main/constants.ts | 1 + src/main/download-manager.ts | 54 +++++++- src/main/main.ts | 4 + src/main/storage.ts | 36 ++++- src/preload/preload.ts | 98 +++++++------- src/renderer/App.tsx | 112 +++++++++++++++- src/renderer/styles.css | 40 ++++++ src/shared/ipc.ts | 67 +++++----- src/shared/preload-api.ts | 70 +++++----- src/shared/types.ts | 45 +++++++ tasks/lessons.md | 20 +++ tests/account-check.test.ts | 162 +++++++++++++++++++++++ 15 files changed, 885 insertions(+), 126 deletions(-) create mode 100644 src/main/account-check.ts create mode 100644 tests/account-check.test.ts diff --git a/src/main/account-check.ts b/src/main/account-check.ts new file mode 100644 index 0000000..1fdc768 --- /dev/null +++ b/src/main/account-check.ts @@ -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 | null { + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" ? (parsed as Record) : 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 { + 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 { + 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; + // 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 { + 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> = [ + ...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(taskFns: Array<() => Promise>, limit: number): Promise { + const results: T[] = new Array(taskFns.length); + let nextIndex = 0; + const worker = async (): Promise => { + 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; +} diff --git a/src/main/account-rotation-log.ts b/src/main/account-rotation-log.ts index f5fa995..821e510 100644 --- a/src/main/account-rotation-log.ts +++ b/src/main/account-rotation-log.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { RotationEvent } from "../shared/types"; /** Dedicated log file for multi-account/key rotation events: * Mega-Debrid account selection, Debrid-Link key selection, per-attempt @@ -9,6 +10,70 @@ import path from "node:path"; 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, + 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_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14); @@ -108,6 +173,8 @@ export function logAccountRotation( event: string, fields?: Record ): void { + // Surface to the UI ring buffer regardless of whether the file log is ready. + pushRotationEvent(level, provider, accountLabel, event, fields); if (!rotationLogPath) { return; } diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index a550a16..745d61b 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -5,6 +5,7 @@ import { AddLinksPayload, AllDebridHostInfo, AppSettings, + DebridAccountStatus, DebridProvider, DuplicatePolicy, HistoryEntry, @@ -23,6 +24,7 @@ import { importDlcContainers } from "./container"; import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; +import { checkAllDebridAccounts } from "./account-check"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; @@ -374,6 +376,19 @@ export class AppController { 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 { + 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 { const result = await checkGitHubUpdate(this.settings.updateRepo); if (!result.error) { diff --git a/src/main/constants.ts b/src/main/constants.ts index a9c0b66..ea82c25 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -123,6 +123,7 @@ export function defaultSettings(): AppSettings { megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, + debridAccountStatuses: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 }; diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c34ae74..af51865 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -19,12 +19,13 @@ import { SessionState, StartConflictEntry, StartConflictResolutionResult, - UiSnapshot -} from "../shared/types"; + UiSnapshot, DebridAccountStatus } from "../shared/types"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { addDebridLinkApiKeyDailyUsageBytes, addDebridLinkApiKeyTotalUsageBytes, + addMegaDebridAccountDailyUsageBytes, + addMegaDebridAccountTotalUsageBytes, addProviderDailyUsageBytes, addProviderTotalUsageBytes, 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 { validateFileAgainstManifest } from "./integrity"; import { logger } from "./logger"; +import { getRecentRotationEvents, setRotationEventListener } from "./account-rotation-log"; import { ensureItemLog, getItemLogPath as getPersistedItemLogPath, logItemEvent as writeItemLogEvent } from "./item-log"; import { ensurePackageLog, getPackageLogPath as getPersistedPackageLogPath, logPackageEvent as writePackageLogEvent } from "./package-log"; import { logRenameEvent as writeRenameLogEvent } from "./rename-log"; @@ -1808,8 +1810,26 @@ export class DownloadManager extends EventEmitter { this.recoverPostProcessingOnStartup(); this.checkExistingRapidgatorLinks(); 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 { const pkg = this.session.packages[packageId]; if (pkg) { @@ -2042,6 +2062,17 @@ export class DownloadManager extends EventEmitter { } } + public applyDebridAccountStatuses(statuses: DebridAccountStatus[]): void { + const map: Record = { ...(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 { const previous = this.settings; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); @@ -2324,6 +2355,7 @@ export class DownloadManager extends EventEmitter { : null; return { + rotationEvents: getRecentRotationEvents(40), settings: snapshotSettings, session: snapshotSession, summary: snapshotSummary, @@ -5643,6 +5675,7 @@ export class DownloadManager extends EventEmitter { public prepareForShutdown(): void { logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); + this.rotationListenerActive = false; this.clearPersistTimer(); if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); @@ -7781,6 +7814,15 @@ export class DownloadManager extends EventEmitter { this.settings.debridLinkApiKeyDailyUsageBytes = nextKeyUsage.debridLinkApiKeyDailyUsageBytes; 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 { @@ -7796,12 +7838,12 @@ export class DownloadManager extends EventEmitter { return Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); } if (effectiveProvider === "megadebrid-api") { - return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim() - || this.settings.megaDebridApiEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); + const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled)); } if (effectiveProvider === "megadebrid-web") { - return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim() - || this.settings.megaDebridWebEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); + const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim())); + return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled)); } if (effectiveProvider === "bestdebrid") { return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim()); diff --git a/src/main/main.ts b/src/main/main.ts index d05e425..40c0d5c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -644,6 +644,10 @@ function registerIpcHandlers(): void { return controller.getDebridLinkHostLimits(); }); + ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { + return controller.checkDebridAccounts(); + }); + ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => { const options = { properties: ["openFile"] as Array<"openFile">, diff --git a/src/main/storage.ts b/src/main/storage.ts index 1590279..cb74998 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -3,7 +3,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; 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 { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -230,6 +230,39 @@ function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Re return result; } +function normalizeDebridAccountStatuses( + value: unknown, + megaIds: string[], + debridLinkIds: string[] +): Record { + const allowed = new Set([...megaIds, ...debridLinkIds]); + const result: Record = {}; + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [key, raw] of Object.entries(value as Record)) { + if (!allowed.has(key) || !raw || typeof raw !== "object") { + continue; + } + const entry = raw as Partial; + 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[] { if (!Array.isArray(raw)) { return []; @@ -452,6 +485,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { ? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds) : {}, megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds), + debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds), providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index e9a126e..99fd059 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,11 +1,12 @@ import { contextBridge, ipcRenderer } from "electron"; -import { - AddLinksPayload, - AllDebridHostInfo, - AppSettings, - DebridLinkHostLimitInfo, - DebridProvider, - DuplicatePolicy, +import { + AddLinksPayload, + AllDebridHostInfo, + AppSettings, + DebridAccountStatus, + DebridLinkHostLimitInfo, + DebridProvider, + DuplicatePolicy, HistoryEntry, PackagePriority, SessionStats, @@ -22,13 +23,13 @@ const api: ElectronApi = { getSnapshot: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT), getVersion: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION), checkUpdates: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES), - installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), - openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), - updateSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), - resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), - resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), - addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => - ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), + installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), + openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), + updateSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), + resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), + resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => + ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), getStartConflicts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), @@ -40,42 +41,43 @@ const api: ElectronApi = { stop: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.STOP), togglePause: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), cancelPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), - renamePackage: (packageId: string, newName: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), - reorderPackages: (packageIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), - removeItem: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), - togglePackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), - exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds), - exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds), - exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), - importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), - toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), - pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), - pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), - getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), - resetSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), - resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), - restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), + renamePackage: (packageId: string, newName: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), + reorderPackages: (packageIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), + removeItem: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), + togglePackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId), + exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds), + exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds), + exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), + importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), + toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), + pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), + pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), + getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), + resetSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), + resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), + restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), - exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), - importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), - exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), - openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), - openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), - openRenameLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG), - openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), - openTraceLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), - openPackageLog: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), - openItemLog: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), - getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK), - getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), - setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), - rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), - openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), + exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), + importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), + openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), + openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), + openRenameLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG), + openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), + openTraceLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), + openPackageLog: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), + openItemLog: (itemId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), + getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK), + getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), + setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), + rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), + openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), - importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), - getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), - getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), - retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), + importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), + getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), + getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), + checkDebridAccounts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS), + retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a19535e..1e839cc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 | 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 => { + 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 {

Accounts

Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.
- +
+ + +
@@ -5035,6 +5097,31 @@ export function App(): ReactElement { )}
+
+
+
+

Rotations-Verlauf

+
Zeigt, welcher Account/Key zuletzt für die Link-Umwandlung versucht wurde und warum gewechselt wurde.
+
+
+
+ {(!snapshot?.rotationEvents || snapshot.rotationEvents.length === 0) ? ( +
Noch keine Rotations-Ereignisse. Sobald ein Account/Key bei der Link-Umwandlung fehlschlägt oder gewechselt wird, erscheint es hier.
+ ) : ( + snapshot.rotationEvents.map((ev) => ( +
+ {new Date(ev.at).toLocaleTimeString()} + + {ev.provider} · {ev.accountLabel}{" "} + {rotationEventText(ev)} + {ev.reason ? ({ev.reason}) : null} + +
+ )) + )} +
+
+

Hoster-Reihenfolge

@@ -5600,6 +5687,15 @@ export function App(): ReactElement {
Account {index + 1} {maskMegaDebridLogin(account.login)} + {(() => { + const st = snapshot?.settings?.debridAccountStatuses?.[getMegaDebridAccountId(account.login)]; + if (!st) return Noch nicht geprüft; + const checkedAgo = formatCheckedAgo(st.checkedAt); + const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${checkedAgo}`; + if (!st.valid) return Login ungültig; + if (!st.isPremium) return Login OK · kein Premium; + return {st.message}; + })()}
Promise; - getVersion: () => Promise; - checkUpdates: () => Promise; + getVersion: () => Promise; + checkUpdates: () => Promise; installUpdate: () => Promise; openExternal: (url: string) => Promise; updateSettings: (settings: Partial) => Promise; resetProviderDailyUsage: (provider: DebridProvider) => Promise; resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; - addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; - getStartConflicts: () => Promise; - resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; - clearAll: () => Promise; - start: () => Promise; - startPackages: (packageIds: string[]) => Promise; - stop: () => Promise; - togglePause: () => Promise; - cancelPackage: (packageId: string) => Promise; - renamePackage: (packageId: string, newName: string) => Promise; - reorderPackages: (packageIds: string[]) => Promise; + addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; + getStartConflicts: () => Promise; + resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise; + clearAll: () => Promise; + start: () => Promise; + startPackages: (packageIds: string[]) => Promise; + stop: () => Promise; + togglePause: () => Promise; + cancelPackage: (packageId: string) => Promise; + renamePackage: (packageId: string, newName: string) => Promise; + reorderPackages: (packageIds: string[]) => Promise; removeItem: (itemId: string) => Promise; togglePackage: (packageId: string) => Promise; exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; @@ -52,7 +53,7 @@ export interface ElectronApi { resetSessionStats: () => Promise; resetDownloadStats: () => Promise; restart: () => Promise; - quit: () => Promise; + quit: () => Promise; exportBackup: () => Promise<{ saved: boolean }>; importBackup: () => Promise<{ restored: boolean; message: string }>; exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; @@ -68,21 +69,22 @@ export interface ElectronApi { setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise; rotateDebugToken: () => Promise<{ path: string }>; openRealDebridLogin: () => Promise; - openAllDebridLogin: () => Promise; + openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; getDebridLinkHostLimits: () => Promise; + checkDebridAccounts: () => Promise; retryExtraction: (packageId: string) => Promise; - extractNow: (packageId: string) => Promise; - resetPackage: (packageId: string) => Promise; - getHistory: () => Promise; - clearHistory: () => Promise; - removeHistoryEntry: (entryId: string) => Promise; - setPackagePriority: (packageId: string, priority: PackagePriority) => Promise; - skipItems: (itemIds: string[]) => Promise; - resetItems: (itemIds: string[]) => Promise; - startItems: (itemIds: string[]) => Promise; - onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; - onClipboardDetected: (callback: (links: string[]) => void) => () => void; - onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; -} + extractNow: (packageId: string) => Promise; + resetPackage: (packageId: string) => Promise; + getHistory: () => Promise; + clearHistory: () => Promise; + removeHistoryEntry: (entryId: string) => Promise; + setPackagePriority: (packageId: string, priority: PackagePriority) => Promise; + skipItems: (itemIds: string[]) => Promise; + resetItems: (itemIds: string[]) => Promise; + startItems: (itemIds: string[]) => Promise; + onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; + onClipboardDetected: (callback: (links: string[]) => void) => () => void; + onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 31fc56c..52a9c40 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,28 @@ export interface DownloadStats { 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 { token: string; realDebridUseWebLogin: boolean; @@ -135,6 +157,9 @@ export interface AppSettings { megaDebridAccountDailyLimitBytes: Record; megaDebridAccountDailyUsageBytes: Record; megaDebridAccountTotalUsageBytes: Record; + /** 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; providerDailyUsageDay: string; scheduledStartEpochMs: number; } @@ -217,6 +242,23 @@ export interface ContainerImportResult { 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 { settings: AppSettings; session: SessionState; @@ -239,6 +281,9 @@ export interface UiSnapshot { removedItemIds?: string[]; /** Package IDs to remove from the renderer's master state when payloadKind="delta". */ 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 { diff --git a/tasks/lessons.md b/tasks/lessons.md index 672d98e..25a2a31 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,5 +1,25 @@ # 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) **Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im diff --git a/tests/account-check.test.ts b/tests/account-check.test.ts new file mode 100644 index 0000000..846f587 --- /dev/null +++ b/tests/account-check.test.ts @@ -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}`)); + }); +});