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:
parent
748c07a531
commit
3977184fd4
220
src/main/account-check.ts
Normal file
220
src/main/account-check.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -123,6 +123,7 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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">,
|
||||||
|
|||||||
@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
162
tests/account-check.test.ts
Normal 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}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user