Compare commits
2 Commits
748c07a531
...
4b1625c5ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1625c5ee | ||
|
|
3977184fd4 |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.163",
|
||||
"version": "1.7.164",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
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 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<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_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||
|
||||
@ -108,6 +173,8 @@ export function logAccountRotation(
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
// Surface to the UI ring buffer regardless of whether the file log is ready.
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -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<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> {
|
||||
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
||||
if (!result.error) {
|
||||
|
||||
@ -123,6 +123,7 @@ export function defaultSettings(): AppSettings {
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
};
|
||||
|
||||
@ -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<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 {
|
||||
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());
|
||||
|
||||
@ -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">,
|
||||
|
||||
@ -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<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[] {
|
||||
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)
|
||||
};
|
||||
|
||||
@ -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<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => 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<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => 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<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||
@ -40,42 +41,43 @@ const api: ElectronApi = {
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
togglePackage: (packageId: string): Promise<void> => 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<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||
togglePackage: (packageId: string): Promise<void> => 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<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||
quit: (): Promise<void> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||
openItemLog: (itemId: string): Promise<void> => 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<void> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||
openItemLog: (itemId: string): Promise<void> => 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||
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),
|
||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import type {
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
@ -866,6 +866,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
megaDebridAccountDailyLimitBytes: {},
|
||||
megaDebridAccountDailyUsageBytes: {},
|
||||
megaDebridAccountTotalUsageBytes: {},
|
||||
debridAccountStatuses: {},
|
||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||
scheduledStartEpochMs: 0
|
||||
},
|
||||
@ -1121,6 +1122,37 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef
|
||||
return info.note || "Nicht verfügbar";
|
||||
}
|
||||
|
||||
function formatCheckedAgo(checkedAt: number): string {
|
||||
const deltaMs = Date.now() - checkedAt;
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return "gerade eben";
|
||||
const min = Math.floor(deltaMs / 60000);
|
||||
if (min < 1) return "gerade eben";
|
||||
if (min < 60) return `vor ${min} Min`;
|
||||
const hours = Math.floor(min / 60);
|
||||
if (hours < 24) return `vor ${hours} Std`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `vor ${days} Tag${days === 1 ? "" : "en"}`;
|
||||
}
|
||||
|
||||
function rotationEventText(ev: { event: string; cooldownSec?: number; next?: string }): string {
|
||||
switch (ev.event) {
|
||||
case "OK": return "erfolgreich";
|
||||
case "FAILED": {
|
||||
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
|
||||
const nx = ev.next && ev.next !== "ENDE" ? ` → ${ev.next}` : "";
|
||||
return `fehlgeschlagen${cd}${nx}`;
|
||||
}
|
||||
case "FATAL": return "abgebrochen (fataler Fehler)";
|
||||
case "SKIP_COOLDOWN": return "übersprungen (Cooldown aktiv)";
|
||||
case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
|
||||
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
|
||||
case "SKIP_HOST_COOLDOWN": return "übersprungen (Host-Cooldown)";
|
||||
case "PROVIDER_WIDE": return "Provider-weiter Fehler, restliche Keys übersprungen";
|
||||
case "TRANSPORT_CASCADE": return "Netzwerk-Kaskade, restliche Keys übersprungen";
|
||||
default: return ev.event;
|
||||
}
|
||||
}
|
||||
|
||||
function getDebridLinkKeyStatusDisplay(
|
||||
key: DebridLinkAccountKeyEntry,
|
||||
info: DebridLinkHostLimitInfo | null | undefined
|
||||
@ -1569,6 +1601,7 @@ export function App(): ReactElement {
|
||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||
const actionBusyRef = useRef(false);
|
||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
@ -1893,6 +1926,12 @@ export function App(): ReactElement {
|
||||
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
||||
// Merge delta payloads into the master snapshot. Full payloads replace
|
||||
// the master entirely (initial sync + periodic 30s resync).
|
||||
// NOTE: `settings` and `rotationEvents` are NOT delta-filtered — every emit
|
||||
// (full or delta) carries the complete `settings` object and recent
|
||||
// rotationEvents. The account-validity badges read
|
||||
// `snapshot.settings.debridAccountStatuses` and the rotation panel reads
|
||||
// `snapshot.rotationEvents`; if `settings` is ever delta-optimized, both
|
||||
// must keep flowing on every emit or those views go stale.
|
||||
let merged: UiSnapshot;
|
||||
const master = masterSnapshotRef.current;
|
||||
if (wireState.payloadKind === "delta" && master) {
|
||||
@ -2597,6 +2636,24 @@ export function App(): ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const checkAllAccounts = useCallback(async (): Promise<void> => {
|
||||
setAccountCheckBusy(true);
|
||||
try {
|
||||
const statuses = await window.rd.checkDebridAccounts();
|
||||
if (!statuses || statuses.length === 0) {
|
||||
showToast("Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", 3200);
|
||||
} else {
|
||||
const valid = statuses.filter((st) => st.valid).length;
|
||||
const premium = statuses.filter((st) => st.isPremium).length;
|
||||
showToast(`Account-Check: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
|
||||
} finally {
|
||||
setAccountCheckBusy(false);
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
const openCreateAccountDialog = (): void => {
|
||||
setAccountDialogSearch("");
|
||||
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
||||
@ -4868,9 +4925,14 @@ export function App(): ReactElement {
|
||||
<h3>Accounts</h3>
|
||||
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
|
||||
</div>
|
||||
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||
Account hinzufügen
|
||||
</button>
|
||||
<div className="account-board-header-actions">
|
||||
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
|
||||
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"}
|
||||
</button>
|
||||
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||
Account hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="account-board-summary">
|
||||
@ -5035,6 +5097,31 @@ export function App(): ReactElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-section card">
|
||||
<div className="account-board-header">
|
||||
<div>
|
||||
<h3>Rotations-Verlauf</h3>
|
||||
<div className="hint">Zeigt, welcher Account/Key zuletzt für die Link-Umwandlung versucht wurde und warum gewechselt wurde.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rotation-panel">
|
||||
{(!snapshot?.rotationEvents || snapshot.rotationEvents.length === 0) ? (
|
||||
<div className="rotation-empty">Noch keine Rotations-Ereignisse. Sobald ein Account/Key bei der Link-Umwandlung fehlschlägt oder gewechselt wird, erscheint es hier.</div>
|
||||
) : (
|
||||
snapshot.rotationEvents.map((ev) => (
|
||||
<div key={ev.id} className={`rotation-event ${ev.level}`}>
|
||||
<span className="rotation-time">{new Date(ev.at).toLocaleTimeString()}</span>
|
||||
<span className="rotation-body">
|
||||
<strong>{ev.provider} · {ev.accountLabel}</strong>{" "}
|
||||
{rotationEventText(ev)}
|
||||
{ev.reason ? <span className="rotation-reason"> ({ev.reason})</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section card">
|
||||
<h3>Hoster-Reihenfolge</h3>
|
||||
<div className="hint">
|
||||
@ -5600,6 +5687,15 @@ export function App(): ReactElement {
|
||||
<div className="account-dl-key-meta">
|
||||
<strong>Account {index + 1}</strong>
|
||||
<span>{maskMegaDebridLogin(account.login)}</span>
|
||||
{(() => {
|
||||
const st = snapshot?.settings?.debridAccountStatuses?.[getMegaDebridAccountId(account.login)];
|
||||
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
|
||||
const checkedAgo = formatCheckedAgo(st.checkedAt);
|
||||
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${checkedAgo}`;
|
||||
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Login ungültig</span>;
|
||||
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Login OK · kein Premium</span>;
|
||||
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
className="btn danger"
|
||||
@ -5672,6 +5768,14 @@ export function App(): ReactElement {
|
||||
<div className="account-dl-key-meta">
|
||||
<strong>{key.label}</strong>
|
||||
<span>{key.masked}</span>
|
||||
{(() => {
|
||||
const st = snapshot?.settings?.debridAccountStatuses?.[key.id];
|
||||
if (!st) return <span className="account-validity-badge unknown" title="Noch nicht geprüft – auf „Alle prüfen“ klicken">Noch nicht geprüft</span>;
|
||||
const tip = `${st.message}${st.email ? ` · ${st.email}` : ""} · geprüft ${formatCheckedAgo(st.checkedAt)}`;
|
||||
if (!st.valid) return <span className="account-validity-badge invalid" title={tip}>Key ungültig</span>;
|
||||
if (!st.isPremium) return <span className="account-validity-badge free" title={tip}>Key OK · kein Premium</span>;
|
||||
return <span className="account-validity-badge ok" title={tip}>{st.message}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
|
||||
@ -3138,3 +3138,43 @@ td {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── Account validity + premium badges (account check) ───────────────── */
|
||||
.account-board-header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.account-validity-badge {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.account-validity-badge.ok { color: #1c1206; background: linear-gradient(90deg, #7bd88f, #4fb96a); border-color: #4fb96a; }
|
||||
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
|
||||
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
|
||||
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
|
||||
|
||||
/* ── Live account-rotation panel ─────────────────────────────────────── */
|
||||
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
|
||||
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
|
||||
.rotation-event {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
border-left: 3px solid var(--line, #4a4032);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.rotation-event.WARN { border-left-color: #f2c14e; }
|
||||
.rotation-event.ERROR { border-left-color: #d9534f; }
|
||||
.rotation-event.INFO { border-left-color: #4fb96a; }
|
||||
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
||||
.rotation-event .rotation-body strong { font-weight: 600; }
|
||||
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
||||
|
||||
@ -4,11 +4,11 @@ export const IPC_CHANNELS = {
|
||||
CHECK_UPDATES: "app:check-updates",
|
||||
INSTALL_UPDATE: "app:install-update",
|
||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||
OPEN_EXTERNAL: "app:open-external",
|
||||
UPDATE_SETTINGS: "app:update-settings",
|
||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||
ADD_LINKS: "queue:add-links",
|
||||
OPEN_EXTERNAL: "app:open-external",
|
||||
UPDATE_SETTINGS: "app:update-settings",
|
||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||
ADD_LINKS: "queue:add-links",
|
||||
ADD_CONTAINERS: "queue:add-containers",
|
||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
@ -21,41 +21,42 @@ export const IPC_CHANNELS = {
|
||||
RENAME_PACKAGE: "queue:rename-package",
|
||||
REORDER_PACKAGES: "queue:reorder-packages",
|
||||
REMOVE_ITEM: "queue:remove-item",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||
EXPORT_QUEUE: "queue:export",
|
||||
IMPORT_QUEUE: "queue:import",
|
||||
PICK_FOLDER: "dialog:pick-folder",
|
||||
PICK_CONTAINERS: "dialog:pick-containers",
|
||||
STATE_UPDATE: "state:update",
|
||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats",
|
||||
RESET_SESSION_STATS: "stats:reset-session",
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||
GET_SESSION_STATS: "stats:get-session-stats",
|
||||
RESET_SESSION_STATS: "stats:reset-session",
|
||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||
RESTART: "app:restart",
|
||||
QUIT: "app:quit",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||
OPEN_SESSION_LOG: "app:open-session-log",
|
||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||
OPEN_ITEM_LOG: "app:open-item-log",
|
||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||
OPEN_SESSION_LOG: "app:open-session-log",
|
||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||
OPEN_ITEM_LOG: "app:open-item-log",
|
||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||
EXTRACT_NOW: "queue:extract-now",
|
||||
RESET_PACKAGE: "queue:reset-package",
|
||||
GET_HISTORY: "history:get",
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type {
|
||||
import type {
|
||||
AddLinksPayload,
|
||||
AllDebridHostInfo,
|
||||
AppSettings,
|
||||
DebridAccountStatus,
|
||||
DebugSetupCheckResult,
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
@ -15,30 +16,30 @@ import type {
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
UpdateInstallProgress,
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
|
||||
export interface ElectronApi {
|
||||
UpdateInstallResult
|
||||
} from "./types";
|
||||
|
||||
export interface ElectronApi {
|
||||
getSnapshot: () => Promise<UiSnapshot>;
|
||||
getVersion: () => Promise<string>;
|
||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||
getVersion: () => Promise<string>;
|
||||
checkUpdates: () => Promise<UpdateCheckResult>;
|
||||
installUpdate: () => Promise<UpdateInstallResult>;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
|
||||
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
|
||||
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
|
||||
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
|
||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||
getStartConflicts: () => Promise<StartConflictEntry[]>;
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
renamePackage: (packageId: string, newName: string) => Promise<void>;
|
||||
reorderPackages: (packageIds: string[]) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
togglePackage: (packageId: string) => Promise<void>;
|
||||
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
|
||||
@ -52,7 +53,7 @@ export interface ElectronApi {
|
||||
resetSessionStats: () => Promise<void>;
|
||||
resetDownloadStats: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
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<SupportTraceConfig>;
|
||||
rotateDebugToken: () => Promise<{ path: string }>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
|
||||
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
|
||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
}
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
|
||||
skipItems: (itemIds: string[]) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
startItems: (itemIds: string[]) => Promise<void>;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
}
|
||||
|
||||
@ -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<string, number>;
|
||||
megaDebridAccountDailyUsageBytes: 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;
|
||||
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 {
|
||||
|
||||
@ -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
|
||||
|
||||
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