Compare commits
No commits in common. "4b1625c5eedc8207ee4026f1e6aae8c44a48389a" and "748c07a53126fb74997953b7769ab577aa9bfefb" have entirely different histories.
4b1625c5ee
...
748c07a531
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.164",
|
"version": "1.7.163",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1,220 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
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
|
||||||
@ -10,70 +9,6 @@ import type { RotationEvent } from "../shared/types";
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@ -173,8 +108,6 @@ 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,7 +5,6 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
@ -24,7 +23,6 @@ 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";
|
||||||
@ -376,19 +374,6 @@ 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,7 +123,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,13 +19,12 @@ import {
|
|||||||
SessionState,
|
SessionState,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot, DebridAccountStatus } from "../shared/types";
|
UiSnapshot
|
||||||
|
} 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,
|
||||||
@ -56,7 +55,6 @@ 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";
|
||||||
@ -1810,25 +1808,7 @@ 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];
|
||||||
@ -2062,17 +2042,6 @@ 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);
|
||||||
@ -2355,7 +2324,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rotationEvents: getRecentRotationEvents(40),
|
|
||||||
settings: snapshotSettings,
|
settings: snapshotSettings,
|
||||||
session: snapshotSession,
|
session: snapshotSession,
|
||||||
summary: snapshotSummary,
|
summary: snapshotSummary,
|
||||||
@ -5675,7 +5643,6 @@ 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);
|
||||||
@ -7814,15 +7781,6 @@ 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 {
|
||||||
@ -7838,12 +7796,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") {
|
||||||
const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|
return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()
|
||||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
|
|| this.settings.megaDebridApiEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim());
|
||||||
}
|
}
|
||||||
if (effectiveProvider === "megadebrid-web") {
|
if (effectiveProvider === "megadebrid-web") {
|
||||||
const hasMegaCreds = Boolean(this.settings.megaCredentials.trim() || (this.settings.megaLogin.trim() && this.settings.megaPassword.trim()));
|
return Boolean(resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" && this.settings.megaLogin.trim() && this.settings.megaPassword.trim()
|
||||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled));
|
|| this.settings.megaDebridWebEnabled && this.settings.megaLogin.trim() && this.settings.megaPassword.trim());
|
||||||
}
|
}
|
||||||
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,10 +644,6 @@ 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, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
import { AppSettings, BandwidthScheduleEntry, 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,39 +230,6 @@ 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 [];
|
||||||
@ -485,7 +452,6 @@ 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,7 +3,6 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
@ -76,7 +75,6 @@ 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 { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||||
import type {
|
import type {
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@ -866,7 +866,6 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
},
|
},
|
||||||
@ -1122,37 +1121,6 @@ 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
|
||||||
@ -1601,7 +1569,6 @@ 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);
|
||||||
@ -1926,12 +1893,6 @@ 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) {
|
||||||
@ -2636,24 +2597,6 @@ 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));
|
||||||
@ -4925,15 +4868,10 @@ 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>
|
||||||
<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}>
|
<button className="btn accent" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
||||||
Account hinzufügen
|
Account hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="account-board-summary">
|
<div className="account-board-summary">
|
||||||
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span>
|
||||||
@ -5097,31 +5035,6 @@ 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">
|
||||||
@ -5687,15 +5600,6 @@ 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"
|
||||||
@ -5768,14 +5672,6 @@ 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,43 +3138,3 @@ 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,7 +55,6 @@ 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,7 +2,6 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebugSetupCheckResult,
|
DebugSetupCheckResult,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
@ -73,7 +72,6 @@ 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,28 +53,6 @@ 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;
|
||||||
@ -157,9 +135,6 @@ 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;
|
||||||
}
|
}
|
||||||
@ -242,23 +217,6 @@ 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;
|
||||||
@ -281,9 +239,6 @@ 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,25 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
@ -1,162 +0,0 @@
|
|||||||
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