fix: harden account rotation and active download recovery
Apply account enablement changes optimistically and persist rapid toggles sequentially without losing clicks. Refresh Mega-Debrid API and Web account pools during active downloads, isolate per-account cancellation budgets, rotate within the same conversion attempt, and keep pause, stop, resume, reset, and no-account gates consistent. Use the native Electron clipboard under Remote Desktop and make support bundle export bounded, redacted, responsive, guarded, visible, and atomic. Add end-to-end regressions for live account switching, persisted partial recovery, support export, native copy, availability preservation, and the 500 ms telemetry cadence.
This commit is contained in:
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.29] - 2026-08-13
|
||||||
|
|
||||||
|
### Account and rotation reliability
|
||||||
|
|
||||||
|
- Applied rapid account enable and disable changes immediately, persisted every click in order, and reconciled the final state without losing fast consecutive changes.
|
||||||
|
- Refreshed Mega-Debrid API and Web account pools during active downloads without requiring an application restart.
|
||||||
|
- Isolated account attempt cancellation and timeouts so a failed or stalled account can rotate to the next enabled account within the same link conversion.
|
||||||
|
- Cleared stale account cooldowns, provider failures, API token work, and Web sessions when relevant credentials or enabled accounts change.
|
||||||
|
- Added explicit TEST, OK, and FAILED account-rotation evidence with masked account identities to diagnostics and support bundles.
|
||||||
|
|
||||||
|
### Download lifecycle
|
||||||
|
|
||||||
|
- Kept Pause and Stop responsive while other interface actions are running and canceled active requests cleanly when pausing.
|
||||||
|
- Recovered persisted partial downloads by renewing expiring direct links and falling back to one clean full request after repeated range rejection.
|
||||||
|
- Preserved known online availability when resetting packages or selected files.
|
||||||
|
- Kept every start and resume path blocked when no usable download account is active.
|
||||||
|
- Reduced active session, speed, sparkline, and bandwidth updates to a stable 500 ms interface cadence.
|
||||||
|
|
||||||
|
### Diagnostics and desktop integration
|
||||||
|
|
||||||
|
- Routed copy actions through Electron's native clipboard for reliable operation in hardened and Remote Desktop sessions.
|
||||||
|
- Made support bundle creation visibly active from the save dialog through the final write, prevented duplicate exports, and saved completed bundles atomically.
|
||||||
|
- Bounded support bundle queue data and recent log collection, kept the interface responsive during creation, and strengthened redaction for credentials, URLs, local paths, and account identifiers.
|
||||||
|
|
||||||
## [2.0.28] - 2026-08-12
|
## [2.0.28] - 2026-08-12
|
||||||
|
|
||||||
### Startup reliability
|
### Startup reliability
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.28",
|
"version": "2.0.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.28",
|
"version": "2.0.29",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.28",
|
"version": "2.0.29",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -860,17 +860,36 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||||
this.audit("INFO", "Support-Bundle exportiert");
|
const buffer = await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
hostDiagnosticsMode: "cached",
|
||||||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
debugSetupMode: "deferred"
|
||||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
buffer,
|
||||||
defaultFileName: getSupportBundleDefaultFileName()
|
defaultFileName: getSupportBundleDefaultFileName()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public recordSupportBundleExported(filePath: string, bytes: number): void {
|
||||||
|
const snapshot = this.manager.getSnapshot();
|
||||||
|
const fields = {
|
||||||
|
fileName: path.basename(filePath),
|
||||||
|
bytes,
|
||||||
|
packageCount: Object.keys(snapshot.session.packages).length,
|
||||||
|
itemCount: Object.keys(snapshot.session.items).length
|
||||||
|
};
|
||||||
|
this.audit("INFO", "Support-Bundle exportiert", fields);
|
||||||
|
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
public recordSupportBundleExportFailed(error: unknown): void {
|
||||||
|
const fields = {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
};
|
||||||
|
this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||||
|
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||||
|
}
|
||||||
|
|
||||||
public getSupportBundleDefaultFileName(): string {
|
public getSupportBundleDefaultFileName(): string {
|
||||||
return getSupportBundleDefaultFileName();
|
return getSupportBundleDefaultFileName();
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-28
@@ -291,6 +291,7 @@ const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
|||||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
||||||
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
|
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
|
||||||
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
|
const MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT = 120_000;
|
||||||
|
|
||||||
// A Mega-Web account abort (the shared unrestrict timeout firing while this
|
// A Mega-Web account abort (the shared unrestrict timeout firing while this
|
||||||
// account ran) only cools the account down — so the next attempt rotates on —
|
// account ran) only cools the account down — so the next attempt rotates on —
|
||||||
@@ -332,6 +333,20 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
|||||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMegaDebridAccountAttemptTimeoutMs(): number {
|
||||||
|
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
|
||||||
|
return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000
|
||||||
|
? Math.floor(fromEnv)
|
||||||
|
: MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable<string>): void {
|
||||||
|
for (const accountKey of accountKeys) {
|
||||||
|
megaDebridAccountCooldowns.delete(accountKey);
|
||||||
|
megaDebridEmptyResponseStreaks.delete(accountKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resetMegaDebridRuntimeStateForTests(): void {
|
export function resetMegaDebridRuntimeStateForTests(): void {
|
||||||
megaDebridAccountCooldowns.clear();
|
megaDebridAccountCooldowns.clear();
|
||||||
megaDebridEmptyResponseStreaks.clear();
|
megaDebridEmptyResponseStreaks.clear();
|
||||||
@@ -748,6 +763,32 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
|
if (!signal) {
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return Promise.reject(new Error("aborted:debrid"));
|
||||||
|
}
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const onAbort = (): void => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(new Error("aborted:debrid"));
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
void promise.then(
|
||||||
|
(value) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1808,31 +1849,47 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
private allowApiFallback: boolean;
|
private allowApiFallback: boolean;
|
||||||
|
|
||||||
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
|
private static cachedApiTokens = new Map<string, { token: string; at: number; generation: number }>();
|
||||||
|
|
||||||
private static pendingConnects = new Map<string, Promise<string | null>>();
|
private static pendingConnects = new Map<string, { generation: number; promise: Promise<string | null> }>();
|
||||||
|
|
||||||
|
private static credentialGenerations = new Map<string, number>();
|
||||||
|
|
||||||
|
private static getCredentialGeneration(key: string): number {
|
||||||
|
return MegaDebridClient.credentialGenerations.get(key) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static invalidateCredential(key: string): void {
|
||||||
|
MegaDebridClient.credentialGenerations.set(key, MegaDebridClient.getCredentialGeneration(key) + 1);
|
||||||
|
MegaDebridClient.cachedApiTokens.delete(key);
|
||||||
|
MegaDebridClient.pendingConnects.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static invalidateCredentialIfCurrent(key: string, generation: number): void {
|
||||||
|
if (MegaDebridClient.getCredentialGeneration(key) === generation) {
|
||||||
|
MegaDebridClient.invalidateCredential(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
|
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
|
||||||
const keep = new Set<string>();
|
const keep = new Set<string>();
|
||||||
for (const login of activeLogins) {
|
for (const login of activeLogins) {
|
||||||
keep.add(String(login || "").toLowerCase());
|
keep.add(String(login || "").toLowerCase());
|
||||||
}
|
}
|
||||||
for (const login of MegaDebridClient.cachedApiTokens.keys()) {
|
const knownLogins = new Set<string>([
|
||||||
|
...MegaDebridClient.cachedApiTokens.keys(),
|
||||||
|
...MegaDebridClient.pendingConnects.keys()
|
||||||
|
]);
|
||||||
|
for (const login of knownLogins) {
|
||||||
if (!keep.has(login)) {
|
if (!keep.has(login)) {
|
||||||
MegaDebridClient.cachedApiTokens.delete(login);
|
MegaDebridClient.invalidateCredential(login);
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const login of MegaDebridClient.pendingConnects.keys()) {
|
|
||||||
if (!keep.has(login)) {
|
|
||||||
MegaDebridClient.pendingConnects.delete(login);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static clearCachedApiToken(login: string): void {
|
public static clearCachedApiToken(login: string): void {
|
||||||
const key = String(login || "").toLowerCase();
|
const key = String(login || "").toLowerCase();
|
||||||
MegaDebridClient.cachedApiTokens.delete(key);
|
MegaDebridClient.invalidateCredential(key);
|
||||||
MegaDebridClient.pendingConnects.delete(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
|
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
|
||||||
@@ -1849,40 +1906,46 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
private async connectApi(signal?: AbortSignal): Promise<string | null> {
|
private async connectApi(signal?: AbortSignal): Promise<string | null> {
|
||||||
const key = this.cacheKey;
|
const key = this.cacheKey;
|
||||||
|
const generation = MegaDebridClient.getCredentialGeneration(key);
|
||||||
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
||||||
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
if (cached && cached.generation === generation && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
|
||||||
return cached.token;
|
return waitForPromiseWithSignal(Promise.resolve(cached.token), signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = MegaDebridClient.pendingConnects.get(key);
|
const pending = MegaDebridClient.pendingConnects.get(key);
|
||||||
if (pending) {
|
if (pending && pending.generation === generation) {
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
|
||||||
return pending;
|
return waitForPromiseWithSignal(pending.promise, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = this.doConnectApi(signal).finally(() => {
|
const promise = this.doConnectApi(key, generation);
|
||||||
MegaDebridClient.pendingConnects.delete(key);
|
const entry = { generation, promise };
|
||||||
});
|
MegaDebridClient.pendingConnects.set(key, entry);
|
||||||
MegaDebridClient.pendingConnects.set(key, promise);
|
const clearPending = (): void => {
|
||||||
return promise;
|
if (MegaDebridClient.pendingConnects.get(key) === entry) {
|
||||||
|
MegaDebridClient.pendingConnects.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void promise.then(clearPending, clearPending);
|
||||||
|
return waitForPromiseWithSignal(promise, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearTokenCache(): void {
|
private clearTokenCache(): void {
|
||||||
MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
|
MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
|
private async doConnectApi(cacheKey: string, generation: number): Promise<string | null> {
|
||||||
const connectStartedAt = Date.now();
|
const connectStartedAt = Date.now();
|
||||||
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
|
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: { "User-Agent": DEBRID_USER_AGENT },
|
headers: { "User-Agent": DEBRID_USER_AGENT },
|
||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.clearTokenCache();
|
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
|
||||||
return null;
|
return null;
|
||||||
@@ -1890,7 +1953,7 @@ class MegaDebridClient {
|
|||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!payload || payload.response_code !== "ok") {
|
if (!payload || payload.response_code !== "ok") {
|
||||||
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
||||||
this.clearTokenCache();
|
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
|
||||||
}
|
}
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
|
||||||
return null;
|
return null;
|
||||||
@@ -1900,7 +1963,10 @@ class MegaDebridClient {
|
|||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
|
if (MegaDebridClient.getCredentialGeneration(cacheKey) !== generation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MegaDebridClient.cachedApiTokens.set(cacheKey, { token, at: Date.now(), generation });
|
||||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
|
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -2123,9 +2189,13 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
usableAccountSeen = true;
|
usableAccountSeen = true;
|
||||||
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
|
||||||
|
const accountAttemptTimeoutSignal = AbortSignal.timeout(getMegaDebridAccountAttemptTimeoutMs());
|
||||||
|
const accountAttemptSignal = signal
|
||||||
|
? AbortSignal.any([signal, accountAttemptTimeoutSignal])
|
||||||
|
: accountAttemptTimeoutSignal;
|
||||||
try {
|
try {
|
||||||
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
||||||
const result = await client.unrestrictLink(link, signal);
|
const result = await client.unrestrictLink(link, accountAttemptSignal);
|
||||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
@@ -2152,6 +2222,9 @@ class MegaDebridClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||||
// account — so it is set only when another usable account actually exists.
|
// account — so it is set only when another usable account actually exists.
|
||||||
@@ -2161,7 +2234,8 @@ class MegaDebridClient {
|
|||||||
// we park just this link (mega_debrid_slow_link) and leave the account free
|
// we park just this link (mega_debrid_slow_link) and leave the account free
|
||||||
// for other items. A quick user-cancel (below the min run) parks nothing.
|
// for other items. A quick user-cancel (below the min run) parks nothing.
|
||||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
const accountAttemptTimedOut = accountAttemptTimeoutSignal.aborted;
|
||||||
|
const ranLongEnough = accountAttemptTimedOut || elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||||
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
|
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
|
||||||
if (candidate.account.id === account.id) {
|
if (candidate.account.id === account.id) {
|
||||||
return count;
|
return count;
|
||||||
@@ -2194,8 +2268,11 @@ class MegaDebridClient {
|
|||||||
elapsedMs,
|
elapsedMs,
|
||||||
reason: abortText,
|
reason: abortText,
|
||||||
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
|
||||||
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
|
next: rotateToAnotherAccount ? "naechster Account im selben Versuch" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
|
||||||
});
|
});
|
||||||
|
if (rotateToAnotherAccount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (ranLongEnough && !rotateToAnotherAccount) {
|
if (ranLongEnough && !rotateToAnotherAccount) {
|
||||||
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
|
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-22
@@ -54,7 +54,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
|
||||||
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 { classifyDiskError } from "./fs-error";
|
import { classifyDiskError } from "./fs-error";
|
||||||
@@ -78,7 +78,7 @@ type ActiveTask = {
|
|||||||
itemId: string;
|
itemId: string;
|
||||||
packageId: string;
|
packageId: string;
|
||||||
abortController: AbortController;
|
abortController: AbortController;
|
||||||
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none";
|
abortReason: "stop" | "pause" | "cancel" | "reconnect" | "package_toggle" | "settings_refresh" | "stall" | "shutdown" | "reset" | "none";
|
||||||
resumable: boolean;
|
resumable: boolean;
|
||||||
nonResumableCounted: boolean;
|
nonResumableCounted: boolean;
|
||||||
freshRetryUsed?: boolean;
|
freshRetryUsed?: boolean;
|
||||||
@@ -124,6 +124,10 @@ const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
|
|||||||
|
|
||||||
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
|
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
const MAX_UNRESTRICT_TIMEOUT_MS = 2_147_483_647;
|
||||||
|
|
||||||
|
const UNRESTRICT_TIMEOUT_OVERHEAD_MS = 15_000;
|
||||||
|
|
||||||
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260;
|
const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260;
|
||||||
@@ -477,6 +481,31 @@ function extractHosterKey(link: string): string {
|
|||||||
return extractHosterFromUrl(link);
|
return extractHosterFromUrl(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUnrestrictTimeoutMsForProviderPlan(settings: AppSettings, providerPlan: readonly DebridProvider[]): number {
|
||||||
|
const baseTimeoutMs = getUnrestrictTimeoutMs();
|
||||||
|
const megaModes = new Set<"api" | "web">();
|
||||||
|
for (const provider of providerPlan) {
|
||||||
|
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
|
||||||
|
if (effectiveProvider === "megadebrid-api") {
|
||||||
|
megaModes.add("api");
|
||||||
|
} else if (effectiveProvider === "megadebrid-web") {
|
||||||
|
megaModes.add("web");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (megaModes.size === 0) {
|
||||||
|
return baseTimeoutMs;
|
||||||
|
}
|
||||||
|
let accountCount = 0;
|
||||||
|
for (const mode of megaModes) {
|
||||||
|
accountCount += getAvailableMegaDebridAccounts(settings, mode).length;
|
||||||
|
}
|
||||||
|
if (accountCount === 0) {
|
||||||
|
return baseTimeoutMs;
|
||||||
|
}
|
||||||
|
const calculatedTimeoutMs = baseTimeoutMs + UNRESTRICT_TIMEOUT_OVERHEAD_MS + accountCount * getMegaDebridAccountAttemptTimeoutMs();
|
||||||
|
return Math.min(MAX_UNRESTRICT_TIMEOUT_MS, Math.max(baseTimeoutMs, calculatedTimeoutMs));
|
||||||
|
}
|
||||||
|
|
||||||
function isLargeBinaryLikePath(filePath: string): boolean {
|
function isLargeBinaryLikePath(filePath: string): boolean {
|
||||||
const lower = path.basename(String(filePath || "")).toLowerCase();
|
const lower = path.basename(String(filePath || "")).toLowerCase();
|
||||||
return isArchiveLikePath(lower) || LARGE_BINARY_FILE_RE.test(lower);
|
return isArchiveLikePath(lower) || LARGE_BINARY_FILE_RE.test(lower);
|
||||||
@@ -552,9 +581,14 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean {
|
|||||||
|| text.includes("server ignorierte range");
|
|| text.includes("server ignorierte range");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isResumeHardResetReason(errorText: string): boolean {
|
export function isResumeHardResetReason(errorText: string, renewedLinkFailures = 0): boolean {
|
||||||
const text = String(errorText || "");
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.startsWith("resume_download_underflow:");
|
return text.startsWith("resume_download_underflow:")
|
||||||
|
|| (renewedLinkFailures > 0 && (
|
||||||
|
text.startsWith("range_ignored_on_resume:")
|
||||||
|
|| text.startsWith("range_mismatch_on_resume:")
|
||||||
|
|| text.includes("server ignorierte range")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRealDebridProvider(provider: string | null | undefined): boolean {
|
function isRealDebridProvider(provider: string | null | undefined): boolean {
|
||||||
@@ -1701,6 +1735,15 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
|||||||
return Math.floor(jitter);
|
return Math.floor(jitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMegaDebridProviderKey(value: string): boolean {
|
||||||
|
return value === "megadebrid"
|
||||||
|
|| value === "megadebrid-api"
|
||||||
|
|| value === "megadebrid-web"
|
||||||
|
|| value.startsWith("megadebrid:")
|
||||||
|
|| value.startsWith("megadebrid-api:")
|
||||||
|
|| value.startsWith("megadebrid-web:");
|
||||||
|
}
|
||||||
|
|
||||||
export function getDiskWriteWaitReason(error: unknown): string | null {
|
export function getDiskWriteWaitReason(error: unknown): string | null {
|
||||||
const text = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const text = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
const marked = text.match(/^disk_write_wait:(.+)$/i);
|
const marked = text.match(/^disk_write_wait:(.+)$/i);
|
||||||
@@ -2194,25 +2237,31 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||||
const previous = this.settings;
|
const previous = this.settings;
|
||||||
const previousMegaPool = (["api", "web"] as const)
|
const previousMegaAccounts = (["api", "web"] as const)
|
||||||
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`))
|
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
|
||||||
|
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
|
||||||
|
const previousMegaPool = [...previousMegaPoolEntries]
|
||||||
|
.map(([key, password]) => `${key}:${password}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const nextMegaAccounts = (["api", "web"] as const)
|
const nextMegaAccounts = (["api", "web"] as const)
|
||||||
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
|
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
|
||||||
const nextMegaPool = (["api", "web"] as const)
|
const nextMegaPoolEntries = new Map<string, string>((["api", "web"] as const)
|
||||||
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`))
|
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
|
||||||
|
const nextMegaPool = [...nextMegaPoolEntries]
|
||||||
|
.map(([key, password]) => `${key}:${password}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web")
|
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
|
||||||
.map((account) => `${account.id}:${account.password}`)
|
.map((account) => `${account.id}:${account.password}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web")
|
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
|
||||||
.map((account) => `${account.id}:${account.password}`)
|
.map((account) => `${account.id}:${account.password}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0;
|
const megaPoolChanged = previousMegaPool !== nextMegaPool;
|
||||||
|
const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool;
|
||||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
@@ -2222,7 +2271,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
|
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
|
||||||
this.runtimePersistedAt = now;
|
this.runtimePersistedAt = now;
|
||||||
this.ensureProviderDailyUsageFresh(nowMs());
|
this.ensureProviderDailyUsageFresh(nowMs());
|
||||||
if (previousMegaWebCredentials !== nextMegaWebCredentials) {
|
if (megaWebPoolChanged) {
|
||||||
this.invalidateMegaSessionFn?.();
|
this.invalidateMegaSessionFn?.();
|
||||||
}
|
}
|
||||||
this.debridService.setSettings(next);
|
this.debridService.setSettings(next);
|
||||||
@@ -2280,7 +2329,34 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opts?.settingsOnlyImport && megaPoolChanged) {
|
if (megaPoolChanged) {
|
||||||
|
const changedAccountKeys = new Set<string>();
|
||||||
|
for (const key of new Set<string>([...previousMegaPoolEntries.keys(), ...nextMegaPoolEntries.keys()])) {
|
||||||
|
if (previousMegaPoolEntries.get(key) !== nextMegaPoolEntries.get(key)) {
|
||||||
|
changedAccountKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearMegaDebridAccountRuntimeStates(changedAccountKeys);
|
||||||
|
for (const key of [...this.providerFailures.keys()]) {
|
||||||
|
if (isMegaDebridProviderKey(key)) {
|
||||||
|
this.providerFailures.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
const item = this.session.items[active.itemId];
|
||||||
|
if (!item || item.status !== "validating") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const provider = String(item.provider || this.getExpectedProviderForItem(item) || "");
|
||||||
|
if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
active.abortReason = "settings_refresh";
|
||||||
|
active.abortController.abort("settings_refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts?.settingsOnlyImport && megaPoolChanged && nextMegaAccounts.length > 0) {
|
||||||
this.releaseMegaDebridResetParks();
|
this.releaseMegaDebridResetParks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5372,7 +5448,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.targetPath = "";
|
item.targetPath = "";
|
||||||
item.provider = null;
|
item.provider = null;
|
||||||
item.fullStatus = "Wartet";
|
item.fullStatus = "Wartet";
|
||||||
item.onlineStatus = undefined;
|
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5456,7 +5531,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.targetPath = "";
|
item.targetPath = "";
|
||||||
item.provider = null;
|
item.provider = null;
|
||||||
item.fullStatus = "Wartet";
|
item.fullStatus = "Wartet";
|
||||||
item.onlineStatus = undefined;
|
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
|
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
@@ -5937,6 +6011,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.providerStartReservations.clear();
|
this.providerStartReservations.clear();
|
||||||
this.pacedStartReservationByItem.clear();
|
this.pacedStartReservationByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
|
this.invalidateMegaSessionFn?.();
|
||||||
|
for (const key of [...this.providerFailures.keys()]) {
|
||||||
|
if (isMegaDebridProviderKey(key)) {
|
||||||
|
this.providerFailures.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||||
this.lastGlobalProgressAt = nowMs();
|
this.lastGlobalProgressAt = nowMs();
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
@@ -6091,6 +6171,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.speedBytesPerPackage.clear();
|
this.speedBytesPerPackage.clear();
|
||||||
this.speedEventsHead = 0;
|
this.speedEventsHead = 0;
|
||||||
|
this.invalidateMegaSessionFn?.();
|
||||||
|
for (const active of this.activeTasks.values()) {
|
||||||
|
active.abortReason = "pause";
|
||||||
|
active.abortController.abort("pause");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasPaused && !this.session.paused) {
|
if (wasPaused && !this.session.paused) {
|
||||||
@@ -8188,6 +8273,34 @@ export class DownloadManager extends EventEmitter {
|
|||||||
].filter(Boolean) as DebridProvider[];
|
].filter(Boolean) as DebridProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getReachableUnrestrictProviderPlan(item: DownloadItem, preferredLeadProvider: DebridProvider | null): DebridProvider[] {
|
||||||
|
const baseOrder = this.getProviderOrder();
|
||||||
|
const order = preferredLeadProvider && baseOrder.includes(preferredLeadProvider)
|
||||||
|
? [preferredLeadProvider, ...baseOrder.filter((provider) => provider !== preferredLeadProvider)]
|
||||||
|
: baseOrder;
|
||||||
|
const candidates: DebridProvider[] = [];
|
||||||
|
const hosterKey = extractHosterKey(item.url);
|
||||||
|
const routedProvider = hosterKey ? (this.settings.hosterRouting || {})[hosterKey] : undefined;
|
||||||
|
if (routedProvider && this.isProviderConfigured(routedProvider)) {
|
||||||
|
candidates.push(routedProvider);
|
||||||
|
if (!this.settings.autoProviderFallback) {
|
||||||
|
return [resolveMegaDebridProvider(this.settings, routedProvider) || routedProvider];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const configuredOrder = order.filter((provider) => this.isProviderConfigured(provider));
|
||||||
|
candidates.push(...(this.settings.autoProviderFallback ? configuredOrder : configuredOrder.slice(0, 1)));
|
||||||
|
const seen = new Set<DebridProvider>();
|
||||||
|
const providerPlan: DebridProvider[] = [];
|
||||||
|
for (const provider of candidates) {
|
||||||
|
const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider;
|
||||||
|
if (!seen.has(effectiveProvider)) {
|
||||||
|
seen.add(effectiveProvider);
|
||||||
|
providerPlan.push(effectiveProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providerPlan;
|
||||||
|
}
|
||||||
|
|
||||||
private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null {
|
private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null {
|
||||||
const hosterKey = extractHosterKey(item.url);
|
const hosterKey = extractHosterKey(item.url);
|
||||||
for (const provider of this.getProviderOrder()) {
|
for (const provider of this.getProviderOrder()) {
|
||||||
@@ -8677,7 +8790,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALIDATING_STUCK_MS = getUnrestrictTimeoutMs() + 15000;
|
|
||||||
for (const active of this.activeTasks.values()) {
|
for (const active of this.activeTasks.values()) {
|
||||||
if (active.abortController.signal.aborted) {
|
if (active.abortController.signal.aborted) {
|
||||||
continue;
|
continue;
|
||||||
@@ -8686,8 +8798,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!item || item.status !== "validating") {
|
if (!item || item.status !== "validating") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const validatingStuckMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, this.getReachableUnrestrictProviderPlan(item, null)) + 15_000;
|
||||||
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
|
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
|
||||||
if (ageMs > VALIDATING_STUCK_MS) {
|
if (ageMs > validatingStuckMs) {
|
||||||
logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`);
|
logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`);
|
||||||
active.abortReason = "stall";
|
active.abortReason = "stall";
|
||||||
active.abortController.abort("stall");
|
active.abortController.abort("stall");
|
||||||
@@ -9172,7 +9285,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs());
|
const unrestrictProviderPlan = this.getReachableUnrestrictProviderPlan(item, preferredLeadProvider);
|
||||||
|
const unrestrictTimeoutMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, unrestrictProviderPlan);
|
||||||
|
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
|
||||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||||
let unrestricted;
|
let unrestricted;
|
||||||
try {
|
try {
|
||||||
@@ -9193,7 +9308,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
traceConversionPhase({
|
traceConversionPhase({
|
||||||
phase: "caller-timeout",
|
phase: "caller-timeout",
|
||||||
outcome: "timeout",
|
outcome: "timeout",
|
||||||
detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw innerError;
|
throw innerError;
|
||||||
@@ -9203,7 +9318,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
this.recordProviderFailure(cooldownProvider);
|
this.recordProviderFailure(cooldownProvider);
|
||||||
throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`);
|
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
|
||||||
}
|
}
|
||||||
const errText = compactErrorText(unrestrictError);
|
const errText = compactErrorText(unrestrictError);
|
||||||
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
|
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
|
||||||
@@ -9484,6 +9599,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.dropItemContribution(item.id);
|
this.dropItemContribution(item.id);
|
||||||
}
|
}
|
||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
|
} else if (reason === "pause") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.fullStatus = "Pausiert";
|
||||||
|
this.retryAfterByItem.delete(item.id);
|
||||||
|
this.retryStateByItem.set(item.id, {
|
||||||
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||||
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
|
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
||||||
|
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||||
|
});
|
||||||
} else if (reason === "shutdown") {
|
} else if (reason === "shutdown") {
|
||||||
this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", {
|
this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", {
|
||||||
reason
|
reason
|
||||||
@@ -9509,6 +9636,17 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
} else if (reason === "reset") {
|
} else if (reason === "reset") {
|
||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
|
} else if (reason === "settings_refresh") {
|
||||||
|
item.status = "queued";
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.fullStatus = "Wartet";
|
||||||
|
item.lastError = "";
|
||||||
|
item.provider = null;
|
||||||
|
item.providerLabel = undefined;
|
||||||
|
item.providerAccountId = undefined;
|
||||||
|
item.providerAccountLabel = undefined;
|
||||||
|
this.retryAfterByItem.delete(item.id);
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
} else if (reason === "package_toggle") {
|
} else if (reason === "package_toggle") {
|
||||||
this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
|
this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
|
||||||
reason
|
reason
|
||||||
@@ -9640,7 +9778,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
||||||
active.resumeHardResetUsed = true;
|
active.resumeHardResetUsed = true;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
||||||
|
|||||||
+23
-12
@@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state";
|
|||||||
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||||
|
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
|
||||||
|
|
||||||
function validateString(value: unknown, name: string): string {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -611,6 +612,13 @@ function registerIpcHandlers(): void {
|
|||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => {
|
||||||
|
if (typeof text !== "string" || text.length > 16 * 1024 * 1024) {
|
||||||
|
throw new Error("Ungültiger Zwischenablageinhalt");
|
||||||
|
}
|
||||||
|
clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||||
@@ -667,20 +675,23 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.importOnlineBackup(key);
|
return controller.importOnlineBackup(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
const runSupportBundleExport = createSupportBundleExportRunner({
|
||||||
const options = {
|
chooseFile: async () => {
|
||||||
defaultPath: controller.getSupportBundleDefaultFileName(),
|
const options = {
|
||||||
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
defaultPath: controller.getSupportBundleDefaultFileName(),
|
||||||
};
|
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
};
|
||||||
if (result.canceled || !result.filePath) {
|
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||||
return { saved: false };
|
return result.canceled || !result.filePath ? null : result.filePath;
|
||||||
}
|
},
|
||||||
const exported = await controller.exportSupportBundle();
|
build: async () => (await controller.exportSupportBundle()).buffer,
|
||||||
await fs.promises.writeFile(result.filePath, exported.buffer);
|
write: writeSupportBundleAtomically,
|
||||||
return { saved: true, filePath: result.filePath };
|
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
|
||||||
|
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => {
|
||||||
const logPath = getLogFilePath();
|
const logPath = getLogFilePath();
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ export class MegaWebFallback {
|
|||||||
|
|
||||||
private sessionGeneration = 0;
|
private sessionGeneration = 0;
|
||||||
|
|
||||||
|
private invalidationController = new AbortController();
|
||||||
|
|
||||||
public constructor(getCredentials: () => MegaCredentials) {
|
public constructor(getCredentials: () => MegaCredentials) {
|
||||||
this.getCredentials = getCredentials;
|
this.getCredentials = getCredentials;
|
||||||
}
|
}
|
||||||
@@ -239,7 +241,11 @@ export class MegaWebFallback {
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
account?: { login: string; password: string }
|
account?: { login: string; password: string }
|
||||||
): Promise<UnrestrictedLink | null> {
|
): Promise<UnrestrictedLink | null> {
|
||||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
const invalidationSignal = this.invalidationController.signal;
|
||||||
|
const requestSignal = signal
|
||||||
|
? AbortSignal.any([signal, invalidationSignal])
|
||||||
|
: invalidationSignal;
|
||||||
|
const overallSignal = withTimeoutSignal(requestSignal, 180000);
|
||||||
const creds = (account && account.login.trim() && account.password.trim())
|
const creds = (account && account.login.trim() && account.password.trim())
|
||||||
? account
|
? account
|
||||||
: this.getCredentials();
|
: this.getCredentials();
|
||||||
@@ -251,12 +257,18 @@ export class MegaWebFallback {
|
|||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
|
|
||||||
let generated = await this.generate(link, cookie, overallSignal);
|
let generated = await this.generate(link, cookie, overallSignal);
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
this.sessions.delete(key);
|
if (sessionGeneration === this.sessionGeneration) {
|
||||||
|
this.sessions.delete(key);
|
||||||
|
}
|
||||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
generated = await this.generate(link, cookie, overallSignal);
|
generated = await this.generate(link, cookie, overallSignal);
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -290,7 +302,10 @@ export class MegaWebFallback {
|
|||||||
|
|
||||||
public invalidateSession(): void {
|
public invalidateSession(): void {
|
||||||
this.sessionGeneration += 1;
|
this.sessionGeneration += 1;
|
||||||
|
this.invalidationController.abort("session_invalidated");
|
||||||
|
this.invalidationController = new AbortController();
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
|
this.queues.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
|
||||||
@@ -317,7 +332,13 @@ export class MegaWebFallback {
|
|||||||
};
|
};
|
||||||
const prev = this.queues.get(key) ?? Promise.resolve();
|
const prev = this.queues.get(key) ?? Promise.resolve();
|
||||||
const run = prev.then(guardedJob, guardedJob);
|
const run = prev.then(guardedJob, guardedJob);
|
||||||
this.queues.set(key, run.then(() => undefined, () => undefined));
|
const tail = run.then(() => undefined, () => undefined);
|
||||||
|
this.queues.set(key, tail);
|
||||||
|
void tail.finally(() => {
|
||||||
|
if (this.queues.get(key) === tail) {
|
||||||
|
this.queues.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
return raceWithAbort(run, signal, () =>
|
return raceWithAbort(run, signal, () =>
|
||||||
workStarted
|
workStarted
|
||||||
? abortError()
|
? abortError()
|
||||||
@@ -461,7 +482,9 @@ export class MegaWebFallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
this.invalidationController.abort("dispose");
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
|
this.queues.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+525
-98
@@ -1,4 +1,5 @@
|
|||||||
import { promises as fsp } from "node:fs";
|
import { promises as fsp } from "node:fs";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
@@ -8,81 +9,382 @@ import { getAuditLogPath } from "./audit-log";
|
|||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { getLogFilePath } from "./logger";
|
||||||
import { getRecentErrors } from "./error-ring";
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getPackageLogPath } from "./package-log";
|
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
import { createStoragePaths, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||||
import type { DownloadManager } from "./download-manager";
|
import type { DownloadManager } from "./download-manager";
|
||||||
|
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
||||||
|
|
||||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||||
|
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||||
|
const MAX_TEXT_FILE_BYTES = 128 * 1024;
|
||||||
|
const MAX_RUNTIME_FILE_BYTES = 64 * 1024;
|
||||||
|
const MAX_TOTAL_TEXT_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MAX_DIRECTORY_SCAN_FILES = 2_048;
|
||||||
|
const MAX_SESSION_LOG_FILES = 4;
|
||||||
|
const MAX_PACKAGE_LOG_FILES = 8;
|
||||||
|
const MAX_ITEM_LOG_FILES = 16;
|
||||||
|
const MAX_PACKAGE_DTOS = 200;
|
||||||
|
const MAX_ITEM_DTOS = 500;
|
||||||
|
const MAX_HISTORY_FILE_BYTES = 1024 * 1024;
|
||||||
|
const MAX_HISTORY_ENTRIES = 100;
|
||||||
|
|
||||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
interface TextBudget {
|
||||||
|
remainingBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSensitiveValues(value: unknown, key = "", output = new Set<string>()): Set<string> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) {
|
||||||
|
for (const candidate of [value, ...value.split(/\r?\n/)]) {
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (trimmed.length >= 4) {
|
||||||
|
output.add(trimmed);
|
||||||
|
}
|
||||||
|
if (/credential/i.test(key)) {
|
||||||
|
const separator = trimmed.indexOf(":");
|
||||||
|
if (separator > 0) {
|
||||||
|
const login = trimmed.slice(0, separator).trim();
|
||||||
|
const password = trimmed.slice(separator + 1).trim();
|
||||||
|
if (login.length >= 4) {
|
||||||
|
output.add(login);
|
||||||
|
}
|
||||||
|
if (password.length >= 4) {
|
||||||
|
output.add(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
collectSensitiveValues(entry, key, output);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
for (const [entryKey, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
collectSensitiveValues(entryValue, entryKey, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactUrl(value: string): string {
|
||||||
try {
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}/<redacted>`;
|
||||||
|
} catch {
|
||||||
|
return "<redacted-url>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): string {
|
||||||
|
let output = String(value || "").replace(/\0/g, "");
|
||||||
|
const secrets = [...sensitiveValues].sort((a, b) => b.length - a.length);
|
||||||
|
for (const secret of secrets) {
|
||||||
|
output = output.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
|
||||||
|
}
|
||||||
|
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url));
|
||||||
|
output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*')/gi, "$1\"<redacted>\"");
|
||||||
|
output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
|
||||||
|
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
|
||||||
|
output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;|]+)/gi, "$1$2<redacted>");
|
||||||
|
output = output.replace(/\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g, "<redacted>");
|
||||||
|
output = output.replace(/\b(?=[A-Za-z0-9+/_=-]{24,}\b)(?=[A-Za-z0-9+/_=-]*[A-Za-z])(?=[A-Za-z0-9+/_=-]*\d)[A-Za-z0-9+/_=-]+\b/g, "<redacted>");
|
||||||
|
output = output.replace(/\b[A-Z]:\\[^\r\n|"<>]+/gi, "<local-path>");
|
||||||
|
output = output.replace(/\\\\[^\r\n|"<>]+/g, "<local-path>");
|
||||||
|
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, "<local-path>");
|
||||||
|
output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "<redacted-email>");
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>, key = ""): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key) && value.trim()) {
|
||||||
|
return "<redacted>";
|
||||||
|
}
|
||||||
|
return redactSupportText(value, sensitiveValues);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => redactSupportValue(entry, sensitiveValues, key));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([entryKey, entryValue]) => [entryKey, redactSupportValue(entryValue, sensitiveValues, entryKey)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string {
|
||||||
|
return redactSupportText(zipPath, sensitiveValues)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry")
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function yieldToEventLoop(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addJson(zip: AdmZip, zipPath: string, value: unknown, sensitiveValues: ReadonlySet<string>): Promise<void> {
|
||||||
|
const redacted = redactSupportValue(value, sensitiveValues);
|
||||||
|
const buffer = Buffer.from(`${JSON.stringify(redacted, null, 2)}\n`, "utf8");
|
||||||
|
await yieldToEventLoop();
|
||||||
|
zip.addFile(zipPath, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeReadBoundedJson(filePath: string, maxBytes: number): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
if (!stats.isFile() || stats.size > maxBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
function getSourcePathKey(sourcePath: string): string {
|
||||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
const resolved = path.resolve(sourcePath);
|
||||||
|
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
|
async function readTextTail(filePath: string, maxBytes: number): Promise<string> {
|
||||||
if (!sourcePath) {
|
const stats = await fsp.stat(filePath);
|
||||||
return;
|
const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
|
||||||
|
if (bytesToRead <= 0) {
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
const handle = await fsp.open(filePath, "r");
|
||||||
try {
|
try {
|
||||||
const buffer = await fsp.readFile(sourcePath);
|
const buffer = Buffer.alloc(bytesToRead);
|
||||||
zip.addFile(zipPath, buffer);
|
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead));
|
||||||
} catch {
|
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
||||||
|
return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
|
async function addTextFileIfExists(
|
||||||
let entries;
|
zip: AdmZip,
|
||||||
try {
|
sourcePath: string | null,
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
zipPath: string,
|
||||||
} catch {
|
includedSourcePaths: Set<string>,
|
||||||
return;
|
sensitiveValues: ReadonlySet<string>,
|
||||||
|
budget: TextBudget,
|
||||||
|
maxFileBytes: number,
|
||||||
|
maxAgeMs?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
for (const entry of entries) {
|
const sourcePathKey = getSourcePathKey(sourcePath);
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
if (includedSourcePaths.has(sourcePathKey)) {
|
||||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
return false;
|
||||||
if (entry.isDirectory()) {
|
}
|
||||||
await addDirectoryIfExists(zip, fullPath, zipPath);
|
try {
|
||||||
continue;
|
if (maxAgeMs !== undefined && (await fsp.stat(sourcePath)).mtimeMs < Date.now() - maxAgeMs) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
await addFileIfExists(zip, fullPath, zipPath);
|
const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes);
|
||||||
|
const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues);
|
||||||
|
let buffer = Buffer.from(text, "utf8");
|
||||||
|
if (buffer.length > allowedBytes) {
|
||||||
|
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
||||||
|
}
|
||||||
|
await yieldToEventLoop();
|
||||||
|
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
|
||||||
|
includedSourcePaths.add(sourcePathKey);
|
||||||
|
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
|
async function addRecentDirectoryFiles(
|
||||||
let entries;
|
zip: AdmZip,
|
||||||
|
dirPath: string,
|
||||||
|
zipRoot: string,
|
||||||
|
maxAgeMs: number,
|
||||||
|
maxFiles: number,
|
||||||
|
includedSourcePaths: Set<string>,
|
||||||
|
sensitiveValues: ReadonlySet<string>,
|
||||||
|
budget: TextBudget
|
||||||
|
): Promise<number> {
|
||||||
|
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
|
||||||
|
let directory;
|
||||||
try {
|
try {
|
||||||
entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
directory = await fsp.opendir(dirPath);
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const cutoff = Date.now() - maxAgeMs;
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
let added = 0;
|
let scanned = 0;
|
||||||
for (const entry of entries) {
|
for await (const entry of directory) {
|
||||||
if (!entry.isFile()) continue;
|
if (scanned >= MAX_DIRECTORY_SCAN_FILES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
scanned += 1;
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
try {
|
try {
|
||||||
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
|
const stats = await fsp.stat(fullPath);
|
||||||
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
|
if (stats.mtimeMs >= cutoff) {
|
||||||
added += 1;
|
candidates.push({ name: entry.name, fullPath, mtimeMs: stats.mtimeMs });
|
||||||
|
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||||
|
if (candidates.length > maxFiles) {
|
||||||
|
candidates.length = maxFiles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let added = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (budget.remainingBytes <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (await addTextFileIfExists(
|
||||||
|
zip,
|
||||||
|
candidate.fullPath,
|
||||||
|
path.posix.join(zipRoot, candidate.name),
|
||||||
|
includedSourcePaths,
|
||||||
|
sensitiveValues,
|
||||||
|
budget,
|
||||||
|
MAX_TEXT_FILE_BYTES
|
||||||
|
)) {
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActiveStatus(status: unknown): boolean {
|
||||||
|
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPackageDto(entry: PackageEntry): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
status: entry.status,
|
||||||
|
itemCount: entry.itemIds.length,
|
||||||
|
cancelled: entry.cancelled,
|
||||||
|
enabled: entry.enabled,
|
||||||
|
priority: entry.priority,
|
||||||
|
postProcessLabel: entry.postProcessLabel,
|
||||||
|
outputPath: entry.outputDir ? "<local-path>" : "",
|
||||||
|
extractPath: entry.extractDir ? "<local-path>" : "",
|
||||||
|
cleanedItemCount: entry.cleanedCompletedItemCount,
|
||||||
|
cleanedUrlCount: entry.cleanedUrls?.length || 0,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceHost(value: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
packageId: entry.packageId,
|
||||||
|
sourceHost: getSourceHost(entry.url),
|
||||||
|
provider: entry.provider,
|
||||||
|
providerLabel: entry.providerLabel,
|
||||||
|
providerAccountLabel: entry.providerAccountLabel,
|
||||||
|
status: entry.status,
|
||||||
|
retries: entry.retries,
|
||||||
|
speedBps: entry.speedBps,
|
||||||
|
downloadedBytes: entry.downloadedBytes,
|
||||||
|
totalBytes: entry.totalBytes,
|
||||||
|
progressPercent: entry.progressPercent,
|
||||||
|
fileName: entry.fileName,
|
||||||
|
targetPath: entry.targetPath ? "<local-path>" : "",
|
||||||
|
resumable: entry.resumable,
|
||||||
|
attempts: entry.attempts,
|
||||||
|
lastError: entry.lastError,
|
||||||
|
fullStatus: entry.fullStatus,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
onlineStatus: entry.onlineStatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRelevantEntries<T extends { status: unknown; updatedAt: number }>(entries: T[], limit: number): T[] {
|
||||||
|
return entries.sort((a, b) => Number(isActiveStatus(b.status)) - Number(isActiveStatus(a.status)) || b.updatedAt - a.updatedAt).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionDto(session: SessionState): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
version: session.version,
|
||||||
|
runStartedAt: session.runStartedAt,
|
||||||
|
totalDownloadedBytes: session.totalDownloadedBytes,
|
||||||
|
summaryText: session.summaryText,
|
||||||
|
reconnectUntil: session.reconnectUntil,
|
||||||
|
reconnectReason: session.reconnectReason,
|
||||||
|
paused: session.paused,
|
||||||
|
running: session.running,
|
||||||
|
updatedAt: session.updatedAt,
|
||||||
|
packageCount: Object.keys(session.packages).length,
|
||||||
|
itemCount: Object.keys(session.items).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHistoryDto(entry: HistoryEntry): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
status: entry.status,
|
||||||
|
provider: entry.provider,
|
||||||
|
fileCount: entry.fileCount,
|
||||||
|
totalBytes: entry.totalBytes,
|
||||||
|
downloadedBytes: entry.downloadedBytes,
|
||||||
|
durationSeconds: entry.durationSeconds,
|
||||||
|
completedAt: entry.completedAt,
|
||||||
|
outputPath: entry.outputDir ? "<local-path>" : "",
|
||||||
|
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoundedHistory(filePath: string): Promise<{ total: number | null; entries: Array<Record<string, unknown>>; omitted: number | null }> {
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
if (!stats.isFile() || stats.size > MAX_HISTORY_FILE_BYTES) {
|
||||||
|
return { total: null, entries: [], omitted: null };
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return { total: 0, entries: [], omitted: 0 };
|
||||||
|
}
|
||||||
|
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry));
|
||||||
|
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
|
||||||
|
} catch {
|
||||||
|
return { total: 0, entries: [], omitted: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestampForFileName(date: Date): string {
|
function formatTimestampForFileName(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
@@ -97,10 +399,93 @@ export function getSupportBundleDefaultFileName(): string {
|
|||||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SupportBundleExportResult {
|
||||||
|
saved: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupportBundleExportSuccess {
|
||||||
|
filePath: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupportBundleExportRunnerOptions {
|
||||||
|
chooseFile: () => Promise<string | null>;
|
||||||
|
build: () => Promise<Buffer>;
|
||||||
|
write: (filePath: string, buffer: Buffer) => Promise<void>;
|
||||||
|
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
|
||||||
|
onFailure?: (error: unknown) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSupportBundleExportRunner(
|
||||||
|
options: SupportBundleExportRunnerOptions
|
||||||
|
): () => Promise<SupportBundleExportResult> {
|
||||||
|
let active = false;
|
||||||
|
return async () => {
|
||||||
|
if (active) {
|
||||||
|
return {
|
||||||
|
saved: false,
|
||||||
|
busy: true,
|
||||||
|
message: "Support-Bundle wird bereits erstellt."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
active = true;
|
||||||
|
try {
|
||||||
|
const filePath = await options.chooseFile();
|
||||||
|
if (!filePath) {
|
||||||
|
return { saved: false, busy: false };
|
||||||
|
}
|
||||||
|
const buffer = await options.build();
|
||||||
|
await options.write(filePath, buffer);
|
||||||
|
if (options.onSuccess) {
|
||||||
|
try {
|
||||||
|
await options.onSuccess({ filePath, bytes: buffer.length });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { saved: true, busy: false, filePath };
|
||||||
|
} catch (error) {
|
||||||
|
if (options.onFailure) {
|
||||||
|
try {
|
||||||
|
await options.onFailure(error);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSupportBundleAtomically(filePath: string, buffer: Buffer): Promise<void> {
|
||||||
|
const targetPath = path.resolve(filePath);
|
||||||
|
const targetDirectory = path.dirname(targetPath);
|
||||||
|
const temporaryPath = path.join(targetDirectory, `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`);
|
||||||
|
let handle: Awaited<ReturnType<typeof fsp.open>> | null = null;
|
||||||
|
try {
|
||||||
|
handle = await fsp.open(temporaryPath, "wx");
|
||||||
|
await handle.writeFile(buffer);
|
||||||
|
await handle.sync();
|
||||||
|
await handle.close();
|
||||||
|
handle = null;
|
||||||
|
await fsp.rename(temporaryPath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (handle) {
|
||||||
|
await handle.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
await fsp.rm(temporaryPath, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
type HostDiagnosticsMode = "full" | "cached" | "none";
|
||||||
|
|
||||||
interface BuildSupportBundleOptions {
|
interface BuildSupportBundleOptions {
|
||||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
hostDiagnosticsMode?: HostDiagnosticsMode;
|
||||||
|
debugSetupMode?: "full" | "deferred";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
function createDeferredHostDiagnostics(reason: string): unknown {
|
||||||
@@ -137,88 +522,130 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
|||||||
|
|
||||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
|
const includedSourcePaths = new Set<string>();
|
||||||
|
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
|
||||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
const settings = loadSettings(storagePaths);
|
const settings = loadSettings(storagePaths);
|
||||||
const history = loadHistory(storagePaths);
|
const sensitiveValues = collectSensitiveValues(settings);
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const packageIds = Object.keys(snapshot.session.packages);
|
const packageEntries = Object.values(snapshot.session.packages);
|
||||||
const itemIds = Object.keys(snapshot.session.items);
|
const itemEntries = Object.values(snapshot.session.items);
|
||||||
const debugSetup = getDebugSetupCheck(baseDir);
|
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
|
||||||
|
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
|
||||||
|
const history = await loadBoundedHistory(storagePaths.historyFile);
|
||||||
|
const debugSetup = options.debugSetupMode === "deferred"
|
||||||
|
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
|
||||||
|
: getDebugSetupCheck(baseDir);
|
||||||
|
|
||||||
addJson(zip, "overview/meta.json", {
|
await addJson(zip, "overview/meta.json", {
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
runtimeBaseDir: baseDir,
|
runtimeBaseDir: "<local-path>",
|
||||||
packageCount: packageIds.length,
|
packageCount: packageEntries.length,
|
||||||
itemCount: itemIds.length
|
itemCount: itemEntries.length,
|
||||||
});
|
limits: {
|
||||||
addJson(zip, "overview/status.json", snapshot.session);
|
packageDtos: MAX_PACKAGE_DTOS,
|
||||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
itemDtos: MAX_ITEM_DTOS,
|
||||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
textBytes: MAX_TOTAL_TEXT_BYTES,
|
||||||
addJson(zip, "overview/stats.json", {
|
textFileBytes: MAX_TEXT_FILE_BYTES,
|
||||||
|
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000
|
||||||
|
}
|
||||||
|
}, sensitiveValues);
|
||||||
|
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
|
||||||
|
await addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings), sensitiveValues);
|
||||||
|
await addJson(zip, "overview/accounts.json", buildAccountSummary(settings), sensitiveValues);
|
||||||
|
await addJson(zip, "overview/stats.json", {
|
||||||
...buildStatsPayload(snapshot),
|
...buildStatsPayload(snapshot),
|
||||||
allTime: {
|
allTime: {
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
||||||
}
|
}
|
||||||
});
|
}, sensitiveValues);
|
||||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
|
||||||
addJson(zip, "overview/self-check.json", debugSetup);
|
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
|
||||||
addJson(zip, "overview/history.json", {
|
await addJson(zip, "overview/history.json", history, sensitiveValues);
|
||||||
total: history.length,
|
await addJson(zip, "overview/packages.json", {
|
||||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
count: packageEntries.length,
|
||||||
});
|
included: selectedPackages.length,
|
||||||
addJson(zip, "overview/packages.json", {
|
omitted: Math.max(0, packageEntries.length - selectedPackages.length),
|
||||||
count: packageIds.length,
|
packages: selectedPackages
|
||||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
}, sensitiveValues);
|
||||||
});
|
await addJson(zip, "overview/items.json", {
|
||||||
addJson(zip, "overview/items.json", {
|
count: itemEntries.length,
|
||||||
count: itemIds.length,
|
included: selectedItems.length,
|
||||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
omitted: Math.max(0, itemEntries.length - selectedItems.length),
|
||||||
});
|
items: selectedItems
|
||||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
}, sensitiveValues);
|
||||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
|
||||||
const recentErrors = getRecentErrors();
|
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
|
||||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
const recentErrors = getRecentErrors().slice(-100);
|
||||||
|
await addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }, sensitiveValues);
|
||||||
|
|
||||||
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
const addRuntimeFile = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
zip,
|
||||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
sourcePath,
|
||||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
zipPath,
|
||||||
|
includedSourcePaths,
|
||||||
|
sensitiveValues,
|
||||||
|
textBudget,
|
||||||
|
MAX_RUNTIME_FILE_BYTES
|
||||||
|
);
|
||||||
|
const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
||||||
|
zip,
|
||||||
|
sourcePath,
|
||||||
|
zipPath,
|
||||||
|
includedSourcePaths,
|
||||||
|
sensitiveValues,
|
||||||
|
textBudget,
|
||||||
|
MAX_TEXT_FILE_BYTES
|
||||||
|
);
|
||||||
|
const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
|
||||||
|
zip,
|
||||||
|
sourcePath,
|
||||||
|
zipPath,
|
||||||
|
includedSourcePaths,
|
||||||
|
sensitiveValues,
|
||||||
|
textBudget,
|
||||||
|
MAX_TEXT_FILE_BYTES,
|
||||||
|
SUPPORT_BUNDLE_LOG_WINDOW_MS
|
||||||
|
);
|
||||||
|
|
||||||
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
||||||
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
await addRuntimeFile(path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||||
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||||
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
|
||||||
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
|
||||||
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
|
||||||
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
|
||||||
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
|
||||||
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
|
||||||
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
|
|
||||||
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
|
|
||||||
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
|
|
||||||
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
|
|
||||||
|
|
||||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
const mainLogPath = getLogFilePath();
|
||||||
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
const auditLogPath = getAuditLogPath();
|
||||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
const renameLogPath = getRenameLogPath();
|
||||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
const traceLogPath = getTraceLogPath();
|
||||||
|
const accountRotationLogPath = getAccountRotationLogPath();
|
||||||
|
const conversionLogPath = getConversionLogPath();
|
||||||
|
await addCurrentLog(mainLogPath, "logs/rd_downloader.log");
|
||||||
|
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old");
|
||||||
|
await addCurrentLog(auditLogPath, "logs/audit.log");
|
||||||
|
await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old");
|
||||||
|
await addCurrentLog(renameLogPath, "logs/rename.log");
|
||||||
|
await addRotatedLog(renameLogPath ? `${renameLogPath}.old` : null, "logs/rename.log.old");
|
||||||
|
await addCurrentLog(getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
||||||
|
await addCurrentLog(getSessionLogPath(), "logs/session.log");
|
||||||
|
await addCurrentLog(traceLogPath, "logs/trace.log");
|
||||||
|
await addRotatedLog(traceLogPath ? `${traceLogPath}.old` : null, "logs/trace.log.old");
|
||||||
|
await addCurrentLog(accountRotationLogPath, "logs/account-rotation.log");
|
||||||
|
await addRotatedLog(accountRotationLogPath ? `${accountRotationLogPath}.old` : null, "logs/account-rotation.log.old");
|
||||||
|
await addCurrentLog(conversionLogPath, "logs/conversion.log");
|
||||||
|
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
|
||||||
|
|
||||||
for (const packageId of packageIds) {
|
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||||
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||||
}
|
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||||
for (const itemId of itemIds) {
|
|
||||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
|
||||||
if (supportManifest) {
|
if (supportManifest) {
|
||||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
await addJson(zip, "overview/support-manifest.json", supportManifest, sensitiveValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
return zip.toBuffer();
|
return await zip.toBufferPromise();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const api: ElectronApi = {
|
|||||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
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),
|
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||||
|
writeClipboardText: (text: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text),
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
@@ -78,7 +79,7 @@ const api: ElectronApi = {
|
|||||||
cancelBackupImport: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT),
|
cancelBackupImport: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT),
|
||||||
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
|
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
|
||||||
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
|
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
|
||||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string; busy?: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY),
|
openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY),
|
||||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||||
|
|||||||
+246
-119
@@ -45,6 +45,7 @@ import type { AccountModeFilter } from "./account-ui";
|
|||||||
import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit";
|
import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
|
import { buildAccountToggleSettingsUpdate, SerialTaskQueue, setAccountTargetEnabled, type AccountToggleTarget } from "./account-toggle-queue";
|
||||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
||||||
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
||||||
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
|
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
|
||||||
@@ -869,12 +870,45 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
|
|||||||
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||||
|
|
||||||
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
||||||
let delay = itemCount >= 700 ? 0 : itemCount >= 250 ? 50 : 100;
|
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
||||||
if (!running) delay = Math.min(delay, 200);
|
if (!running) delay = Math.min(delay, 200);
|
||||||
if (activeTab !== "downloads") delay = Math.max(delay, 800);
|
if (activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||||
return delay;
|
return delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SupportBundleExportUiOptions {
|
||||||
|
exportBundle: () => Promise<{ saved: boolean; busy?: boolean }>;
|
||||||
|
setBusy: (busy: boolean) => void;
|
||||||
|
clearMessage: () => void;
|
||||||
|
showMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSupportBundleExportUi(options: SupportBundleExportUiOptions): Promise<void> {
|
||||||
|
options.clearMessage();
|
||||||
|
options.setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await options.exportBundle();
|
||||||
|
if (result.saved) {
|
||||||
|
options.showMessage("Support-Bundle exportiert");
|
||||||
|
} else if (result.busy) {
|
||||||
|
options.showMessage("Support-Bundle wird bereits erstellt …");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
options.showMessage(`Support-Bundle fehlgeschlagen: ${String(error)}`);
|
||||||
|
} finally {
|
||||||
|
options.setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupportBundleToastProps {
|
||||||
|
busy: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportBundleToast({ busy, message }: SupportBundleToastProps): ReactElement | null {
|
||||||
|
return <Toast message={busy ? "Support-Bundle wird erstellt …" : message} />;
|
||||||
|
}
|
||||||
|
|
||||||
const KNOWN_HOSTERS: { id: string; label: string }[] = [
|
const KNOWN_HOSTERS: { id: string; label: string }[] = [
|
||||||
{ id: "rapidgator", label: "Rapidgator" },
|
{ id: "rapidgator", label: "Rapidgator" },
|
||||||
{ id: "uploaded", label: "Uploaded" },
|
{ id: "uploaded", label: "Uploaded" },
|
||||||
@@ -1261,7 +1295,7 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
|
|||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
drawChart();
|
drawChart();
|
||||||
}, reducedMotion ? 1000 : 250);
|
}, reducedMotion ? 1000 : 500);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
@@ -1363,7 +1397,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
|
|||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
|
|
||||||
const id = window.setInterval(tick, 250);
|
const id = window.setInterval(tick, 500);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1513,6 +1547,8 @@ export function App(): ReactElement {
|
|||||||
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
|
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
|
||||||
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
|
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
|
||||||
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
|
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
|
||||||
|
const settingsDraftRef = useRef(settingsDraft);
|
||||||
|
settingsDraftRef.current = settingsDraft;
|
||||||
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
|
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
|
||||||
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
||||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||||
@@ -1571,10 +1607,17 @@ export function App(): ReactElement {
|
|||||||
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 [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||||
|
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
|
||||||
|
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
|
||||||
|
const accountToggleQueueRef = useRef(new SerialTaskQueue());
|
||||||
|
const accountToggleRevisionRef = useRef(0);
|
||||||
|
const accountTogglePendingRef = useRef(0);
|
||||||
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);
|
||||||
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
||||||
|
const [supportBundleExporting, setSupportBundleExporting] = useState(false);
|
||||||
|
const supportBundleExportingRef = useRef(false);
|
||||||
const dragOverRef = useRef(false);
|
const dragOverRef = useRef(false);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
@@ -1755,6 +1798,14 @@ export function App(): ReactElement {
|
|||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearToast = useCallback((): void => {
|
||||||
|
setStatusToast("");
|
||||||
|
if (toastTimerRef.current) {
|
||||||
|
clearTimeout(toastTimerRef.current);
|
||||||
|
toastTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
||||||
const availableIds = entries.map((entry) => entry.id);
|
const availableIds = entries.map((entry) => entry.id);
|
||||||
const availableSet = new Set(availableIds);
|
const availableSet = new Set(availableIds);
|
||||||
@@ -1954,7 +2005,7 @@ export function App(): ReactElement {
|
|||||||
if (next.settings.columnOrder?.length > 0) {
|
if (next.settings.columnOrder?.length > 0) {
|
||||||
setColumnOrder(next.settings.columnOrder);
|
setColumnOrder(next.settings.columnOrder);
|
||||||
}
|
}
|
||||||
if (!settingsDirtyRef.current) {
|
if (!settingsDirtyRef.current && accountTogglePendingRef.current === 0) {
|
||||||
setSettingsDraft(createSettingsDraft(next.settings));
|
setSettingsDraft(createSettingsDraft(next.settings));
|
||||||
}
|
}
|
||||||
latestStateRef.current = null;
|
latestStateRef.current = null;
|
||||||
@@ -2308,8 +2359,9 @@ export function App(): ReactElement {
|
|||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
const used = acc.dailyUsageBytes;
|
const used = acc.dailyUsageBytes;
|
||||||
const limit = acc.dailyLimitBytes;
|
const limit = acc.dailyLimitBytes;
|
||||||
|
const rowKey = `mega-${entry.kind}-${acc.accountId}`;
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `mega-${entry.kind}-${acc.accountId}`,
|
rowKey,
|
||||||
entry,
|
entry,
|
||||||
hosterLabel: entry.serviceLabel,
|
hosterLabel: entry.serviceLabel,
|
||||||
modeLabel: entry.modeLabel,
|
modeLabel: entry.modeLabel,
|
||||||
@@ -2317,7 +2369,9 @@ export function App(): ReactElement {
|
|||||||
credentialLabel: "••••••",
|
credentialLabel: "••••••",
|
||||||
accountId: acc.accountId,
|
accountId: acc.accountId,
|
||||||
checkable: true,
|
checkable: true,
|
||||||
disabled: !acc.enabled,
|
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
|
||||||
|
? !accountEnabledOverrides[rowKey]
|
||||||
|
: !acc.enabled,
|
||||||
dailyUsedBytes: used,
|
dailyUsedBytes: used,
|
||||||
dailyLimitBytes: limit,
|
dailyLimitBytes: limit,
|
||||||
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
|
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
|
||||||
@@ -2334,8 +2388,9 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
} else if (entry.kind === "debridlink-api") {
|
} else if (entry.kind === "debridlink-api") {
|
||||||
for (const key of entry.debridLinkKeys) {
|
for (const key of entry.debridLinkKeys) {
|
||||||
|
const rowKey = `dl-${key.id}`;
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `dl-${key.id}`,
|
rowKey,
|
||||||
entry,
|
entry,
|
||||||
hosterLabel: entry.serviceLabel,
|
hosterLabel: entry.serviceLabel,
|
||||||
modeLabel: entry.modeLabel,
|
modeLabel: entry.modeLabel,
|
||||||
@@ -2343,7 +2398,9 @@ export function App(): ReactElement {
|
|||||||
credentialLabel: "API-Key",
|
credentialLabel: "API-Key",
|
||||||
accountId: key.id,
|
accountId: key.id,
|
||||||
checkable: true,
|
checkable: true,
|
||||||
disabled: key.disabled,
|
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
|
||||||
|
? !accountEnabledOverrides[rowKey]
|
||||||
|
: key.disabled,
|
||||||
dailyUsedBytes: key.dailyUsedBytes,
|
dailyUsedBytes: key.dailyUsedBytes,
|
||||||
dailyLimitBytes: key.dailyLimitBytes,
|
dailyLimitBytes: key.dailyLimitBytes,
|
||||||
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
|
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
|
||||||
@@ -2360,8 +2417,9 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const rowKey = `svc-${entry.service}`;
|
||||||
rows.push({
|
rows.push({
|
||||||
rowKey: `svc-${entry.service}`,
|
rowKey,
|
||||||
entry,
|
entry,
|
||||||
hosterLabel: entry.serviceLabel,
|
hosterLabel: entry.serviceLabel,
|
||||||
modeLabel: entry.modeLabel,
|
modeLabel: entry.modeLabel,
|
||||||
@@ -2369,7 +2427,9 @@ export function App(): ReactElement {
|
|||||||
credentialLabel: getAccountCredentialLabel(entry.kind),
|
credentialLabel: getAccountCredentialLabel(entry.kind),
|
||||||
accountId: null,
|
accountId: null,
|
||||||
checkable: false,
|
checkable: false,
|
||||||
disabled: entry.disabled,
|
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
|
||||||
|
? !accountEnabledOverrides[rowKey]
|
||||||
|
: entry.disabled,
|
||||||
dailyUsedBytes: entry.dailyUsedBytes,
|
dailyUsedBytes: entry.dailyUsedBytes,
|
||||||
dailyLimitBytes: entry.dailyLimitBytes,
|
dailyLimitBytes: entry.dailyLimitBytes,
|
||||||
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
|
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
|
||||||
@@ -2386,7 +2446,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [configuredAccounts, snapshot.accounts]);
|
}, [accountEnabledOverrides, configuredAccounts, snapshot.accounts]);
|
||||||
|
|
||||||
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
||||||
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
||||||
@@ -2791,28 +2851,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || [];
|
|
||||||
const nextDisabledIds = key.disabled
|
|
||||||
? currentDisabledIds.filter((existingId) => existingId !== key.id)
|
|
||||||
: [...currentDisabledIds, key.id];
|
|
||||||
const nextDraft: RendererSettingsDraft = {
|
|
||||||
...settingsDraft,
|
|
||||||
debridLinkDisabledKeyIds: nextDisabledIds
|
|
||||||
};
|
|
||||||
await persistSpecificSettings(nextDraft);
|
|
||||||
showToast(
|
|
||||||
key.disabled
|
|
||||||
? `${entry.serviceLabel} ${key.label} aktiviert`
|
|
||||||
: `${entry.serviceLabel} ${key.label} deaktiviert`,
|
|
||||||
2200
|
|
||||||
);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
||||||
const meta = getAccountQuickActionMeta(entry.kind);
|
const meta = getAccountQuickActionMeta(entry.kind);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
@@ -2825,25 +2863,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise<void> => {
|
|
||||||
await performQuickAction(async () => {
|
|
||||||
const mode = kind === "megadebrid-web" ? "web" : "api";
|
|
||||||
const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
||||||
const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId];
|
|
||||||
const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds;
|
|
||||||
const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds;
|
|
||||||
await persistSpecificSettings({
|
|
||||||
...settingsDraft,
|
|
||||||
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
|
|
||||||
megaDebridApiDisabledAccountIds: apiDisabledIds,
|
|
||||||
megaDebridWebDisabledAccountIds: webDisabledIds
|
|
||||||
});
|
|
||||||
showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000);
|
|
||||||
}, (error) => {
|
|
||||||
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
|
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
|
||||||
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
|
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
@@ -2855,61 +2874,145 @@ export function App(): ReactElement {
|
|||||||
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
|
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
|
const reconcileAccountToggleState = async (revision: number): Promise<void> => {
|
||||||
await performQuickAction(async () => {
|
const fresh = await window.rd.getSnapshot();
|
||||||
const provider = entry.service as DebridProvider;
|
if (!mountedRef.current || revision !== accountToggleRevisionRef.current) {
|
||||||
const current = settingsDraft.disabledProviders || [];
|
return;
|
||||||
const nextDisabledProviders = current.includes(provider)
|
}
|
||||||
? current.filter((existing) => existing !== provider)
|
masterSnapshotRef.current = fresh;
|
||||||
: [...current, provider];
|
latestStateRef.current = null;
|
||||||
const nextDraft: RendererSettingsDraft = {
|
setSnapshot(fresh);
|
||||||
...settingsDraft,
|
const reconciledDraft: RendererSettingsDraft = {
|
||||||
disabledProviders: nextDisabledProviders
|
...settingsDraftRef.current,
|
||||||
};
|
...buildAccountToggleSettingsUpdate(fresh.settings)
|
||||||
await persistSpecificSettings(nextDraft);
|
};
|
||||||
showToast(
|
settingsDraftRef.current = reconciledDraft;
|
||||||
nextDisabledProviders.includes(provider)
|
setSettingsDraft(reconciledDraft);
|
||||||
? `${entry.serviceLabel} deaktiviert`
|
accountEnabledOverridesRef.current = {};
|
||||||
: `${entry.serviceLabel} aktiviert`,
|
setAccountEnabledOverrides({});
|
||||||
2200
|
};
|
||||||
);
|
|
||||||
}, (error) => {
|
const enqueueAccountSettingsChange = (
|
||||||
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
|
nextDraft: RendererSettingsDraft,
|
||||||
|
nextOverrides: Record<string, boolean>,
|
||||||
|
successMessage: string,
|
||||||
|
errorMessage: string
|
||||||
|
): void => {
|
||||||
|
settingsDraftRevisionRef.current += 1;
|
||||||
|
settingsDraftRef.current = nextDraft;
|
||||||
|
setSettingsDraft(nextDraft);
|
||||||
|
accountEnabledOverridesRef.current = nextOverrides;
|
||||||
|
setAccountEnabledOverrides(nextOverrides);
|
||||||
|
const revision = accountToggleRevisionRef.current + 1;
|
||||||
|
accountToggleRevisionRef.current = revision;
|
||||||
|
accountTogglePendingRef.current += 1;
|
||||||
|
void accountToggleQueueRef.current.enqueue(async () => {
|
||||||
|
let error: unknown = null;
|
||||||
|
try {
|
||||||
|
await window.rd.updateSettings(buildAccountToggleSettingsUpdate(nextDraft));
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
} finally {
|
||||||
|
accountTogglePendingRef.current = Math.max(0, accountTogglePendingRef.current - 1);
|
||||||
|
}
|
||||||
|
if (revision !== accountToggleRevisionRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reconcileAccountToggleState(revision);
|
||||||
|
} catch (caught) {
|
||||||
|
error = error ?? caught;
|
||||||
|
if (mountedRef.current && revision === accountToggleRevisionRef.current) {
|
||||||
|
const fallbackDraft: RendererSettingsDraft = {
|
||||||
|
...settingsDraftRef.current,
|
||||||
|
...buildAccountToggleSettingsUpdate(snapshotRef.current.settings)
|
||||||
|
};
|
||||||
|
settingsDraftRef.current = fallbackDraft;
|
||||||
|
setSettingsDraft(fallbackDraft);
|
||||||
|
accountEnabledOverridesRef.current = {};
|
||||||
|
setAccountEnabledOverrides({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
showToast(`${errorMessage}: ${String(error)}`, 3200);
|
||||||
|
} else {
|
||||||
|
showToast(successMessage, 1800);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAccountToggleTarget = (row: AccountTableRow): AccountToggleTarget | null => {
|
||||||
|
if (row.toggleKind === "mega" && row.accountId) {
|
||||||
|
return {
|
||||||
|
kind: row.entry.kind === "megadebrid-web" ? "mega-web" : "mega-api",
|
||||||
|
accountId: row.accountId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.toggleKind === "dl" && row.accountId) {
|
||||||
|
return { kind: "debridlink", accountId: row.accountId };
|
||||||
|
}
|
||||||
|
if (row.toggleKind === "single") {
|
||||||
|
return { kind: "provider", provider: row.entry.service as DebridProvider };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAccountTableRow = (row: AccountTableRow): void => {
|
const toggleAccountTableRow = (row: AccountTableRow): void => {
|
||||||
setAccountContextMenu(null);
|
setAccountContextMenu(null);
|
||||||
if (row.toggleKind === "mega" && row.accountId) {
|
const target = getAccountToggleTarget(row);
|
||||||
void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled);
|
if (!target) {
|
||||||
} else if (row.toggleKind === "dl" && row.dlKey) {
|
return;
|
||||||
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey);
|
}
|
||||||
} else {
|
const currentEnabled = Object.prototype.hasOwnProperty.call(accountEnabledOverridesRef.current, row.rowKey)
|
||||||
void onToggleAccountEnabled(row.entry);
|
? accountEnabledOverridesRef.current[row.rowKey]
|
||||||
|
: !row.disabled;
|
||||||
|
const nextEnabled = !currentEnabled;
|
||||||
|
const nextDraft = setAccountTargetEnabled(settingsDraftRef.current, target, nextEnabled);
|
||||||
|
const nextOverrides = {
|
||||||
|
...accountEnabledOverridesRef.current,
|
||||||
|
[row.rowKey]: nextEnabled
|
||||||
|
};
|
||||||
|
enqueueAccountSettingsChange(
|
||||||
|
nextDraft,
|
||||||
|
nextOverrides,
|
||||||
|
nextEnabled ? "Account aktiviert" : "Account deaktiviert",
|
||||||
|
"Account konnte nicht umgeschaltet werden"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleDebridLinkApiKeyEnabled = (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): void => {
|
||||||
|
const row = accountRows.find((candidate) => candidate.entry.service === entry.service && candidate.accountId === key.id);
|
||||||
|
if (row) {
|
||||||
|
toggleAccountTableRow(row);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setAllAccountsEnabled = async (enabled: boolean): Promise<void> => {
|
const setAllAccountsEnabled = (enabled: boolean): void => {
|
||||||
await performQuickAction(async () => {
|
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
|
||||||
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
|
const nextEnabledState = buildBulkAccountEnabledState(
|
||||||
const nextEnabledState = buildBulkAccountEnabledState(
|
settingsDraftRef.current.disabledProviders || [],
|
||||||
settingsDraft.disabledProviders || [],
|
configuredProviderIds,
|
||||||
configuredProviderIds,
|
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
|
||||||
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
|
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
|
||||||
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
|
enabled
|
||||||
enabled
|
);
|
||||||
);
|
const nextDraft: RendererSettingsDraft = {
|
||||||
const nextDraft: RendererSettingsDraft = {
|
...settingsDraftRef.current,
|
||||||
...settingsDraft,
|
...nextEnabledState,
|
||||||
...nextEnabledState,
|
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
|
||||||
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
|
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
|
||||||
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
|
};
|
||||||
};
|
nextDraft.megaDebridDisabledAccountIds = [...new Set([
|
||||||
await persistSpecificSettings(nextDraft);
|
...nextDraft.megaDebridApiDisabledAccountIds,
|
||||||
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200);
|
...nextDraft.megaDebridWebDisabledAccountIds
|
||||||
}, (error) => {
|
])];
|
||||||
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200);
|
const nextOverrides = Object.fromEntries(accountRows.map((row) => [row.rowKey, enabled]));
|
||||||
});
|
enqueueAccountSettingsChange(
|
||||||
|
nextDraft,
|
||||||
|
nextOverrides,
|
||||||
|
enabled ? "Accounts aktiviert" : "Accounts deaktiviert",
|
||||||
|
"Accounts konnten nicht umgeschaltet werden"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAccountTableRow = (row: AccountTableRow): void => {
|
const removeAccountTableRow = (row: AccountTableRow): void => {
|
||||||
@@ -4199,7 +4302,7 @@ export function App(): ReactElement {
|
|||||||
const onCopyOnlineBackupKey = async (): Promise<void> => {
|
const onCopyOnlineBackupKey = async (): Promise<void> => {
|
||||||
if (!onlineBackupDialog?.key) return;
|
if (!onlineBackupDialog?.key) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(onlineBackupDialog.key);
|
await window.rd.writeClipboardText(onlineBackupDialog.key);
|
||||||
showToast("Online-Schlüssel kopiert", 2200);
|
showToast("Online-Schlüssel kopiert", 2200);
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Schlüssel konnte nicht kopiert werden", 2600);
|
showToast("Schlüssel konnte nicht kopiert werden", 2600);
|
||||||
@@ -4208,14 +4311,21 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onExportSupportBundle = async (): Promise<void> => {
|
const onExportSupportBundle = async (): Promise<void> => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
await performQuickAction(async () => {
|
if (supportBundleExportingRef.current) {
|
||||||
const result = await window.rd.exportSupportBundle();
|
showToast("Support-Bundle wird bereits erstellt …", 2600);
|
||||||
if (result.saved) {
|
return;
|
||||||
showToast("Support-Bundle exportiert", 2600);
|
}
|
||||||
}
|
supportBundleExportingRef.current = true;
|
||||||
}, (error) => {
|
try {
|
||||||
showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800);
|
await runSupportBundleExportUi({
|
||||||
});
|
exportBundle: () => window.rd.exportSupportBundle(),
|
||||||
|
setBusy: setSupportBundleExporting,
|
||||||
|
clearMessage: clearToast,
|
||||||
|
showMessage: (message) => showToast(message, 2800)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
supportBundleExportingRef.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onToggleSupportTrace = async (): Promise<void> => {
|
const onToggleSupportTrace = async (): Promise<void> => {
|
||||||
@@ -4268,7 +4378,7 @@ export function App(): ReactElement {
|
|||||||
detailsLabel: "Einträge anzeigen"
|
detailsLabel: "Einträge anzeigen"
|
||||||
});
|
});
|
||||||
if (copy && entries.length > 0) {
|
if (copy && entries.length > 0) {
|
||||||
await navigator.clipboard.writeText(details);
|
await window.rd.writeClipboardText(details);
|
||||||
showToast("Fehlerliste kopiert", 2600);
|
showToast("Fehlerliste kopiert", 2600);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -4371,7 +4481,7 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(remoteDiag.code);
|
await window.rd.writeClipboardText(remoteDiag.code);
|
||||||
showToast("Verbindungscode kopiert", 2200);
|
showToast("Verbindungscode kopiert", 2200);
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Kopieren fehlgeschlagen", 2200);
|
showToast("Kopieren fehlgeschlagen", 2200);
|
||||||
@@ -4624,9 +4734,26 @@ export function App(): ReactElement {
|
|||||||
},
|
},
|
||||||
onPauseDownloads: () => {
|
onPauseDownloads: () => {
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
||||||
void window.rd.togglePause().catch(() => {});
|
void window.rd.togglePause().then((paused) => {
|
||||||
|
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
|
||||||
|
}).catch(async (error) => {
|
||||||
|
try {
|
||||||
|
setSnapshot(await window.rd.getSnapshot());
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStopDownloads: () => {
|
||||||
|
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
|
||||||
|
void window.rd.stop().catch(async (error) => {
|
||||||
|
try {
|
||||||
|
setSnapshot(await window.rd.getSnapshot());
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
|
|
||||||
onToggleSchedule: () => {
|
onToggleSchedule: () => {
|
||||||
setSchedulePickerOpen((current) => !current);
|
setSchedulePickerOpen((current) => !current);
|
||||||
setScheduleTimeInput("");
|
setScheduleTimeInput("");
|
||||||
@@ -5492,7 +5619,7 @@ export function App(): ReactElement {
|
|||||||
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
|
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
|
||||||
>
|
>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
<button className="menu-dropdown-item" disabled={supportBundleExporting} onClick={() => { void onExportSupportBundle(); }}><span>{supportBundleExporting ? "Support-Bundle wird erstellt …" : "Support-Bundle exportieren"}</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5843,7 +5970,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
accountEdit={accountEditDialogView}
|
accountEdit={accountEditDialogView}
|
||||||
accountCreate={accountAddDialog}
|
accountCreate={accountAddDialog}
|
||||||
toast={<Toast message={statusToast} />}
|
toast={<SupportBundleToast busy={supportBundleExporting} message={statusToast} />}
|
||||||
dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null}
|
dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null}
|
||||||
accountContextMenu={accountContextMenu && activeAccountContextRow ? (
|
accountContextMenu={accountContextMenu && activeAccountContextRow ? (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -6153,7 +6280,7 @@ export function App(): ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
title={`${key.masked}\nMaskierte Kennung kopieren`}
|
title={`${key.masked}\nMaskierte Kennung kopieren`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(key.masked)
|
void window.rd.writeClipboardText(key.masked)
|
||||||
.then(() => showToast("Maskierte Kennung kopiert", 1800))
|
.then(() => showToast("Maskierte Kennung kopiert", 1800))
|
||||||
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
|
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
|
||||||
}}
|
}}
|
||||||
@@ -6211,8 +6338,8 @@ export function App(): ReactElement {
|
|||||||
<div className="link-popup-list">
|
<div className="link-popup-list">
|
||||||
{linkPopup.links.map((link, i) => (
|
{linkPopup.links.map((link, i) => (
|
||||||
<div key={i} className="link-popup-row">
|
<div key={i} className="link-popup-row">
|
||||||
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
|
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
|
||||||
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
|
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -6220,13 +6347,13 @@ export function App(): ReactElement {
|
|||||||
{linkPopup.isPackage && (
|
{linkPopup.isPackage && (
|
||||||
<button className="btn" onClick={() => {
|
<button className="btn" onClick={() => {
|
||||||
const text = linkPopup.links.map((l) => l.name).join("\n");
|
const text = linkPopup.links.map((l) => l.name).join("\n");
|
||||||
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
|
void window.rd.writeClipboardText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
|
||||||
}}>Alle Namen kopieren</button>
|
}}>Alle Namen kopieren</button>
|
||||||
)}
|
)}
|
||||||
{linkPopup.isPackage && (
|
{linkPopup.isPackage && (
|
||||||
<button className="btn" onClick={() => {
|
<button className="btn" onClick={() => {
|
||||||
const text = linkPopup.links.map((l) => l.url).join("\n");
|
const text = linkPopup.links.map((l) => l.url).join("\n");
|
||||||
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
|
void window.rd.writeClipboardText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
|
||||||
}}>Alle Links kopieren</button>
|
}}>Alle Links kopieren</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
|
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { DebridProvider, RendererSettingsUpdate } from "../shared/types";
|
||||||
|
|
||||||
|
export type AccountToggleTarget =
|
||||||
|
| { kind: "provider"; provider: DebridProvider }
|
||||||
|
| { kind: "debridlink"; accountId: string }
|
||||||
|
| { kind: "mega-api"; accountId: string }
|
||||||
|
| { kind: "mega-web"; accountId: string };
|
||||||
|
|
||||||
|
export interface AccountToggleSettings {
|
||||||
|
disabledProviders: DebridProvider[];
|
||||||
|
debridLinkDisabledKeyIds: string[];
|
||||||
|
megaDebridDisabledAccountIds: string[];
|
||||||
|
megaDebridApiDisabledAccountIds: string[];
|
||||||
|
megaDebridWebDisabledAccountIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setListEntry<T extends string>(values: T[], value: T, present: boolean): T[] {
|
||||||
|
return present
|
||||||
|
? [...new Set([...values, value])]
|
||||||
|
: values.filter((entry) => entry !== value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccountTargetEnabled<T extends AccountToggleSettings>(
|
||||||
|
settings: T,
|
||||||
|
target: AccountToggleTarget,
|
||||||
|
enabled: boolean
|
||||||
|
): T {
|
||||||
|
if (target.kind === "provider") {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
disabledProviders: setListEntry(settings.disabledProviders, target.provider, !enabled)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target.kind === "debridlink") {
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
debridLinkDisabledKeyIds: setListEntry(settings.debridLinkDisabledKeyIds, target.accountId, !enabled)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const apiDisabled = target.kind === "mega-api"
|
||||||
|
? setListEntry(settings.megaDebridApiDisabledAccountIds, target.accountId, !enabled)
|
||||||
|
: settings.megaDebridApiDisabledAccountIds;
|
||||||
|
const webDisabled = target.kind === "mega-web"
|
||||||
|
? setListEntry(settings.megaDebridWebDisabledAccountIds, target.accountId, !enabled)
|
||||||
|
: settings.megaDebridWebDisabledAccountIds;
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
megaDebridApiDisabledAccountIds: apiDisabled,
|
||||||
|
megaDebridWebDisabledAccountIds: webDisabled,
|
||||||
|
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings): RendererSettingsUpdate {
|
||||||
|
return {
|
||||||
|
disabledProviders: settings.disabledProviders,
|
||||||
|
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
|
||||||
|
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
|
||||||
|
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
|
||||||
|
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SerialTaskQueue {
|
||||||
|
private tail: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
public enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.tail.then(task);
|
||||||
|
this.tail = result.then(() => undefined, () => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,7 +137,7 @@ const pairs = [
|
|||||||
["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"],
|
["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"],
|
||||||
["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"],
|
["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"],
|
||||||
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
|
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
|
||||||
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
|
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Bundle wird erstellt …", "Support bundle is being created …"], ["Support-Bundle wird bereits erstellt …", "Support bundle is already being created …"],
|
||||||
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
|
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
|
||||||
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
|
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
|
||||||
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
|
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
<button disabled={!model.canStop} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||||
{model.scheduleActive
|
{model.scheduleActive
|
||||||
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
|
||||||
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const IPC_CHANNELS = {
|
|||||||
STATE_UPDATE: "state:update",
|
STATE_UPDATE: "state:update",
|
||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
|
WRITE_CLIPBOARD_TEXT: "clipboard:write-text",
|
||||||
GET_SESSION_STATS: "stats:get-session-stats",
|
GET_SESSION_STATS: "stats:get-session-stats",
|
||||||
RESET_SESSION_STATS: "stats:reset-session",
|
RESET_SESSION_STATS: "stats:reset-session",
|
||||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface ElectronApi {
|
|||||||
exportQueue: () => Promise<{ saved: boolean }>;
|
exportQueue: () => Promise<{ saved: boolean }>;
|
||||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
toggleClipboard: () => Promise<boolean>;
|
toggleClipboard: () => Promise<boolean>;
|
||||||
|
writeClipboardText: (text: string) => Promise<boolean>;
|
||||||
pickFolder: () => Promise<string | null>;
|
pickFolder: () => Promise<string | null>;
|
||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
getSessionStats: () => Promise<SessionStats>;
|
getSessionStats: () => Promise<SessionStats>;
|
||||||
@@ -75,7 +76,7 @@ export interface ElectronApi {
|
|||||||
cancelBackupImport: () => Promise<void>;
|
cancelBackupImport: () => Promise<void>;
|
||||||
exportOnlineBackup: () => Promise<{ key: string }>;
|
exportOnlineBackup: () => Promise<{ key: string }>;
|
||||||
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
|
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
|
||||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string; busy?: boolean }>;
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openLogDirectory: () => Promise<void>;
|
openLogDirectory: () => Promise<void>;
|
||||||
openAuditLog: () => Promise<void>;
|
openAuditLog: () => Promise<void>;
|
||||||
|
|||||||
@@ -61,4 +61,15 @@ describe("account preload contract", () => {
|
|||||||
IPC_CHANNELS.DELETE_ACCOUNT
|
IPC_CHANNELS.DELETE_ACCOUNT
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes copied text through the native Electron clipboard channel", async () => {
|
||||||
|
electron.invoke.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
await electron.api?.writeClipboardText("https://rapidgator.net/file/example");
|
||||||
|
|
||||||
|
expect(electron.invoke).toHaveBeenCalledWith(
|
||||||
|
IPC_CHANNELS.WRITE_CLIPBOARD_TEXT,
|
||||||
|
"https://rapidgator.net/file/example"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
SerialTaskQueue,
|
||||||
|
setAccountTargetEnabled,
|
||||||
|
type AccountToggleTarget
|
||||||
|
} from "../src/renderer/account-toggle-queue";
|
||||||
|
|
||||||
|
describe("account toggle queue", () => {
|
||||||
|
it("keeps every rapid mutation and persists them in click order", async () => {
|
||||||
|
const queue = new SerialTaskQueue();
|
||||||
|
const calls: number[] = [];
|
||||||
|
const first = queue.enqueue(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
calls.push(1);
|
||||||
|
});
|
||||||
|
const second = queue.enqueue(async () => {
|
||||||
|
calls.push(2);
|
||||||
|
});
|
||||||
|
const third = queue.enqueue(async () => {
|
||||||
|
calls.push(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([first, second, third]);
|
||||||
|
|
||||||
|
expect(calls).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues after a failed mutation", async () => {
|
||||||
|
const queue = new SerialTaskQueue();
|
||||||
|
const calls: number[] = [];
|
||||||
|
const failed = queue.enqueue(async () => {
|
||||||
|
calls.push(1);
|
||||||
|
throw new Error("failed");
|
||||||
|
});
|
||||||
|
const recovered = queue.enqueue(async () => {
|
||||||
|
calls.push(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(failed).rejects.toThrow("failed");
|
||||||
|
await recovered;
|
||||||
|
|
||||||
|
expect(calls).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("combines rapid Mega-Debrid Web activations without losing earlier clicks", () => {
|
||||||
|
const accountIds = ["web-1", "web-2", "web-3"];
|
||||||
|
let settings = {
|
||||||
|
disabledProviders: [],
|
||||||
|
debridLinkDisabledKeyIds: [],
|
||||||
|
megaDebridApiDisabledAccountIds: [],
|
||||||
|
megaDebridWebDisabledAccountIds: [...accountIds],
|
||||||
|
megaDebridDisabledAccountIds: [...accountIds]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const target: AccountToggleTarget = { kind: "mega-web", accountId };
|
||||||
|
settings = setAccountTargetEnabled(settings, target, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||||
|
expect(settings.megaDebridDisabledAccountIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
|
|||||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||||
import { getSnapshotRenderDelay } from "../src/renderer/App";
|
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||||
|
|
||||||
describe("desktop shell", () => {
|
describe("desktop shell", () => {
|
||||||
it("uses keyboard-focusable controls for every copy target", () => {
|
it("uses keyboard-focusable controls for every copy target", () => {
|
||||||
@@ -13,8 +13,8 @@ describe("desktop shell", () => {
|
|||||||
|
|
||||||
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
|
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
|
||||||
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
|
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
|
||||||
expect(source).not.toContain("navigator.clipboard.writeText(key.token)");
|
expect(source).not.toContain("navigator.clipboard.writeText");
|
||||||
expect(source).toContain("navigator.clipboard.writeText(key.masked)");
|
expect(source).toContain("window.rd.writeClipboardText(key.masked)");
|
||||||
expect(source).toContain("Maskierte Kennung kopiert");
|
expect(source).toContain("Maskierte Kennung kopiert");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,11 +35,57 @@ describe("desktop shell", () => {
|
|||||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not stack renderer latency on the manager cadence for large active queues", () => {
|
it("renders active download telemetry at a stable half-second cadence", () => {
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||||
|
let resolveExport: (value: { saved: boolean; busy?: boolean }) => void = () => undefined;
|
||||||
|
const pendingExport = new Promise<{ saved: boolean; busy?: boolean }>((resolve) => { resolveExport = resolve; });
|
||||||
|
const busyStates: boolean[] = [];
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
const running = runSupportBundleExportUi({
|
||||||
|
exportBundle: () => pendingExport,
|
||||||
|
setBusy: (busy) => { busyStates.push(busy); },
|
||||||
|
clearMessage: () => { messages.length = 0; },
|
||||||
|
showMessage: (message) => { messages.push(message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busyStates).toEqual([true]);
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
resolveExport({ saved: true });
|
||||||
|
await running;
|
||||||
|
expect(busyStates).toEqual([true, false]);
|
||||||
|
expect(messages).toEqual(["Support-Bundle exportiert"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends support bundle progress immediately when the file dialog is canceled", async () => {
|
||||||
|
const busyStates: boolean[] = [];
|
||||||
|
const messages = ["Vorherige Meldung"];
|
||||||
|
|
||||||
|
await runSupportBundleExportUi({
|
||||||
|
exportBundle: async () => ({ saved: false, busy: false }),
|
||||||
|
setBusy: (busy) => { busyStates.push(busy); },
|
||||||
|
clearMessage: () => { messages.length = 0; },
|
||||||
|
showMessage: (message) => { messages.push(message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(busyStates).toEqual([true, false]);
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders progress from the real busy state instead of a timed status message", () => {
|
||||||
|
const busyHtml = renderToStaticMarkup(<SupportBundleToast busy message="Alte Meldung" />);
|
||||||
|
const idleHtml = renderToStaticMarkup(<SupportBundleToast busy={false} message="Export beendet" />);
|
||||||
|
|
||||||
|
expect(busyHtml).toContain("Support-Bundle wird erstellt …");
|
||||||
|
expect(busyHtml).not.toContain("Alte Meldung");
|
||||||
|
expect(idleHtml).toContain("Export beendet");
|
||||||
|
expect(idleHtml).not.toContain("Support-Bundle wird erstellt …");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps application menus mounted for animated opening and closing", () => {
|
it("keeps application menus mounted for animated opening and closing", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
|
||||||
|
|||||||
+248
-16
@@ -13,6 +13,7 @@ afterEach(() => {
|
|||||||
resetDebridLinkRuntimeStateForTests();
|
resetDebridLinkRuntimeStateForTests();
|
||||||
resetMegaDebridRuntimeStateForTests();
|
resetMegaDebridRuntimeStateForTests();
|
||||||
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
|
||||||
|
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1429,6 +1430,181 @@ describe("debrid service", () => {
|
|||||||
expect(megaWeb).toHaveBeenCalledTimes(0);
|
expect(megaWeb).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("isolates Mega-Debrid API single-flight consumers so one abort does not cancel the shared connect", async () => {
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaCredentials: "single-flight-user:single-flight-pass",
|
||||||
|
megaDebridApiCredentials: "single-flight-user:single-flight-pass",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid-api" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
let releaseConnect: () => void = () => {};
|
||||||
|
let markConnectStarted: () => void = () => {};
|
||||||
|
const connectStarted = new Promise<void>((resolve) => {
|
||||||
|
markConnectStarted = resolve;
|
||||||
|
});
|
||||||
|
let connectSignal: AbortSignal | undefined;
|
||||||
|
let connectCalls = 0;
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("action=connectUser")) {
|
||||||
|
connectCalls += 1;
|
||||||
|
connectSignal = init?.signal as AbortSignal | undefined;
|
||||||
|
markConnectStarted();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onAbort = (): void => reject(new Error("shared connect aborted"));
|
||||||
|
releaseConnect = (): void => {
|
||||||
|
connectSignal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
if (connectSignal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectSignal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "shared-token" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("action=getLink")) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
response_code: "ok",
|
||||||
|
debridLink: "https://mega-cdn.example/survivor.rar",
|
||||||
|
filename: "survivor.rar"
|
||||||
|
}), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const service = new DebridService(settings);
|
||||||
|
const firstController = new AbortController();
|
||||||
|
const secondController = new AbortController();
|
||||||
|
const first = service.unrestrictLink("https://rapidgator.net/file/first-consumer", firstController.signal);
|
||||||
|
await connectStarted;
|
||||||
|
const second = service.unrestrictLink("https://rapidgator.net/file/second-consumer", secondController.signal);
|
||||||
|
const firstOutcome = first.then(() => "fulfilled", () => "rejected");
|
||||||
|
const secondOutcome = second.then(
|
||||||
|
(value) => ({ status: "fulfilled" as const, value }),
|
||||||
|
(error: unknown) => ({ status: "rejected" as const, error })
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
firstController.abort("cancel-first-consumer");
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const firstState = await Promise.race([
|
||||||
|
firstOutcome,
|
||||||
|
new Promise<"pending">((resolve) => {
|
||||||
|
timeout = setTimeout(() => resolve("pending"), 100);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
expect(firstState).toBe("rejected");
|
||||||
|
expect(connectSignal?.aborted).toBe(false);
|
||||||
|
expect(secondController.signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
releaseConnect();
|
||||||
|
const survivor = await secondOutcome;
|
||||||
|
expect(survivor.status).toBe("fulfilled");
|
||||||
|
if (survivor.status === "fulfilled") {
|
||||||
|
expect(survivor.value.directUrl).toBe("https://mega-cdn.example/survivor.rar");
|
||||||
|
}
|
||||||
|
expect(connectCalls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
releaseConnect();
|
||||||
|
await Promise.all([firstOutcome, secondOutcome]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
||||||
|
const oldSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaCredentials: "generation-user:old-pass",
|
||||||
|
megaDebridApiCredentials: "generation-user:old-pass",
|
||||||
|
megaDebridApiEnabled: true,
|
||||||
|
megaDebridWebEnabled: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid-api" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
const newSettings = {
|
||||||
|
...oldSettings,
|
||||||
|
megaCredentials: "generation-user:new-pass",
|
||||||
|
megaDebridApiCredentials: "generation-user:new-pass"
|
||||||
|
};
|
||||||
|
let releaseOldConnect: () => void = () => {};
|
||||||
|
let markOldConnectStarted: () => void = () => {};
|
||||||
|
const oldConnectStarted = new Promise<void>((resolve) => {
|
||||||
|
markOldConnectStarted = resolve;
|
||||||
|
});
|
||||||
|
const connectPasswords: string[] = [];
|
||||||
|
const getLinkTokens: string[] = [];
|
||||||
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("action=connectUser")) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const password = parsed.searchParams.get("password") || "";
|
||||||
|
connectPasswords.push(password);
|
||||||
|
if (password === "old-pass") {
|
||||||
|
markOldConnectStarted();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseOldConnect = resolve;
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "old-token" }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({ response_code: "ok", token: "new-token" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.includes("action=getLink")) {
|
||||||
|
const token = new URL(url).searchParams.get("token") || "";
|
||||||
|
getLinkTokens.push(token);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
response_code: "ok",
|
||||||
|
debridLink: `https://mega-cdn.example/${token}.rar`,
|
||||||
|
filename: `${token}.rar`
|
||||||
|
}), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not-found", { status: 404 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const service = new DebridService(oldSettings);
|
||||||
|
const oldRequest = service.unrestrictLink("https://rapidgator.net/file/old-credentials");
|
||||||
|
const oldOutcome = oldRequest.then(
|
||||||
|
(value) => ({ status: "fulfilled" as const, value }),
|
||||||
|
(error: unknown) => ({ status: "rejected" as const, error })
|
||||||
|
);
|
||||||
|
await oldConnectStarted;
|
||||||
|
|
||||||
|
try {
|
||||||
|
service.setSettings(newSettings);
|
||||||
|
const current = await service.unrestrictLink("https://rapidgator.net/file/new-credentials");
|
||||||
|
expect(current.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
||||||
|
|
||||||
|
releaseOldConnect();
|
||||||
|
await oldOutcome;
|
||||||
|
|
||||||
|
const subsequent = await service.unrestrictLink("https://rapidgator.net/file/subsequent");
|
||||||
|
expect(subsequent.directUrl).toBe("https://mega-cdn.example/new-token.rar");
|
||||||
|
expect(connectPasswords).toEqual(["old-pass", "new-pass"]);
|
||||||
|
expect(getLinkTokens.at(-1)).toBe("new-token");
|
||||||
|
} finally {
|
||||||
|
releaseOldConnect();
|
||||||
|
await oldOutcome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -1693,7 +1869,6 @@ describe("debrid service", () => {
|
|||||||
try {
|
try {
|
||||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
|
||||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||||
expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal);
|
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(abortTimer);
|
clearTimeout(abortTimer);
|
||||||
}
|
}
|
||||||
@@ -2093,6 +2268,7 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
|
||||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
|
||||||
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
token: "",
|
token: "",
|
||||||
@@ -2110,13 +2286,18 @@ describe("debrid service", () => {
|
|||||||
};
|
};
|
||||||
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (calls === 1) {
|
if (calls === 1) {
|
||||||
controller.abort("simulated-60s-timeout");
|
return new Promise((_resolve, reject) => {
|
||||||
return Promise.reject(new Error("aborted"));
|
const onAbort = (): void => reject(new Error("aborted"));
|
||||||
|
if (signal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
fileName: "healthy.rar",
|
fileName: "healthy.rar",
|
||||||
@@ -2128,7 +2309,7 @@ describe("debrid service", () => {
|
|||||||
|
|
||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
|
||||||
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
|
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
|
||||||
expect(err).toBeTruthy();
|
expect(err).toBeTruthy();
|
||||||
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
|
||||||
|
|
||||||
@@ -2280,7 +2461,7 @@ describe("debrid service", () => {
|
|||||||
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
|
it("rotates to the next Mega-Web account in the same unrestrict after an account timeout", async () => {
|
||||||
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -2310,17 +2491,68 @@ describe("debrid service", () => {
|
|||||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
|
||||||
|
|
||||||
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
|
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-1");
|
||||||
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
|
|
||||||
expect(loginsSeen).toContain("user1");
|
expect(loginsSeen).toContain("user1");
|
||||||
expect(loginsSeen).not.toContain("user2");
|
|
||||||
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
|
||||||
|
|
||||||
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
|
|
||||||
loginsSeen.length = 0;
|
|
||||||
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
|
|
||||||
expect(loginsSeen).not.toContain("user1");
|
|
||||||
expect(loginsSeen).toContain("user2");
|
expect(loginsSeen).toContain("user2");
|
||||||
|
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
|
||||||
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
|
}, 20000);
|
||||||
|
|
||||||
|
it("gives every Mega-Web account a fresh timeout budget after a queue timeout", async () => {
|
||||||
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "",
|
||||||
|
bestToken: "",
|
||||||
|
allDebridToken: "",
|
||||||
|
megaLogin: "user1",
|
||||||
|
megaPassword: "pass1",
|
||||||
|
megaCredentials: "user1:pass1\nuser2:pass2",
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [] as const,
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
autoProviderFallback: false
|
||||||
|
};
|
||||||
|
const loginsSeen: string[] = [];
|
||||||
|
const accountSignals: AbortSignal[] = [];
|
||||||
|
const outerController = new AbortController();
|
||||||
|
const megaWeb = vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
||||||
|
loginsSeen.push(account?.login || "");
|
||||||
|
if (signal) {
|
||||||
|
accountSignals.push(signal);
|
||||||
|
}
|
||||||
|
if (account?.login === "user1") {
|
||||||
|
await new Promise<void>((_resolve, reject) => {
|
||||||
|
const onAbort = (): void => reject(new Error("aborted:debrid"));
|
||||||
|
if (signal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("account 2 received an aborted signal");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
directUrl: "https://mega-web.example/fresh-account.rar",
|
||||||
|
fileName: "fresh-account.rar",
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||||
|
|
||||||
|
const result = await service.unrestrictLink("https://rapidgator.net/file/queue-timeout-rotation", outerController.signal);
|
||||||
|
|
||||||
|
expect(loginsSeen).toEqual(["user1", "user2"]);
|
||||||
|
expect(accountSignals).toHaveLength(2);
|
||||||
|
expect(accountSignals[0]).not.toBe(accountSignals[1]);
|
||||||
|
expect(accountSignals[0].aborted).toBe(true);
|
||||||
|
expect(accountSignals[1].aborted).toBe(false);
|
||||||
|
expect(outerController.signal.aborted).toBe(false);
|
||||||
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
|
|||||||
import { EventEmitter, once } from "node:events";
|
import { EventEmitter, once } from "node:events";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, getUnrestrictTimeoutMsForProviderPlan, isResumeHardResetReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager";
|
||||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
import { DiskReservationCoordinator } from "../src/main/disk-space";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
@@ -14,13 +14,13 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
|||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
|
||||||
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||||
import type { HistoryEntry, PackageEntry } from "../src/shared/types";
|
import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -532,6 +532,43 @@ describe("disk write recovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resume recovery", () => {
|
||||||
|
it("preserves a partial file for one renewed link and resets after repeated range rejection", () => {
|
||||||
|
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 0)).toBe(false);
|
||||||
|
expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 1)).toBe(true);
|
||||||
|
expect(isResumeHardResetReason("range_mismatch_on_resume:100/0", 1)).toBe(true);
|
||||||
|
expect(isResumeHardResetReason("resume_download_underflow:100/200", 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("budgets a later Mega fallback across every active account without a fifteen minute cap", () => {
|
||||||
|
process.env.RD_UNRESTRICT_TIMEOUT_MS = "60000";
|
||||||
|
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "120000";
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-unrestrict-budget-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const settings: AppSettings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
bestToken: "best-token",
|
||||||
|
megaDebridWebCredentials: Array.from({ length: 8 }, (_, index) => `user-${index}:pass-${index}`).join("\n"),
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
providerOrder: ["megadebrid-web", "bestdebrid"],
|
||||||
|
autoProviderFallback: true,
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract")
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||||
|
manager.addPackages([{ name: "fallback-budget", links: ["https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] }]);
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
const item = snapshot.session.items[snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0]];
|
||||||
|
const providerPlan = (manager as unknown as {
|
||||||
|
getReachableUnrestrictProviderPlan: (candidate: typeof item, preferredLeadProvider: DebridProvider | null) => DebridProvider[];
|
||||||
|
}).getReachableUnrestrictProviderPlan(item, "bestdebrid");
|
||||||
|
|
||||||
|
expect(providerPlan).toEqual(["bestdebrid", "megadebrid-web"]);
|
||||||
|
expect(getUnrestrictTimeoutMsForProviderPlan(settings, providerPlan)).toBe(1_035_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("download start account gate", () => {
|
describe("download start account gate", () => {
|
||||||
it("disables every start path when no usable account is active", async () => {
|
it("disables every start path when no usable account is active", async () => {
|
||||||
const createManager = () => {
|
const createManager = () => {
|
||||||
@@ -760,6 +797,8 @@ async function removeDirWithRetries(dir: string): Promise<void> {
|
|||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
delete process.env.RD_UNRESTRICT_TIMEOUT_MS;
|
||||||
|
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
|
||||||
delete process.env.RD_FFMPEG_BIN;
|
delete process.env.RD_FFMPEG_BIN;
|
||||||
delete process.env.RD_FFPROBE_BIN;
|
delete process.env.RD_FFPROBE_BIN;
|
||||||
resetVideoToolingCache();
|
resetVideoToolingCache();
|
||||||
@@ -861,6 +900,155 @@ describe("download manager", () => {
|
|||||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refreshes an active Mega-Debrid account pool without restarting the application", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-pool-refresh-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const firstId = getMegaDebridAccountId("first-user");
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||||
|
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||||
|
megaDebridWebEnabled: true
|
||||||
|
};
|
||||||
|
const invalidateMegaSession = vi.fn();
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
||||||
|
manager.addPackages([{ name: "pool-refresh", links: ["https://rapidgator.net/file/pool-refresh"] }]);
|
||||||
|
const session = (manager as any).session;
|
||||||
|
const item = Object.values(session.items)[0] as any;
|
||||||
|
item.provider = "megadebrid-web";
|
||||||
|
item.status = "validating";
|
||||||
|
session.running = true;
|
||||||
|
const active = {
|
||||||
|
itemId: item.id,
|
||||||
|
packageId: item.packageId,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
nonResumableCounted: false
|
||||||
|
};
|
||||||
|
(manager as any).activeTasks.set(item.id, active);
|
||||||
|
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||||
|
primeMegaDebridRuntimeCooldownForTests(`${firstId}:web`, 120_000);
|
||||||
|
failures.set("megadebrid-web:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
failures.set("megadebrid-api:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||||
|
|
||||||
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstId] });
|
||||||
|
|
||||||
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(active.abortController.signal.aborted).toBe(true);
|
||||||
|
expect(active.abortReason).toBe("settings_refresh");
|
||||||
|
expect(getMegaDebridAccountCooldownState(`${firstId}:web`)).toBeNull();
|
||||||
|
expect(failures.has("megadebrid-web:rapidgator.net")).toBe(false);
|
||||||
|
expect(failures.has("megadebrid-api:rapidgator.net")).toBe(false);
|
||||||
|
expect(failures.has("realdebrid:rapidgator.net")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues a running Mega-Web download with the next enabled account after a live settings change", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-account-switch-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const payload = Buffer.alloc(256 * 1024, 0x5a);
|
||||||
|
const server = http.createServer((_req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Length": String(payload.length),
|
||||||
|
"Content-Type": "application/octet-stream"
|
||||||
|
});
|
||||||
|
res.end(payload);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Testserver konnte nicht gestartet werden");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/account-two.bin`;
|
||||||
|
const firstAccountId = getMegaDebridAccountId("first-user");
|
||||||
|
const accountCalls: string[] = [];
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
megaCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||||
|
megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass",
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: [],
|
||||||
|
providerPrimary: "megadebrid" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false,
|
||||||
|
enableIntegrityCheck: false,
|
||||||
|
maxParallel: 1
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), {
|
||||||
|
invalidateMegaSession: vi.fn(),
|
||||||
|
megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => {
|
||||||
|
const login = account?.login || "";
|
||||||
|
accountCalls.push(login);
|
||||||
|
if (login === "first-user") {
|
||||||
|
return await new Promise<UnrestrictedLink | null>((_resolve, reject) => {
|
||||||
|
const onAbort = (): void => reject(new Error("aborted:settings-refresh"));
|
||||||
|
if (signal?.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fileName: "account-two.bin",
|
||||||
|
directUrl,
|
||||||
|
fileSize: payload.length,
|
||||||
|
retriesUsed: 0
|
||||||
|
};
|
||||||
|
})
|
||||||
|
});
|
||||||
|
manager.addPackages([{ name: "live-account-switch", links: ["https://rapidgator.net/file/live-account-switch"] }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => accountCalls.includes("first-user"), 10_000);
|
||||||
|
|
||||||
|
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstAccountId] });
|
||||||
|
|
||||||
|
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.status === "completed"), 15_000);
|
||||||
|
expect(accountCalls).toEqual(["first-user", "second-user"]);
|
||||||
|
expect(fs.readFileSync(path.join(root, "downloads", "live-account-switch", "account-two.bin"))).toEqual(payload);
|
||||||
|
} finally {
|
||||||
|
manager.stop();
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("aborts active work immediately when downloads are paused", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pause-active-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const invalidateMegaSession = vi.fn();
|
||||||
|
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
||||||
|
manager.addPackages([{ name: "pause-active", links: ["https://rapidgator.net/file/pause-active"] }]);
|
||||||
|
const session = (manager as any).session;
|
||||||
|
const item = Object.values(session.items)[0] as any;
|
||||||
|
item.status = "validating";
|
||||||
|
session.running = true;
|
||||||
|
const active = {
|
||||||
|
itemId: item.id,
|
||||||
|
packageId: item.packageId,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
nonResumableCounted: false
|
||||||
|
};
|
||||||
|
(manager as any).activeTasks.set(item.id, active);
|
||||||
|
|
||||||
|
expect(manager.togglePause()).toBe(true);
|
||||||
|
|
||||||
|
expect(active.abortController.signal.aborted).toBe(true);
|
||||||
|
expect(active.abortReason).toBe("pause");
|
||||||
|
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
|
it("releases only Mega-Debrid reset parks when a newly usable account appears", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -2584,6 +2772,134 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores a persisted Mega-Web partial and hard-resets after two HTTP 200 range rejections", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const binary = Buffer.alloc(192 * 1024, 37);
|
||||||
|
const partialSize = 64 * 1024;
|
||||||
|
const packageId = "mega-resume-restore-package";
|
||||||
|
const itemId = "mega-resume-restore-item";
|
||||||
|
const outputDir = path.join(root, "downloads", "mega-resume-restore");
|
||||||
|
const targetPath = path.join(outputDir, "mega-resume-restore.mkv");
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
fs.writeFileSync(targetPath, binary.subarray(0, partialSize));
|
||||||
|
const rangeHeaders: string[] = [];
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
rangeHeaders.push(String(req.headers.range || ""));
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
|
res.end(binary);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/mega-resume-restore`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = emptySession();
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "mega-resume-restore",
|
||||||
|
outputDir,
|
||||||
|
extractDir: path.join(root, "extract", "mega-resume-restore"),
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/mega-resume-restore",
|
||||||
|
provider: "megadebrid-web",
|
||||||
|
status: "queued",
|
||||||
|
retries: 1,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: partialSize,
|
||||||
|
totalBytes: binary.length,
|
||||||
|
progressPercent: Math.floor((partialSize / binary.length) * 100),
|
||||||
|
fileName: "mega-resume-restore.mkv",
|
||||||
|
targetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: `range_ignored_on_resume:${partialSize}/${binary.length}`,
|
||||||
|
fullStatus: "Resume-Link erneuern, Retry 1/3",
|
||||||
|
onlineStatus: "online",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
const storagePaths = createStoragePaths(path.join(root, "state"));
|
||||||
|
saveSession(storagePaths, session);
|
||||||
|
const megaWebUnrestrict = vi.fn(async (): Promise<UnrestrictedLink> => ({
|
||||||
|
directUrl,
|
||||||
|
fileName: "mega-resume-restore.mkv",
|
||||||
|
fileSize: binary.length,
|
||||||
|
retriesUsed: 0
|
||||||
|
}));
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
megaDebridWebCredentials: "mega-user:mega-pass",
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
megaDebridPreferApi: false,
|
||||||
|
providerOrder: ["megadebrid-web"],
|
||||||
|
autoProviderFallback: false,
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 3,
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false,
|
||||||
|
enableIntegrityCheck: false,
|
||||||
|
maxParallel: 1
|
||||||
|
},
|
||||||
|
loadSession(storagePaths),
|
||||||
|
storagePaths,
|
||||||
|
{ megaWebUnrestrict }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
||||||
|
status: "queued",
|
||||||
|
downloadedBytes: partialSize,
|
||||||
|
targetPath,
|
||||||
|
onlineStatus: "online"
|
||||||
|
}));
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
|
const restoredItem = manager.getSnapshot().session.items[itemId];
|
||||||
|
expect(restoredItem).toEqual(expect.objectContaining({
|
||||||
|
status: "completed",
|
||||||
|
downloadedBytes: binary.length,
|
||||||
|
onlineStatus: "online"
|
||||||
|
}));
|
||||||
|
expect(megaWebUnrestrict).toHaveBeenCalledTimes(3);
|
||||||
|
expect(rangeHeaders).toEqual([
|
||||||
|
`bytes=${partialSize}-`,
|
||||||
|
`bytes=${partialSize}-`,
|
||||||
|
""
|
||||||
|
]);
|
||||||
|
expect(fs.readFileSync(targetPath)).toEqual(binary);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
|
it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -8323,11 +8639,73 @@ describe("download manager", () => {
|
|||||||
progressPercent: 0,
|
progressPercent: 0,
|
||||||
lastError: "",
|
lastError: "",
|
||||||
fullStatus: "Wartet",
|
fullStatus: "Wartet",
|
||||||
onlineStatus: undefined
|
onlineStatus: "online"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a known online status when resetting a whole package", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-package-online-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "reset-package-online";
|
||||||
|
const itemId = "reset-package-online-item";
|
||||||
|
const createdAt = Date.now();
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "reset-package-online",
|
||||||
|
outputDir: path.join(root, "downloads", "reset-package-online"),
|
||||||
|
extractDir: path.join(root, "extract", "reset-package-online"),
|
||||||
|
status: "failed",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/reset-package-online",
|
||||||
|
provider: "megadebrid-web",
|
||||||
|
status: "failed",
|
||||||
|
retries: 3,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 512,
|
||||||
|
totalBytes: 1_024,
|
||||||
|
progressPercent: 50,
|
||||||
|
fileName: "reset-package-online.bin",
|
||||||
|
targetPath: "",
|
||||||
|
resumable: true,
|
||||||
|
attempts: 3,
|
||||||
|
lastError: "range_ignored_on_resume:512/1024",
|
||||||
|
fullStatus: "Fehler",
|
||||||
|
onlineStatus: "online",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.resetPackage(packageId);
|
||||||
|
|
||||||
|
expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({
|
||||||
|
status: "queued",
|
||||||
|
downloadedBytes: 0,
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
onlineStatus: "online"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ describe("downloads view", () => {
|
|||||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
it("blocks resume without a usable account and keeps lifecycle controls independent from unrelated action busy state", () => {
|
||||||
const pausedToolbar = DownloadsToolbar({
|
const pausedToolbar = DownloadsToolbar({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
|
||||||
@@ -721,6 +721,7 @@ describe("downloads view", () => {
|
|||||||
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
|
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true);
|
||||||
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
|
||||||
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
|
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
|
||||||
|
expect(findButton(busyToolbar, "Stop").props.disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enables package movement only for a visible selected package row", () => {
|
it("enables package movement only for a visible selected package row", () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("mega-web-fallback", () => {
|
|||||||
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>;
|
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>;
|
||||||
sessions: Map<string, { cookie: string; setAt: number }>;
|
sessions: Map<string, { cookie: string; setAt: number }>;
|
||||||
};
|
};
|
||||||
vi.spyOn(internals, "login")
|
const login = vi.spyOn(internals, "login")
|
||||||
.mockResolvedValueOnce("first-stale-cookie")
|
.mockResolvedValueOnce("first-stale-cookie")
|
||||||
.mockResolvedValueOnce("second-stale-cookie");
|
.mockResolvedValueOnce("second-stale-cookie");
|
||||||
vi.spyOn(internals, "generate")
|
vi.spyOn(internals, "generate")
|
||||||
@@ -55,12 +55,58 @@ describe("mega-web-fallback", () => {
|
|||||||
})
|
})
|
||||||
.mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" });
|
.mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" });
|
||||||
|
|
||||||
const result = await fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" });
|
await expect(fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" }))
|
||||||
|
.rejects.toThrow(/aborted/i);
|
||||||
|
|
||||||
expect(result?.directUrl).toBe("https://mega.direct/retry");
|
expect(login).toHaveBeenCalledTimes(1);
|
||||||
expect(internals.sessions.size).toBe(0);
|
expect(internals.sessions.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a fresh session when an invalidated older request finishes later", async () => {
|
||||||
|
let releaseOldGenerate: () => void = () => {};
|
||||||
|
let markOldGenerateStarted: () => void = () => {};
|
||||||
|
const oldGenerateGate = new Promise<void>((resolve) => {
|
||||||
|
releaseOldGenerate = resolve;
|
||||||
|
});
|
||||||
|
const oldGenerateStarted = new Promise<void>((resolve) => {
|
||||||
|
markOldGenerateStarted = resolve;
|
||||||
|
});
|
||||||
|
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||||
|
const internals = fallback as unknown as {
|
||||||
|
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||||
|
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string } | null>;
|
||||||
|
sessions: Map<string, { cookie: string; setAt: number }>;
|
||||||
|
};
|
||||||
|
const login = vi.spyOn(internals, "login")
|
||||||
|
.mockResolvedValueOnce("old-cookie")
|
||||||
|
.mockResolvedValueOnce("fresh-cookie");
|
||||||
|
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||||
|
if (link.includes("old")) {
|
||||||
|
markOldGenerateStarted();
|
||||||
|
await oldGenerateGate;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||||
|
await oldGenerateStarted;
|
||||||
|
fallback.invalidateSession();
|
||||||
|
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh" });
|
||||||
|
expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie");
|
||||||
|
} finally {
|
||||||
|
releaseOldGenerate();
|
||||||
|
}
|
||||||
|
|
||||||
|
await oldSettled;
|
||||||
|
expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie");
|
||||||
|
expect(login).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not cache old credentials from a queued request after invalidation", async () => {
|
it("does not cache old credentials from a queued request after invalidation", async () => {
|
||||||
let releaseFirstLogin: () => void = () => {};
|
let releaseFirstLogin: () => void = () => {};
|
||||||
let markFirstLoginStarted: () => void = () => {};
|
let markFirstLoginStarted: () => void = () => {};
|
||||||
@@ -88,16 +134,162 @@ describe("mega-web-fallback", () => {
|
|||||||
vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" });
|
vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" });
|
||||||
|
|
||||||
const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" });
|
const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" });
|
||||||
|
const firstSettled = first.catch((error: unknown) => error);
|
||||||
await firstLoginStarted;
|
await firstLoginStarted;
|
||||||
const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" });
|
const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" });
|
||||||
|
const queuedSettled = queued.catch((error: unknown) => error);
|
||||||
fallback.invalidateSession();
|
fallback.invalidateSession();
|
||||||
releaseFirstLogin();
|
releaseFirstLogin();
|
||||||
|
|
||||||
await expect(Promise.all([first, queued])).resolves.toHaveLength(2);
|
await expect(firstSettled).resolves.toBeInstanceOf(Error);
|
||||||
expect(loginCount).toBe(2);
|
await expect(queuedSettled).resolves.toBeInstanceOf(Error);
|
||||||
|
expect(loginCount).toBe(1);
|
||||||
expect(internals.sessions.size).toBe(0);
|
expect(internals.sessions.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let a blocked old account job block a new request after session invalidation", async () => {
|
||||||
|
let releaseOldLogin: () => void = () => {};
|
||||||
|
let markOldLoginStarted: () => void = () => {};
|
||||||
|
const oldLoginGate = new Promise<void>((resolve) => {
|
||||||
|
releaseOldLogin = resolve;
|
||||||
|
});
|
||||||
|
const oldLoginStarted = new Promise<void>((resolve) => {
|
||||||
|
markOldLoginStarted = resolve;
|
||||||
|
});
|
||||||
|
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||||
|
const internals = fallback as unknown as {
|
||||||
|
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||||
|
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||||
|
};
|
||||||
|
const login = vi.spyOn(internals, "login")
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
markOldLoginStarted();
|
||||||
|
await oldLoginGate;
|
||||||
|
return "old-cookie";
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce("new-cookie");
|
||||||
|
vi.spyOn(internals, "generate").mockImplementation(async (link) => ({
|
||||||
|
directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old",
|
||||||
|
fileName: link.includes("fresh") ? "fresh.bin" : "old.bin"
|
||||||
|
}));
|
||||||
|
|
||||||
|
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||||
|
await oldLoginStarted;
|
||||||
|
fallback.invalidateSession();
|
||||||
|
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 });
|
||||||
|
await expect(freshRequest).resolves.toMatchObject({
|
||||||
|
directUrl: "https://mega.direct/fresh",
|
||||||
|
fileName: "fresh.bin"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
releaseOldLogin();
|
||||||
|
await Promise.allSettled([oldSettled, freshRequest]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute provider work for a queued request aborted before it starts", async () => {
|
||||||
|
let releaseFirstLogin: () => void = () => {};
|
||||||
|
let markFirstLoginStarted: () => void = () => {};
|
||||||
|
const firstLoginGate = new Promise<void>((resolve) => {
|
||||||
|
releaseFirstLogin = resolve;
|
||||||
|
});
|
||||||
|
const firstLoginStarted = new Promise<void>((resolve) => {
|
||||||
|
markFirstLoginStarted = resolve;
|
||||||
|
});
|
||||||
|
const generatedLinks: string[] = [];
|
||||||
|
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||||
|
const internals = fallback as unknown as {
|
||||||
|
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||||
|
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||||
|
queues: Map<string, Promise<unknown>>;
|
||||||
|
};
|
||||||
|
const login = vi.spyOn(internals, "login").mockImplementation(async () => {
|
||||||
|
markFirstLoginStarted();
|
||||||
|
await firstLoginGate;
|
||||||
|
return "first-cookie";
|
||||||
|
});
|
||||||
|
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||||
|
generatedLinks.push(link);
|
||||||
|
return { directUrl: "https://mega.direct/first", fileName: "first.bin" };
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRequest = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
await firstLoginStarted;
|
||||||
|
const queuedController = new AbortController();
|
||||||
|
const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" });
|
||||||
|
const queueTail = internals.queues.get("same-user");
|
||||||
|
queuedController.abort("cancel-before-start");
|
||||||
|
|
||||||
|
await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i);
|
||||||
|
releaseFirstLogin();
|
||||||
|
await expect(firstRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/first" });
|
||||||
|
await queueTail;
|
||||||
|
|
||||||
|
expect(login).toHaveBeenCalledTimes(1);
|
||||||
|
expect(generatedLinks).toEqual(["https://mega.debrid/first"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a new request normally after invalidating a queue with an aborted waiter", async () => {
|
||||||
|
let releaseOldLogin: () => void = () => {};
|
||||||
|
let markOldLoginStarted: () => void = () => {};
|
||||||
|
const oldLoginGate = new Promise<void>((resolve) => {
|
||||||
|
releaseOldLogin = resolve;
|
||||||
|
});
|
||||||
|
const oldLoginStarted = new Promise<void>((resolve) => {
|
||||||
|
markOldLoginStarted = resolve;
|
||||||
|
});
|
||||||
|
const generatedLinks: string[] = [];
|
||||||
|
const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" }));
|
||||||
|
const internals = fallback as unknown as {
|
||||||
|
login: (login: string, password: string, signal?: AbortSignal) => Promise<string>;
|
||||||
|
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
|
||||||
|
queues: Map<string, Promise<unknown>>;
|
||||||
|
};
|
||||||
|
const login = vi.spyOn(internals, "login")
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
markOldLoginStarted();
|
||||||
|
await oldLoginGate;
|
||||||
|
return "old-cookie";
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce("fresh-cookie");
|
||||||
|
vi.spyOn(internals, "generate").mockImplementation(async (link) => {
|
||||||
|
generatedLinks.push(link);
|
||||||
|
return {
|
||||||
|
directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old",
|
||||||
|
fileName: link.includes("fresh") ? "fresh.bin" : "old.bin"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
const oldSettled = oldRequest.catch((error: unknown) => error);
|
||||||
|
await oldLoginStarted;
|
||||||
|
const queuedController = new AbortController();
|
||||||
|
const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" });
|
||||||
|
const staleQueueTail = internals.queues.get("same-user");
|
||||||
|
queuedController.abort("cancel-before-start");
|
||||||
|
await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i);
|
||||||
|
|
||||||
|
fallback.invalidateSession();
|
||||||
|
const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 });
|
||||||
|
await expect(freshRequest).resolves.toMatchObject({
|
||||||
|
directUrl: "https://mega.direct/fresh",
|
||||||
|
fileName: "fresh.bin"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
releaseOldLogin();
|
||||||
|
await Promise.allSettled([oldSettled, freshRequest, staleQueueTail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(generatedLinks).toEqual(["https://mega.debrid/fresh"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
|
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
|
||||||
let fetchCallCount = 0;
|
let fetchCallCount = 0;
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
|
|||||||
@@ -482,7 +482,7 @@ describe("bandwidth chart palette", () => {
|
|||||||
expect(chartBlock).toContain('role="img"');
|
expect(chartBlock).toContain('role="img"');
|
||||||
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
||||||
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
|
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
|
||||||
expect(chartBlock).toContain("reducedMotion ? 1000 : 250");
|
expect(chartBlock).toContain("reducedMotion ? 1000 : 500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for confirmation before deleting all saved download statistics", () => {
|
it("asks for confirmation before deleting all saved download statistics", () => {
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
import {
|
||||||
|
buildSupportBundle,
|
||||||
|
createSupportBundleExportRunner,
|
||||||
|
writeSupportBundleAtomically
|
||||||
|
} from "../src/main/support-bundle";
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
|
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
shutdownSessionLog();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||||
}
|
}
|
||||||
@@ -32,6 +38,99 @@ function fakeManager(): DownloadManager {
|
|||||||
} as unknown as DownloadManager;
|
} as unknown as DownloadManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populatedFakeManager(): DownloadManager {
|
||||||
|
const snapshot = {
|
||||||
|
stats: {},
|
||||||
|
session: {
|
||||||
|
packages: {
|
||||||
|
"package-1": { id: "package-1", name: "Paket", itemIds: ["item-1"] }
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
"item-1": { id: "item-1", packageId: "package-1", fileName: "datei.bin" }
|
||||||
|
},
|
||||||
|
packageOrder: ["package-1"]
|
||||||
|
},
|
||||||
|
speedText: "",
|
||||||
|
etaText: "",
|
||||||
|
canStart: false,
|
||||||
|
canStop: false,
|
||||||
|
canPause: false
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
|
||||||
|
getItemLogPath: () => { throw new Error("item log getter must not run"); }
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sensitiveActiveManager(itemCount = 1): DownloadManager {
|
||||||
|
const packages = {
|
||||||
|
"package-sensitive": {
|
||||||
|
id: "package-sensitive",
|
||||||
|
name: "Private Collection",
|
||||||
|
outputDir: "C:\\Users\\Alice\\Downloads\\Private Collection",
|
||||||
|
extractDir: "C:\\Users\\Alice\\Extracted\\Private Collection",
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: Array.from({ length: itemCount }, (_, index) => `item-${index}`),
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
cleanedUrls: ["https://files.example.test/archive?api_key=cleaned-secret#private"],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const items = Object.fromEntries(Array.from({ length: itemCount }, (_, index) => [
|
||||||
|
`item-${index}`,
|
||||||
|
{
|
||||||
|
id: `item-${index}`,
|
||||||
|
packageId: "package-sensitive",
|
||||||
|
url: `https://url-user:url-pass@files.example.test/archive-${index}?token=query-secret-${index}#fragment-secret`,
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "downloading",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 1024,
|
||||||
|
downloadedBytes: index,
|
||||||
|
totalBytes: 2048,
|
||||||
|
progressPercent: 50,
|
||||||
|
fileName: `private-${index}.bin`,
|
||||||
|
targetPath: `C:\\Users\\Alice\\Downloads\\Private Collection\\private-${index}.bin`,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "Authorization: Bearer item-bearer-secret",
|
||||||
|
fullStatus: "Cookie: session=item-cookie-secret",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: index + 2
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
const snapshot = {
|
||||||
|
stats: {},
|
||||||
|
session: {
|
||||||
|
version: 1,
|
||||||
|
packageOrder: ["package-sensitive"],
|
||||||
|
packages,
|
||||||
|
items,
|
||||||
|
runStartedAt: 1,
|
||||||
|
totalDownloadedBytes: 10,
|
||||||
|
summaryText: "password=summary-secret",
|
||||||
|
reconnectUntil: 0,
|
||||||
|
reconnectReason: "api_key=reconnect-secret",
|
||||||
|
paused: false,
|
||||||
|
running: true,
|
||||||
|
updatedAt: 2
|
||||||
|
},
|
||||||
|
speedText: "Geschwindigkeit: 1 KB/s",
|
||||||
|
etaText: "ETA: 1m",
|
||||||
|
canStart: false,
|
||||||
|
canStop: true,
|
||||||
|
canPause: true
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
getPackageLogPath: () => { throw new Error("package log getter must not run"); },
|
||||||
|
getItemLogPath: () => { throw new Error("item log getter must not run"); }
|
||||||
|
} as unknown as DownloadManager;
|
||||||
|
}
|
||||||
|
|
||||||
describe("buildSupportBundle (async, non-blocking)", () => {
|
describe("buildSupportBundle (async, non-blocking)", () => {
|
||||||
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
|
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
@@ -71,4 +170,310 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(timerFired).toBe(true);
|
expect(timerFired).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes only recent directory logs without live duplicates or log getter side effects", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const itemLogs = path.join(root, "item-logs");
|
||||||
|
fs.mkdirSync(itemLogs, { recursive: true });
|
||||||
|
const recentLog = path.join(itemLogs, "recent.txt");
|
||||||
|
const oldLog = path.join(itemLogs, "old.txt");
|
||||||
|
fs.writeFileSync(recentLog, "recent", "utf8");
|
||||||
|
fs.writeFileSync(oldLog, "old", "utf8");
|
||||||
|
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(oldLog, oldTimestamp, oldTimestamp);
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(populatedFakeManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||||
|
|
||||||
|
expect(entries).toContain("logs/item-logs/recent.txt");
|
||||||
|
expect(entries).not.toContain("logs/item-logs/old.txt");
|
||||||
|
expect(entries.some((entry) => entry.startsWith("logs/live/"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes each physical session log only once", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
initSessionLog(root);
|
||||||
|
const sessionLogPath = getSessionLogPath();
|
||||||
|
expect(sessionLogPath).not.toBeNull();
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||||
|
const sessionEntries = entries.filter((entry) => (
|
||||||
|
entry === "logs/session.log" || entry.startsWith("logs/session-logs/")
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(sessionEntries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const itemLogs = path.join(root, "item-logs");
|
||||||
|
fs.mkdirSync(itemLogs, { recursive: true });
|
||||||
|
const baseTime = Date.now() - 60_000;
|
||||||
|
for (let index = 0; index < 365; index += 1) {
|
||||||
|
const filePath = path.join(itemLogs, `item-${String(index).padStart(3, "0")}.txt`);
|
||||||
|
fs.writeFileSync(filePath, String(index), "utf8");
|
||||||
|
const timestamp = new Date(baseTime + index);
|
||||||
|
fs.utimesSync(filePath, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||||
|
const itemEntries = entries.filter((entry) => entry.startsWith("logs/item-logs/"));
|
||||||
|
|
||||||
|
expect(itemEntries).toHaveLength(16);
|
||||||
|
expect(itemEntries).not.toContain("logs/item-logs/item-000.txt");
|
||||||
|
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||||
|
megaDebridWebCredentials: "primary-user:primary-password-secret\nsecondary-user:secondary-password-secret",
|
||||||
|
megaDebridWebEnabled: true
|
||||||
|
}), "utf8");
|
||||||
|
const itemLogs = path.join(root, "item-logs");
|
||||||
|
fs.mkdirSync(itemLogs, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [
|
||||||
|
"Authorization: Bearer log-bearer-secret",
|
||||||
|
"Cookie: session=log-cookie-secret; auth=second-cookie-secret",
|
||||||
|
"username=account-secret",
|
||||||
|
"password=log-password-secret",
|
||||||
|
"api_key=log-api-key-secret",
|
||||||
|
"provider rejected secondary-password-secret during rotation",
|
||||||
|
"https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret",
|
||||||
|
"C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin"
|
||||||
|
].join("\n"), "utf8");
|
||||||
|
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({
|
||||||
|
authorization: "Bearer manifest-bearer-secret",
|
||||||
|
supportManifestPath: "C:\\Users\\Alice\\AppData\\debug_support_manifest.json",
|
||||||
|
endpoint: "https://support.example.test/check?api_key=manifest-query-secret#manifest-fragment"
|
||||||
|
}), "utf8");
|
||||||
|
|
||||||
|
const buffer = await buildSupportBundle(sensitiveActiveManager(), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
const zip = new AdmZip(buffer);
|
||||||
|
const archiveText = zip.getEntries()
|
||||||
|
.map((entry) => `${entry.entryName}\n${entry.getData().toString("utf8")}`)
|
||||||
|
.join("\n");
|
||||||
|
const forbidden = [
|
||||||
|
root,
|
||||||
|
"C:\\Users\\Alice",
|
||||||
|
"url-user",
|
||||||
|
"url-pass",
|
||||||
|
"query-secret-0",
|
||||||
|
"fragment-secret",
|
||||||
|
"item-bearer-secret",
|
||||||
|
"item-cookie-secret",
|
||||||
|
"summary-secret",
|
||||||
|
"reconnect-secret",
|
||||||
|
"cleaned-secret",
|
||||||
|
"filename-secret",
|
||||||
|
"log-bearer-secret",
|
||||||
|
"log-cookie-secret",
|
||||||
|
"second-cookie-secret",
|
||||||
|
"account-secret",
|
||||||
|
"log-password-secret",
|
||||||
|
"log-api-key-secret",
|
||||||
|
"primary-password-secret",
|
||||||
|
"secondary-password-secret",
|
||||||
|
"log-query-secret",
|
||||||
|
"log-fragment-secret",
|
||||||
|
"manifest-bearer-secret",
|
||||||
|
"manifest-query-secret",
|
||||||
|
"manifest-fragment"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const secret of forbidden) {
|
||||||
|
expect(archiveText).not.toContain(secret);
|
||||||
|
}
|
||||||
|
expect(archiveText).toContain("<redacted>");
|
||||||
|
expect(archiveText).toContain("<local-path>");
|
||||||
|
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||||
|
items?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(itemOverview.items?.[0]).toMatchObject({ sourceHost: "files.example.test" });
|
||||||
|
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const itemLogs = path.join(root, "item-logs");
|
||||||
|
fs.mkdirSync(itemLogs, { recursive: true });
|
||||||
|
const payload = `${"x".repeat(512 * 1024)}\napi_key=tail-secret`;
|
||||||
|
for (let index = 0; index < 48; index += 1) {
|
||||||
|
fs.writeFileSync(path.join(itemLogs, `active-${String(index).padStart(2, "0")}.log`), payload, "utf8");
|
||||||
|
}
|
||||||
|
const oldRotatedLog = path.join(itemLogs, "rotated.log.old");
|
||||||
|
fs.writeFileSync(oldRotatedLog, "api_key=old-secret", "utf8");
|
||||||
|
const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000);
|
||||||
|
fs.utimesSync(oldRotatedLog, oldTimestamp, oldTimestamp);
|
||||||
|
|
||||||
|
const timerGaps: number[] = [];
|
||||||
|
let lastTick = Date.now();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
timerGaps.push(now - lastTick);
|
||||||
|
lastTick = now;
|
||||||
|
}, 5);
|
||||||
|
const buffer = await buildSupportBundle(sensitiveActiveManager(1_800), root, {
|
||||||
|
hostDiagnosticsMode: "none",
|
||||||
|
debugSetupMode: "deferred"
|
||||||
|
});
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
const zip = new AdmZip(buffer);
|
||||||
|
const logEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
|
||||||
|
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
|
||||||
|
count?: number;
|
||||||
|
included?: number;
|
||||||
|
omitted?: number;
|
||||||
|
items?: unknown[];
|
||||||
|
};
|
||||||
|
const totalUncompressedBytes = zip.getEntries().reduce((sum, entry) => sum + entry.getData().length, 0);
|
||||||
|
|
||||||
|
expect(logEntries.length).toBeLessThanOrEqual(16);
|
||||||
|
expect(Math.max(...logEntries.map((entry) => entry.getData().length))).toBeLessThanOrEqual(256 * 1024);
|
||||||
|
expect(logEntries.some((entry) => entry.entryName.includes("rotated"))).toBe(false);
|
||||||
|
expect(itemOverview.count).toBe(1_800);
|
||||||
|
expect(itemOverview.items?.length).toBeLessThanOrEqual(500);
|
||||||
|
expect(itemOverview.omitted).toBeGreaterThan(0);
|
||||||
|
expect(totalUncompressedBytes).toBeLessThan(8 * 1024 * 1024);
|
||||||
|
expect(timerGaps.length).toBeGreaterThan(2);
|
||||||
|
expect(Math.max(...timerGaps)).toBeLessThan(100);
|
||||||
|
}, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("support bundle export runner", () => {
|
||||||
|
it("returns a visible busy result for reentry without choosing another target", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const target = path.join(root, "support.zip");
|
||||||
|
let chooseCount = 0;
|
||||||
|
let releaseBuild: (buffer: Buffer) => void = () => undefined;
|
||||||
|
let signalBuildStarted: () => void = () => undefined;
|
||||||
|
const buildStarted = new Promise<void>((resolve) => { signalBuildStarted = resolve; });
|
||||||
|
const buildPending = new Promise<Buffer>((resolve) => { releaseBuild = resolve; });
|
||||||
|
const run = createSupportBundleExportRunner({
|
||||||
|
chooseFile: async () => {
|
||||||
|
chooseCount += 1;
|
||||||
|
return target;
|
||||||
|
},
|
||||||
|
build: async () => {
|
||||||
|
signalBuildStarted();
|
||||||
|
return buildPending;
|
||||||
|
},
|
||||||
|
write: async () => undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = run();
|
||||||
|
await buildStarted;
|
||||||
|
const second = await run();
|
||||||
|
|
||||||
|
expect(second).toEqual({
|
||||||
|
saved: false,
|
||||||
|
busy: true,
|
||||||
|
message: "Support-Bundle wird bereits erstellt."
|
||||||
|
});
|
||||||
|
expect(chooseCount).toBe(1);
|
||||||
|
|
||||||
|
releaseBuild(Buffer.from("zip"));
|
||||||
|
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports success only after the target write has completed", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const target = path.join(root, "support.zip");
|
||||||
|
let releaseWrite: () => void = () => undefined;
|
||||||
|
let signalWriteStarted: () => void = () => undefined;
|
||||||
|
let successCount = 0;
|
||||||
|
const writeStarted = new Promise<void>((resolve) => { signalWriteStarted = resolve; });
|
||||||
|
const writePending = new Promise<void>((resolve) => { releaseWrite = resolve; });
|
||||||
|
const run = createSupportBundleExportRunner({
|
||||||
|
chooseFile: async () => target,
|
||||||
|
build: async () => Buffer.from("zip"),
|
||||||
|
write: async () => {
|
||||||
|
signalWriteStarted();
|
||||||
|
await writePending;
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
successCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = run();
|
||||||
|
await writeStarted;
|
||||||
|
expect(successCount).toBe(0);
|
||||||
|
|
||||||
|
releaseWrite();
|
||||||
|
await expect(result).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||||
|
expect(successCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the busy guard after an export failure", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const target = path.join(root, "support.zip");
|
||||||
|
let writeCount = 0;
|
||||||
|
const run = createSupportBundleExportRunner({
|
||||||
|
chooseFile: async () => target,
|
||||||
|
build: async () => Buffer.from("zip"),
|
||||||
|
write: async () => {
|
||||||
|
writeCount += 1;
|
||||||
|
if (writeCount === 1) {
|
||||||
|
throw new Error("write failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(run()).rejects.toThrow("write failed");
|
||||||
|
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the busy guard after the target dialog is canceled", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const target = path.join(root, "support.zip");
|
||||||
|
let chooseCount = 0;
|
||||||
|
const run = createSupportBundleExportRunner({
|
||||||
|
chooseFile: async () => {
|
||||||
|
chooseCount += 1;
|
||||||
|
return chooseCount === 1 ? null : target;
|
||||||
|
},
|
||||||
|
build: async () => Buffer.from("zip"),
|
||||||
|
write: async () => undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(run()).resolves.toEqual({ saved: false, busy: false });
|
||||||
|
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the target atomically without leaving temporary files", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const target = path.join(root, "support.zip");
|
||||||
|
fs.writeFileSync(target, "old", "utf8");
|
||||||
|
|
||||||
|
await writeSupportBundleAtomically(target, Buffer.from("new"));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(target, "utf8")).toBe("new");
|
||||||
|
expect(fs.readdirSync(root)).toEqual(["support.zip"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function createVisualElectronApi(
|
|||||||
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
||||||
return fixture.snapshot.clipboardActive;
|
return fixture.snapshot.clipboardActive;
|
||||||
},
|
},
|
||||||
|
writeClipboardText: async () => true,
|
||||||
pickFolder: async () => "C:\\Visual\\Selected",
|
pickFolder: async () => "C:\\Visual\\Selected",
|
||||||
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
||||||
getSessionStats: async () => ({
|
getSessionStats: async () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user