fix: strengthen live recovery and support diagnostics
Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
This commit is contained in:
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||
|
||||
## [2.0.31] - 2026-08-13
|
||||
|
||||
### Account rotation and lifecycle
|
||||
|
||||
- Applied active account and Debrid-Link key changes to running queues without requiring an application restart.
|
||||
- Isolated every provider attempt with its own cancellation and timeout boundary so a stalled account cannot block the next enabled account.
|
||||
- Preserved explicit Pause and Stop ownership while account settings, resets, or provider operations are changing concurrently.
|
||||
- Prevented late provider failures from surfacing as unhandled process rejections after an operation was already canceled.
|
||||
|
||||
### Download recovery and queue state
|
||||
|
||||
- Persisted bounded HTTP 416 recovery state and clean-restart decisions across application restarts.
|
||||
- Kept locked partial files pending until Windows confirms removal instead of starting a conflicting replacement request.
|
||||
- Reconciled resets with the authoritative queue snapshot and preserved known availability information.
|
||||
- Stabilized package ordering while active items start, finish, reset, or refresh in the background.
|
||||
- Coalesced all running state updates, including forced refreshes, into the shared 500 ms interface cadence.
|
||||
|
||||
### Support diagnostics
|
||||
|
||||
- Correlated account rotation, link conversion, download recovery, disk waiting, Pause, Stop, Reset, and export events with anonymous attempt identifiers.
|
||||
- Added bounded disk-wait entry and resolution history to support bundles.
|
||||
- Recorded support bundle selection, cancellation, build, write, success, and failure phases without storing destination paths or filenames.
|
||||
- Added native clipboard operation diagnostics with byte counts and errors while never recording copied content.
|
||||
- Redacted credentials, account identities, URLs, hostnames, local paths, package names, and filenames at every persistent log boundary and again when building the support archive.
|
||||
|
||||
## [2.0.30] - 2026-08-13
|
||||
|
||||
### Rotation diagnostics
|
||||
|
||||
@@ -79,6 +79,7 @@ The Downloads workspace is optimized for large queues:
|
||||
|
||||
- Manage several accounts or API keys for the same provider.
|
||||
- Enable or disable individual accounts without deleting their saved data.
|
||||
- Apply account changes to active queues without restarting the application and continue with the next usable account when an attempt fails.
|
||||
- Check account status, remaining traffic, username, expiry, and access type.
|
||||
- Configure primary, secondary, and tertiary provider fallback.
|
||||
- Route individual hosters through a specific provider.
|
||||
@@ -193,7 +194,9 @@ tests Unit and integration tests
|
||||
|
||||
Configuration, credentials, queue state, history, and logs are stored locally in Electron's `userData` directory. Secrets are not included in public source or release archives. Provider credentials are only sent to the configured provider endpoints required for account and download operations.
|
||||
|
||||
The application can generate support diagnostics and expose an optional authenticated local debug API. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token.
|
||||
The application can generate a bounded support bundle that correlates account rotation, link conversion, download recovery, disk waiting, queue controls, and export phases with anonymous identifiers. Credentials, copied content, URLs, hostnames, local paths, package names, and filenames are redacted before diagnostic data is persisted and again when the archive is built.
|
||||
|
||||
An optional authenticated local debug API is also available. Remote access is disabled by default. Do not expose diagnostic endpoints publicly without a firewall, VPN, or reverse proxy, and always use a strong unique token.
|
||||
|
||||
## Updates and changelog
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.30",
|
||||
"version": "2.0.31",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.30",
|
||||
"version": "2.0.31",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.30",
|
||||
"version": "2.0.31",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@@ -3,26 +3,43 @@ import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
import { sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
export interface RotationCorrelationContext {
|
||||
attemptId?: string;
|
||||
itemId?: string;
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
export type CorrelatedRotationEvent = RotationEvent & RotationCorrelationContext;
|
||||
export type RotationItemSink = (event: CorrelatedRotationEvent) => void;
|
||||
|
||||
interface RotationItemContext extends RotationCorrelationContext {
|
||||
sink: RotationItemSink;
|
||||
}
|
||||
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemContext>();
|
||||
|
||||
export function runWithRotationItemSink<T>(
|
||||
sink: RotationItemSink,
|
||||
fn: () => Promise<T>,
|
||||
correlation: RotationCorrelationContext = {}
|
||||
): Promise<T> {
|
||||
return rotationItemContext.run({ ...correlation, sink }, fn);
|
||||
}
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
const rotationEventRing: CorrelatedRotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
let rotationEventListener: ((event: CorrelatedRotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
export function setRotationEventListener(listener: ((event: CorrelatedRotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): CorrelatedRotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
@@ -35,9 +52,10 @@ function pushRotationEvent(
|
||||
event: string,
|
||||
fields?: Record<string, unknown>,
|
||||
at = Date.now()
|
||||
): void {
|
||||
): CorrelatedRotationEvent {
|
||||
rotationEventSeq += 1;
|
||||
const entry: RotationEvent = {
|
||||
const context = rotationItemContext.getStore();
|
||||
const entry: CorrelatedRotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
@@ -47,13 +65,15 @@ function pushRotationEvent(
|
||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined,
|
||||
attemptId: context?.attemptId ? sanitizeDiagnosticText(context.attemptId) : undefined,
|
||||
itemId: context?.itemId ? sanitizeDiagnosticText(context.itemId) : undefined,
|
||||
packageId: context?.packageId ? sanitizeDiagnosticText(context.packageId) : undefined
|
||||
};
|
||||
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
if (context) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
context.sink(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -71,6 +91,7 @@ function pushRotationEvent(
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
@@ -83,13 +104,13 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
@@ -162,7 +183,11 @@ export function logAccountRotation(
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
const safeProvider = sanitizeDiagnosticText(provider);
|
||||
const safeAccountLabel = sanitizeDiagnosticAccountLabel(accountLabel);
|
||||
const safeEvent = sanitizeDiagnosticText(event);
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
const entry = pushRotationEvent(level, safeProvider, safeAccountLabel, safeEvent, safeFields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
@@ -171,8 +196,14 @@ export function logAccountRotation(
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
const head = `${logTimestamp()} [${level}] ${safeProvider} | ${safeAccountLabel} | ${safeEvent}`;
|
||||
const logFields = {
|
||||
...safeFields,
|
||||
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
|
||||
...(entry.itemId ? { itemId: entry.itemId } : {}),
|
||||
...(entry.packageId ? { packageId: entry.packageId } : {})
|
||||
};
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(logFields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
+111
-43
@@ -63,6 +63,7 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import type { SupportBundleExportLifecycleEvent } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
@@ -193,16 +194,9 @@ export class AppController {
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
} else {
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
}
|
||||
void this.manager.waitForStartupRecovery().then(() => {
|
||||
this.prepareAutoResume();
|
||||
}).catch((err) => logger.warn(`Auto-Resume Startup-Recovery Fehler: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,18 +228,33 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||
return Boolean(
|
||||
settings.token.trim()
|
||||
|| settings.realDebridUseWebLogin
|
||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||||
|| settings.bestToken.trim()
|
||||
|| settings.bestDebridUseWebLogin
|
||||
|| settings.allDebridUseWebLogin
|
||||
|| settings.allDebridToken.trim()
|
||||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
||||
|| settings.oneFichierApiKey.trim()
|
||||
);
|
||||
private prepareAutoResume(): void {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const items = Object.values(snapshot.session.items);
|
||||
const pendingCount = items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length;
|
||||
if (pendingCount === 0) {
|
||||
this.audit("INFO", "Auto-Resume übersprungen", {
|
||||
reason: "no_pending",
|
||||
itemCount: items.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!snapshot.canStart) {
|
||||
this.audit("WARN", "Auto-Resume übersprungen", {
|
||||
reason: "cannot_start",
|
||||
pendingCount,
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
configuredAccounts: snapshot.accounts.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
return;
|
||||
}
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
|
||||
public get onState(): ((snapshot: UiSnapshot) => void) | null {
|
||||
@@ -709,16 +718,41 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.audit("INFO", "Session-Stopp ausgelöst");
|
||||
const before = this.manager.getSnapshot();
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Session-Stopp angefordert", this.sessionControlFields(before));
|
||||
this.manager.stop();
|
||||
this.audit("INFO", "Session-Stopp angewendet", {
|
||||
...this.sessionControlFields(this.manager.getSnapshot()),
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
}
|
||||
|
||||
public togglePause(): boolean {
|
||||
const before = this.manager.getSnapshot();
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Pause angefordert", this.sessionControlFields(before));
|
||||
const paused = this.manager.togglePause();
|
||||
this.audit("INFO", "Pause umgeschaltet", { paused });
|
||||
this.audit("INFO", "Pause angewendet", {
|
||||
...this.sessionControlFields(this.manager.getSnapshot()),
|
||||
paused,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return paused;
|
||||
}
|
||||
|
||||
private sessionControlFields(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
const items = Object.values(snapshot.session.items);
|
||||
return {
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
packageCount: Object.keys(snapshot.session.packages).length,
|
||||
itemCount: items.length,
|
||||
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length,
|
||||
queuedItemCount: items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length
|
||||
};
|
||||
}
|
||||
|
||||
public retryExtraction(packageId: string): void {
|
||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||||
this.manager.retryExtraction(packageId);
|
||||
@@ -729,9 +763,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
this.manager.extractNow(packageId);
|
||||
}
|
||||
|
||||
public resetPackage(packageId: string): void {
|
||||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
||||
this.manager.resetPackage(packageId);
|
||||
public async resetPackage(packageId: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Paket-Reset angefordert", { packageId });
|
||||
try {
|
||||
await this.manager.resetPackage(packageId);
|
||||
this.audit("INFO", "Paket-Reset abgeschlossen", { packageId, durationMs: Date.now() - startedAt });
|
||||
} catch (error) {
|
||||
this.audit("ERROR", "Paket-Reset fehlgeschlagen", {
|
||||
packageId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public cancelPackage(packageId: string): void {
|
||||
@@ -761,7 +806,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
|
||||
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
||||
this.audit("INFO", "Paket-Auswahl exportiert", {
|
||||
this.audit("INFO", "Paket-Auswahl für Export vorbereitet", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
packageIds
|
||||
@@ -776,7 +821,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
|
||||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
||||
this.audit("INFO", "Item-Auswahl exportiert", {
|
||||
this.audit("INFO", "Item-Auswahl für Export vorbereitet", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
itemIds
|
||||
@@ -870,24 +915,31 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
};
|
||||
}
|
||||
|
||||
public recordSupportBundleExported(filePath: string, bytes: number): void {
|
||||
public recordSupportBundleExportSelected(): void {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const items = Object.values(snapshot.session.items);
|
||||
const fields = {
|
||||
fileName: path.basename(filePath),
|
||||
bytes,
|
||||
phase: "selected",
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
packageCount: Object.keys(snapshot.session.packages).length,
|
||||
itemCount: Object.keys(snapshot.session.items).length
|
||||
itemCount: items.length,
|
||||
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length
|
||||
};
|
||||
this.audit("INFO", "Support-Bundle exportiert", fields);
|
||||
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
|
||||
this.audit("INFO", "Support-Bundle-Ziel ausgewählt", fields);
|
||||
}
|
||||
|
||||
public recordSupportBundleExportFailed(error: unknown): void {
|
||||
const fields = {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
public recordSupportBundleExportLifecycle(event: SupportBundleExportLifecycleEvent): void {
|
||||
const messages: Record<SupportBundleExportLifecycleEvent["phase"], string> = {
|
||||
busy: "Support-Bundle-Export bereits aktiv",
|
||||
cancel: "Support-Bundle-Export abgebrochen",
|
||||
build: "Support-Bundle aufgebaut",
|
||||
write: "Support-Bundle geschrieben",
|
||||
success: "Support-Bundle-Export abgeschlossen",
|
||||
failure: "Support-Bundle-Export fehlgeschlagen"
|
||||
};
|
||||
this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||
const level = event.phase === "failure" ? "ERROR" : event.phase === "busy" ? "WARN" : "INFO";
|
||||
this.audit(level, messages[event.phase], { ...event });
|
||||
}
|
||||
|
||||
public getSupportBundleDefaultFileName(): string {
|
||||
@@ -1113,9 +1165,25 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
this.manager.skipItems(itemIds);
|
||||
}
|
||||
|
||||
public resetItems(itemIds: string[]): void {
|
||||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
||||
this.manager.resetItems(itemIds);
|
||||
public async resetItems(itemIds: string[]): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Item-Reset angefordert", { itemIds, itemCount: itemIds.length });
|
||||
try {
|
||||
await this.manager.resetItems(itemIds);
|
||||
this.audit("INFO", "Item-Reset abgeschlossen", {
|
||||
itemIds,
|
||||
itemCount: itemIds.length,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
} catch (error) {
|
||||
this.audit("ERROR", "Item-Reset fehlgeschlagen", {
|
||||
itemIds,
|
||||
itemCount: itemIds.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public removeHistoryEntry(entryId: string): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -27,10 +28,11 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -93,7 +95,7 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
||||
}
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
`${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { clipboard } from "electron";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { logger } from "./logger";
|
||||
|
||||
type ClipboardIpcEvent = {
|
||||
senderFrame?: { url: string } | null;
|
||||
sender?: { getURL?: () => string };
|
||||
};
|
||||
|
||||
const CLIPBOARD_TEXT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export function writeClipboardTextFromIpc(
|
||||
event: ClipboardIpcEvent,
|
||||
text: unknown,
|
||||
trustedOptions: TrustedIpcOptions
|
||||
): true {
|
||||
assertTrustedIpcSender(event, trustedOptions);
|
||||
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > CLIPBOARD_TEXT_MAX_BYTES) {
|
||||
throw new Error("Ungültiger Zwischenablageinhalt");
|
||||
}
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
try {
|
||||
clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
logger.warn(`Zwischenablage-Schreiben fehlgeschlagen: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`Zwischenablage geschrieben: bytes=${bytes}`);
|
||||
return true;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { formatDiagnosticLink, sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export interface ConversionPhase {
|
||||
atMs: number;
|
||||
@@ -17,7 +18,9 @@ export interface ConversionPhase {
|
||||
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
attemptId?: string;
|
||||
itemId: string;
|
||||
packageId?: string;
|
||||
itemName: string;
|
||||
link: string;
|
||||
providerOrder: string;
|
||||
@@ -27,9 +30,18 @@ export interface ConversionTrace {
|
||||
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
|
||||
function shortLink(link: string): string {
|
||||
const raw = String(link || "").trim();
|
||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function sanitizeConversionText(value: unknown, itemName = ""): string {
|
||||
let safeValue = sanitizeDiagnosticText(value)
|
||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,63}#[a-f0-9]{10}\b/gi, "<redacted-link>");
|
||||
const safeItemName = sanitizeDiagnosticText(itemName).trim();
|
||||
if (safeItemName) {
|
||||
safeValue = safeValue.replace(new RegExp(escapeRegExp(safeItemName), "gi"), "<redacted-item>");
|
||||
}
|
||||
return safeValue;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
@@ -37,7 +49,16 @@ export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||
trace.phases.push({
|
||||
...phase,
|
||||
phase: sanitizeDiagnosticText(phase.phase),
|
||||
provider: phase.provider ? sanitizeDiagnosticText(phase.provider) : undefined,
|
||||
account: phase.account ? sanitizeDiagnosticAccountLabel(phase.account) : undefined,
|
||||
tokenState: phase.tokenState ? sanitizeDiagnosticText(phase.tokenState) : undefined,
|
||||
outcome: phase.outcome ? sanitizeDiagnosticText(phase.outcome) : undefined,
|
||||
detail: phase.detail ? sanitizeDiagnosticText(phase.detail) : undefined,
|
||||
atMs: Date.now() - trace.startedAt
|
||||
});
|
||||
}
|
||||
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
@@ -45,7 +66,9 @@ export function traceConversionNote(key: string, value: string | number): void {
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.notes[key] = value;
|
||||
const safeKey = sanitizeDiagnosticText(key);
|
||||
const safeValue = sanitizeDiagnosticFields({ [key]: value })?.[key];
|
||||
trace.notes[safeKey] = typeof safeValue === "number" ? safeValue : sanitizeDiagnosticText(safeValue);
|
||||
}
|
||||
|
||||
export function hasActiveConversionTrace(): boolean {
|
||||
@@ -58,22 +81,30 @@ export function formatConversionBlock(
|
||||
detail: string,
|
||||
totalMs: number
|
||||
): string {
|
||||
const noteParts = Object.entries(trace.notes)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
const safeNotes = sanitizeDiagnosticFields(trace.notes) || {};
|
||||
const noteParts = Object.entries(safeNotes)
|
||||
.map(([key, value]) => `${sanitizeConversionText(key, trace.itemName)}=${sanitizeConversionText(value, trace.itemName)}`)
|
||||
.join(" ");
|
||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||
+ ` | link=${shortLink(trace.link)}`;
|
||||
const safeOrder = sanitizeConversionText(trace.providerOrder || "?", trace.itemName);
|
||||
const safeOutcome = sanitizeConversionText(outcome, trace.itemName);
|
||||
const safeDetail = sanitizeConversionText(detail, trace.itemName);
|
||||
const correlation = [
|
||||
trace.attemptId ? `attemptId=${sanitizeConversionText(trace.attemptId)}` : "",
|
||||
`itemId=${sanitizeConversionText(trace.itemId)}`,
|
||||
trace.packageId ? `packageId=${sanitizeConversionText(trace.packageId)}` : ""
|
||||
].filter(Boolean).join(" | ");
|
||||
const header = `${logTimestamp()} [CONV] ${correlation} | order=${safeOrder}`
|
||||
+ ` | result=${safeOutcome}${safeDetail ? ` (${safeDetail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||
if (p.account) parts.push(`account=${p.account}`);
|
||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||
if (p.provider) parts.push(`provider=${sanitizeConversionText(p.provider, trace.itemName)}`);
|
||||
if (p.account) parts.push(`account=${sanitizeDiagnosticAccountLabel(p.account)}`);
|
||||
if (p.tokenState) parts.push(`token=${sanitizeConversionText(p.tokenState, trace.itemName)}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
if (p.outcome) parts.push(`outcome=${sanitizeConversionText(p.outcome, trace.itemName)}`);
|
||||
if (p.detail) parts.push(`detail=${sanitizeConversionText(p.detail, trace.itemName)}`);
|
||||
return ` +${p.atMs}ms ${sanitizeConversionText(p.phase, trace.itemName)}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
});
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
@@ -162,15 +193,17 @@ function writeConversionBlock(block: string): void {
|
||||
}
|
||||
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||
meta: { attemptId?: string; itemId: string; packageId?: string; itemName: string; link: string; providerOrder: string },
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
itemId: meta.itemId,
|
||||
itemName: meta.itemName,
|
||||
link: meta.link,
|
||||
providerOrder: meta.providerOrder,
|
||||
attemptId: meta.attemptId ? sanitizeDiagnosticText(meta.attemptId) : undefined,
|
||||
itemId: sanitizeDiagnosticText(meta.itemId),
|
||||
packageId: meta.packageId ? sanitizeDiagnosticText(meta.packageId) : undefined,
|
||||
itemName: sanitizeDiagnosticText(meta.itemName),
|
||||
link: formatDiagnosticLink(meta.link),
|
||||
providerOrder: sanitizeDiagnosticText(meta.providerOrder),
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
@@ -181,7 +214,7 @@ export async function runWithConversionTrace<T>(
|
||||
return result;
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||
detail = sanitizeDiagnosticText(String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160));
|
||||
throw error;
|
||||
} finally {
|
||||
const totalMs = Date.now() - trace.startedAt;
|
||||
|
||||
+85
-31
@@ -8,6 +8,7 @@ import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
import { logAccountRotation } from "./account-rotation-log";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
import { sanitizeDiagnosticText, type DiagnosticRedactions } from "./diagnostic-sanitizer";
|
||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
||||
@@ -26,6 +27,10 @@ const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
|
||||
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||
|
||||
function sanitizeProviderErrorText(error: unknown, redactions: DiagnosticRedactions = {}): string {
|
||||
return sanitizeDiagnosticText(compactErrorText(error).replace(/^Error:\s*/i, ""), redactions);
|
||||
}
|
||||
|
||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
||||
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
||||
@@ -333,6 +338,22 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||
}
|
||||
|
||||
function clearDebridLinkRuntimeStateForKeys(keyIds: Set<string>): void {
|
||||
for (const keyId of keyIds) {
|
||||
debridLinkKeyCooldowns.delete(keyId);
|
||||
debridLinkKeyCooldownDetails.delete(keyId);
|
||||
debridLinkKeyRuntimeStatuses.delete(keyId);
|
||||
}
|
||||
for (const stateKey of [...debridLinkKeyHostCooldowns.keys()]) {
|
||||
const separator = stateKey.indexOf("|");
|
||||
const keyId = separator >= 0 ? stateKey.slice(0, separator) : stateKey;
|
||||
if (keyIds.has(keyId)) {
|
||||
debridLinkKeyHostCooldowns.delete(stateKey);
|
||||
debridLinkKeyHostCooldownDetails.delete(stateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -643,12 +664,16 @@ function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
||||
}
|
||||
|
||||
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
|
||||
const hasDedicatedPoolCredentials = Boolean(
|
||||
String(settings.megaDebridApiCredentials || "").trim()
|
||||
|| String(settings.megaDebridWebCredentials || "").trim()
|
||||
);
|
||||
if (mode === "api") {
|
||||
return settings.megaDebridApiEnabled
|
||||
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||
}
|
||||
return settings.megaDebridWebEnabled
|
||||
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
||||
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
||||
}
|
||||
|
||||
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
|
||||
@@ -768,6 +793,7 @@ function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal):
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
void promise.catch(() => {});
|
||||
return Promise.reject(new Error("aborted:debrid"));
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -1079,9 +1105,10 @@ async function requestDebridLinkPayloadWithKey(
|
||||
const responseText = await response.text();
|
||||
const payload = parseJsonSafe(responseText);
|
||||
if (!payload) {
|
||||
const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
|
||||
const rawDescription = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
|
||||
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
|
||||
: compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
|
||||
const description = sanitizeDiagnosticText(rawDescription, { secretValues: [apiKey.token] });
|
||||
const error = new DebridLinkApiError(
|
||||
response.status,
|
||||
"requestError",
|
||||
@@ -1097,10 +1124,14 @@ async function requestDebridLinkPayloadWithKey(
|
||||
}
|
||||
|
||||
if (!response.ok || !parseDebridLinkSuccess(payload)) {
|
||||
const description = sanitizeDiagnosticText(
|
||||
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
||||
{ secretValues: [apiKey.token] }
|
||||
);
|
||||
const error = new DebridLinkApiError(
|
||||
response.status,
|
||||
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
|
||||
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
||||
description,
|
||||
parseRetryAfterMs(response.headers.get("retry-after")),
|
||||
payload
|
||||
);
|
||||
@@ -1116,9 +1147,9 @@ async function requestDebridLinkPayloadWithKey(
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
throw error;
|
||||
}
|
||||
lastTransportError = compactErrorText(error);
|
||||
lastTransportError = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
|
||||
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
|
||||
throw error;
|
||||
throw new Error(lastTransportError);
|
||||
}
|
||||
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
|
||||
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
|
||||
@@ -1955,7 +1986,11 @@ class MegaDebridClient {
|
||||
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
||||
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() });
|
||||
const detail = sanitizeDiagnosticText(`response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim(), {
|
||||
accountValues: [this.login],
|
||||
secretValues: [this.password]
|
||||
});
|
||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail });
|
||||
return null;
|
||||
}
|
||||
const token = String(payload.token || "").trim();
|
||||
@@ -2002,7 +2037,10 @@ class MegaDebridClient {
|
||||
if (tokenInvalidated) {
|
||||
this.clearTokenCache();
|
||||
}
|
||||
const errorText = String(payload?.response_text || "").trim();
|
||||
const errorText = sanitizeDiagnosticText(String(payload?.response_text || "").trim(), {
|
||||
accountValues: [this.login],
|
||||
secretValues: [this.password, token]
|
||||
});
|
||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
|
||||
if (errorText) {
|
||||
throw new Error(`Mega-Debrid API: ${errorText}`);
|
||||
@@ -2144,8 +2182,8 @@ class MegaDebridClient {
|
||||
const entry = orderedEntries[orderPos];
|
||||
const account = entry.account;
|
||||
const idx = entry.idx;
|
||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
||||
const accountLabel = ` (${account.label}/${totalAccounts})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts}`;
|
||||
|
||||
if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
|
||||
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
|
||||
@@ -2195,7 +2233,7 @@ class MegaDebridClient {
|
||||
: accountAttemptTimeoutSignal;
|
||||
try {
|
||||
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
||||
const result = await client.unrestrictLink(link, accountAttemptSignal);
|
||||
const result = await waitForPromiseWithSignal(client.unrestrictLink(link, accountAttemptSignal), accountAttemptSignal);
|
||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
@@ -2221,9 +2259,10 @@ class MegaDebridClient {
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const redactions = { accountValues: [account.login], secretValues: [account.password] };
|
||||
const abortText = sanitizeProviderErrorText(error, redactions);
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
throw new Error(abortText);
|
||||
}
|
||||
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||
@@ -2278,7 +2317,7 @@ class MegaDebridClient {
|
||||
}
|
||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
}
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error, redactions);
|
||||
traceConversionPhase({
|
||||
phase: "mega-account",
|
||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
||||
@@ -2326,7 +2365,7 @@ class MegaDebridClient {
|
||||
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
|
||||
const nextAcc = orderedEntries[nextPos].account;
|
||||
if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
|
||||
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
|
||||
nextLabel = `${nextAcc.label}/${totalAccounts}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2364,9 +2403,10 @@ class MegaDebridClient {
|
||||
}
|
||||
|
||||
static classifyAccountFailure(
|
||||
error: unknown
|
||||
error: unknown,
|
||||
redactions: DiagnosticRedactions = {}
|
||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const errorText = sanitizeProviderErrorText(error, redactions);
|
||||
|
||||
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
||||
@@ -2909,8 +2949,8 @@ class DebridLinkClient {
|
||||
|
||||
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
||||
const apiKey = this.apiKeys[keyIdx];
|
||||
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
||||
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
||||
const keyLabel = ` (${apiKey.label}/${totalKeys})`;
|
||||
const rotationLabel = `${apiKey.label}/${totalKeys}`;
|
||||
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
||||
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
|
||||
@@ -2978,7 +3018,7 @@ class DebridLinkClient {
|
||||
} catch (error) {
|
||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const abortText = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
|
||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||
if (ranLongEnough) {
|
||||
@@ -3059,7 +3099,7 @@ class DebridLinkClient {
|
||||
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
||||
const nextKey = this.apiKeys[nextIdx];
|
||||
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
|
||||
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
|
||||
nextLabel = `${nextKey.label}/${totalKeys}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -3232,16 +3272,18 @@ class DebridLinkClient {
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const redactions = { secretValues: [apiKey.token] };
|
||||
const errorText = sanitizeProviderErrorText(error, redactions);
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
||||
const description = error.message || code;
|
||||
const safeCode = sanitizeDiagnosticText(code, redactions);
|
||||
const description = sanitizeDiagnosticText(error.message || code, redactions);
|
||||
|
||||
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
|
||||
message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`,
|
||||
message: `ungueltiger oder deaktivierter API-Key (${safeCode}: ${description})`,
|
||||
category: "invalid"
|
||||
};
|
||||
}
|
||||
@@ -3249,7 +3291,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
|
||||
message: `API-Rate-Limit erreicht (${code}: ${description})`,
|
||||
message: `API-Rate-Limit erreicht (${safeCode}: ${description})`,
|
||||
category: "rate_limit"
|
||||
};
|
||||
}
|
||||
@@ -3260,7 +3302,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs,
|
||||
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
|
||||
message: `Quota erreicht fuer ${hosterLabel} (${safeCode}: ${description})`,
|
||||
category: "quota",
|
||||
hostOnly: true,
|
||||
hoster: hosterRaw
|
||||
@@ -3271,7 +3313,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs,
|
||||
message: `Quota erreicht (${code}: ${description})`,
|
||||
message: `Quota erreicht (${safeCode}: ${description})`,
|
||||
category: "quota"
|
||||
};
|
||||
}
|
||||
@@ -3279,7 +3321,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
|
||||
message: `Link kann aktuell nicht generiert werden (${safeCode}: ${description})`,
|
||||
category: "temporary",
|
||||
providerWide: true
|
||||
};
|
||||
@@ -3288,7 +3330,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: 0,
|
||||
message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`,
|
||||
message: `Key kann Link aktuell nicht verarbeiten (${safeCode}: ${description})`,
|
||||
category: "skip"
|
||||
};
|
||||
}
|
||||
@@ -3304,7 +3346,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
message: `temporärer API-Fehler (${code}: ${description})`
|
||||
message: `temporärer API-Fehler (${safeCode}: ${description})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -3748,7 +3790,19 @@ export class DebridService {
|
||||
const prev = this.settings;
|
||||
this.settings = cloneSettings(next);
|
||||
|
||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
|
||||
const previousDebridLinkDisabled = new Set(prev.debridLinkDisabledKeyIds || []);
|
||||
const nextDebridLinkDisabled = new Set(next.debridLinkDisabledKeyIds || []);
|
||||
const changedDebridLinkKeys = new Set<string>();
|
||||
for (const keyId of new Set([...previousDebridLinkDisabled, ...nextDebridLinkDisabled])) {
|
||||
if (previousDebridLinkDisabled.has(keyId) !== nextDebridLinkDisabled.has(keyId)) {
|
||||
changedDebridLinkKeys.add(keyId);
|
||||
}
|
||||
}
|
||||
if (changedDebridLinkKeys.size > 0) {
|
||||
clearDebridLinkRuntimeStateForKeys(changedDebridLinkKeys);
|
||||
}
|
||||
|
||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys || changedDebridLinkKeys.size > 0) {
|
||||
this.cachedDebridLinkClient = null;
|
||||
this.cachedDebridLinkKey = "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export interface DiagnosticRedactions {
|
||||
accountValues?: readonly string[];
|
||||
secretValues?: readonly string[];
|
||||
}
|
||||
|
||||
const REDACTED = "<redacted>";
|
||||
const REDACTED_ACCOUNT = "<redacted-account>";
|
||||
const REDACTED_PATH = "<redacted-path>";
|
||||
const DIAGNOSTIC_LINK_RE = /^[a-z0-9.-]+#[a-f0-9]{10}$/i;
|
||||
const SECRET_FIELD_NAMES = new Set([
|
||||
"password",
|
||||
"passwords",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"token",
|
||||
"tokens",
|
||||
"apitoken",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"apikey",
|
||||
"apikeys",
|
||||
"secret",
|
||||
"clientsecret",
|
||||
"auth",
|
||||
"authorization",
|
||||
"proxyauthorization",
|
||||
"cookie",
|
||||
"cookies",
|
||||
"sessioncookie",
|
||||
"setcookie",
|
||||
"session",
|
||||
"credential",
|
||||
"credentials"
|
||||
]);
|
||||
const ACCOUNT_FIELD_NAMES = new Set(["login", "username", "user", "email", "accountlogin", "accountemail"]);
|
||||
const LINK_FIELD_NAMES = new Set([
|
||||
"link",
|
||||
"url",
|
||||
"sourceurl",
|
||||
"sourcelink",
|
||||
"directurl",
|
||||
"downloadurl",
|
||||
"resumeurl",
|
||||
"redirecturl",
|
||||
"targeturl",
|
||||
"directlink"
|
||||
]);
|
||||
const PATH_FIELD_NAMES = new Set([
|
||||
"path",
|
||||
"filepath",
|
||||
"localpath",
|
||||
"targetpath",
|
||||
"outputpath",
|
||||
"extractpath",
|
||||
"directory",
|
||||
"dir",
|
||||
"outputdir",
|
||||
"extractdir",
|
||||
"downloadpath"
|
||||
]);
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function sensitiveVariants(value: string): string[] {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
const variants = new Set<string>([trimmed]);
|
||||
variants.add(encodeURIComponent(trimmed));
|
||||
const jsonEscaped = JSON.stringify(trimmed).slice(1, -1);
|
||||
if (jsonEscaped) {
|
||||
variants.add(jsonEscaped);
|
||||
}
|
||||
return [...variants].filter(Boolean).sort((left, right) => right.length - left.length);
|
||||
}
|
||||
|
||||
function replaceSensitiveValue(source: string, value: string, replacement: string, caseInsensitive: boolean): string {
|
||||
let output = source;
|
||||
for (const variant of sensitiveVariants(value)) {
|
||||
const escaped = escapeRegExp(variant);
|
||||
const flags = caseInsensitive ? "gi" : "g";
|
||||
if (variant.length >= 4) {
|
||||
output = output.replace(new RegExp(escaped, flags), () => replacement);
|
||||
continue;
|
||||
}
|
||||
output = output.replace(
|
||||
new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, flags),
|
||||
(_match, prefix: string) => `${prefix}${replacement}`
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatDiagnosticLink(value: unknown): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (DIAGNOSTIC_LINK_RE.test(raw)) {
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
let host = "unknown";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
host = parsed.protocol === "file:"
|
||||
? "local"
|
||||
: parsed.hostname.trim().toLowerCase() || "unknown";
|
||||
} catch {
|
||||
}
|
||||
const fingerprint = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 10);
|
||||
return `${host}#${fingerprint}`;
|
||||
}
|
||||
|
||||
export function sanitizeDiagnosticAccountLabel(value: unknown): string {
|
||||
const raw = String(value || "");
|
||||
const match = raw.match(/\b(Account|Key)\s+(\d+)(?:\/(\d+))?/i);
|
||||
if (!match) {
|
||||
const shorthand = raw.match(/(?:^|[^A-Za-z0-9])(\d+)\/(\d+)(?=$|[^A-Za-z0-9])/);
|
||||
const kind = /\bkey\b/i.test(raw) ? "Key" : "Account";
|
||||
return shorthand ? `${kind} ${shorthand[1]}/${shorthand[2]}` : kind;
|
||||
}
|
||||
const kind = match[1].toLowerCase() === "key" ? "Key" : "Account";
|
||||
return `${kind} ${match[2]}${match[3] ? `/${match[3]}` : ""}`;
|
||||
}
|
||||
|
||||
export function sanitizeDiagnosticText(value: unknown, redactions: DiagnosticRedactions = {}): string {
|
||||
let output = String(value ?? "").replace(/\0/g, "");
|
||||
output = output.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, (url) => formatDiagnosticLink(url));
|
||||
output = output.replace(/\b[A-Z]:[\\/][^|,;\r\n"'<>]*/gi, REDACTED_PATH);
|
||||
output = output.replace(/\\\\[^\\/\s"'<>|]+[\\/][^|,;\r\n"'<>]*/g, REDACTED_PATH);
|
||||
output = output.replace(/(?<![A-Za-z0-9.:])\/(?:[^/\s"'<>|]+\/)+[^/\s"'<>|]*/g, REDACTED_PATH);
|
||||
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1");
|
||||
output = output.replace(/\b(Authorization|Proxy-Authorization)\s*[:=]\s*[^\r\n|]+/gi, "$1: <redacted>");
|
||||
output = output.replace(/\b(Cookie|Set-Cookie)\s*[:=]\s*[^\r\n|]+/gi, "$1: <redacted>");
|
||||
output = output.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, "$1 <redacted>");
|
||||
output = output.replace(
|
||||
/(["']?)\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|apikey|secret|client[_-]?secret|auth|session)\b\1(\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;|]+)/gi,
|
||||
"$1$2$1$3<redacted>"
|
||||
);
|
||||
output = output.replace(
|
||||
/(["']?)\b(login|username|user|email)\b\1(\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;|]+)/gi,
|
||||
"$1$2$1$3<redacted-account>"
|
||||
);
|
||||
output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, REDACTED_ACCOUNT);
|
||||
output = output.replace(
|
||||
/(^|[^A-Za-z0-9._%+@-])([A-Za-z0-9._%+@-]*\*+[A-Za-z0-9._%+@-]*)(?=$|[^A-Za-z0-9._%+@-])/g,
|
||||
`$1${REDACTED_ACCOUNT}`
|
||||
);
|
||||
for (const accountValue of redactions.accountValues || []) {
|
||||
output = replaceSensitiveValue(output, accountValue, REDACTED_ACCOUNT, true);
|
||||
}
|
||||
for (const secretValue of redactions.secretValues || []) {
|
||||
output = replaceSensitiveValue(output, secretValue, REDACTED, false);
|
||||
}
|
||||
return output.replace(/\r?\n/g, "\\n");
|
||||
}
|
||||
|
||||
function normalizeFieldName(key: string): string {
|
||||
return key.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function sanitizeDiagnosticFieldValue(
|
||||
key: string,
|
||||
value: unknown,
|
||||
redactions: DiagnosticRedactions,
|
||||
seen: WeakSet<object>
|
||||
): unknown {
|
||||
const normalizedKey = normalizeFieldName(key);
|
||||
if (value === undefined || value === null) {
|
||||
return value;
|
||||
}
|
||||
if (SECRET_FIELD_NAMES.has(normalizedKey)) {
|
||||
return REDACTED;
|
||||
}
|
||||
if (ACCOUNT_FIELD_NAMES.has(normalizedKey)) {
|
||||
return REDACTED_ACCOUNT;
|
||||
}
|
||||
if (LINK_FIELD_NAMES.has(normalizedKey)) {
|
||||
return formatDiagnosticLink(value);
|
||||
}
|
||||
if (PATH_FIELD_NAMES.has(normalizedKey) || normalizedKey.endsWith("path") || normalizedKey.endsWith("dir")) {
|
||||
return REDACTED_PATH;
|
||||
}
|
||||
if (["account", "accountlabel"].includes(normalizedKey)) {
|
||||
return sanitizeDiagnosticAccountLabel(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return sanitizeDiagnosticText(value, redactions);
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return sanitizeDiagnosticText(value, redactions);
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return REDACTED;
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeDiagnosticFieldValue("", entry, redactions, seen));
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return sanitizeDiagnosticText(value, redactions);
|
||||
}
|
||||
return Object.fromEntries(entries.map(([nestedKey, nestedValue]) => [
|
||||
sanitizeDiagnosticText(nestedKey, redactions),
|
||||
sanitizeDiagnosticFieldValue(nestedKey, nestedValue, redactions, seen)
|
||||
]));
|
||||
}
|
||||
|
||||
export function sanitizeDiagnosticFields(
|
||||
fields?: Record<string, unknown>,
|
||||
redactions: DiagnosticRedactions = {}
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!fields) {
|
||||
return undefined;
|
||||
}
|
||||
const seen = new WeakSet<object>();
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
|
||||
sanitizeDiagnosticText(key, redactions),
|
||||
sanitizeDiagnosticFieldValue(key, value, redactions, seen)
|
||||
]));
|
||||
}
|
||||
+375
-104
@@ -440,6 +440,11 @@ function parseContentRange(contentRange: string | null): ParsedContentRange | nu
|
||||
}
|
||||
|
||||
function parseContentRangeTotal(contentRange: string | null): number | null {
|
||||
const unsatisfiedMatch = contentRange?.match(/^bytes\s+\*\/(\d+)$/i);
|
||||
if (unsatisfiedMatch) {
|
||||
const total = Number(unsatisfiedMatch[1]);
|
||||
return Number.isFinite(total) && total > 0 ? total : null;
|
||||
}
|
||||
return parseContentRange(contentRange)?.total ?? null;
|
||||
}
|
||||
|
||||
@@ -871,10 +876,8 @@ function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvid
|
||||
if (provider !== "megadebrid") {
|
||||
return provider;
|
||||
}
|
||||
const apiEnabled = settings.megaDebridApiEnabled
|
||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||
const webEnabled = settings.megaDebridWebEnabled
|
||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
||||
const apiEnabled = settings.megaDebridApiEnabled;
|
||||
const webEnabled = settings.megaDebridWebEnabled;
|
||||
if (apiEnabled && !webEnabled) {
|
||||
return "megadebrid-api";
|
||||
}
|
||||
@@ -1735,10 +1738,13 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
function clearResumeRecoveryState(item: DownloadItem): void {
|
||||
function clearResumeRecoveryState(item: DownloadItem, includeHttp416Budget = true): void {
|
||||
delete item.resumeLinkRenewalFailures;
|
||||
delete item.resumeHardResetUsed;
|
||||
delete item.resumeResetPending;
|
||||
if (includeHttp416Budget) {
|
||||
delete item.http416FreshRestarts;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeResumePartialForReset(targetPath: string): Promise<boolean> {
|
||||
@@ -1934,6 +1940,40 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = [];
|
||||
|
||||
private recordDiskWait(
|
||||
event: DiskCapacityError["event"],
|
||||
context: { itemId?: string; packageId?: string } = {}
|
||||
): void {
|
||||
this.diskWaitEvents.push({
|
||||
...event,
|
||||
...context,
|
||||
at: nowMs(),
|
||||
state: "waiting"
|
||||
});
|
||||
if (this.diskWaitEvents.length > 60) {
|
||||
this.diskWaitEvents.splice(0, this.diskWaitEvents.length - 60);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveDiskWait(ownerId: string, phase: "download" | "extract" | "remux"): void {
|
||||
const pending = [...this.diskWaitEvents]
|
||||
.reverse()
|
||||
.find((event) => event.ownerId === ownerId && event.phase === phase && event.state === "waiting");
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const resolvedAt = nowMs();
|
||||
this.diskWaitEvents.push({
|
||||
...pending,
|
||||
at: resolvedAt,
|
||||
state: "resolved",
|
||||
resolvedAt
|
||||
});
|
||||
if (this.diskWaitEvents.length > 60) {
|
||||
this.diskWaitEvents.splice(0, this.diskWaitEvents.length - 60);
|
||||
}
|
||||
}
|
||||
|
||||
private diskReservations = new DiskReservationCoordinator();
|
||||
|
||||
private diskLeasesByOwner = new Map<string, DiskReservationLease>();
|
||||
@@ -1946,8 +1986,6 @@ export class DownloadManager extends EventEmitter {
|
||||
unrestrictRetries: number;
|
||||
}>();
|
||||
|
||||
private http416FreshRestartByItem = new Map<string, number>();
|
||||
|
||||
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
||||
|
||||
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
||||
@@ -1959,6 +1997,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private onHistoryEntryCallback?: HistoryEntryCallback;
|
||||
|
||||
private startupRecoveryPromise: Promise<number>;
|
||||
|
||||
private pausedForMissingAccount = false;
|
||||
|
||||
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
||||
super();
|
||||
this.settings = settings;
|
||||
@@ -1996,7 +2038,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.restoreTargetPathReservations();
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
this.revalidateCompletedItems();
|
||||
void this.recoverRetryableItems("startup").catch((err) => logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`));
|
||||
this.startupRecoveryPromise = this.recoverRetryableItems("startup").catch((err) => {
|
||||
logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`);
|
||||
return 0;
|
||||
});
|
||||
this.recoverPostProcessingOnStartup();
|
||||
this.checkExistingRapidgatorLinks();
|
||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`));
|
||||
@@ -2005,7 +2050,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.emitState(true);
|
||||
this.emitState();
|
||||
} catch {
|
||||
}
|
||||
});
|
||||
@@ -2013,6 +2058,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private rotationListenerActive = true;
|
||||
|
||||
public waitForStartupRecovery(): Promise<number> {
|
||||
return this.startupRecoveryPromise;
|
||||
}
|
||||
|
||||
public getPackageLogPath(packageId: string): string | null {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (pkg) {
|
||||
@@ -2132,12 +2181,15 @@ export class DownloadManager extends EventEmitter {
|
||||
private logRotationEventForItem(item: DownloadItem, event: RotationEvent): void {
|
||||
this.logItemOnly(item, event.level, "Account-Rotation", {
|
||||
provider: event.provider,
|
||||
account: event.accountLabel,
|
||||
accountLabel: event.accountLabel,
|
||||
event: event.event,
|
||||
reason: event.reason,
|
||||
category: event.category,
|
||||
cooldownSec: event.cooldownSec,
|
||||
next: event.next
|
||||
next: event.next,
|
||||
attemptId: event.attemptId,
|
||||
itemId: event.itemId,
|
||||
packageId: event.packageId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2279,8 +2331,13 @@ export class DownloadManager extends EventEmitter {
|
||||
const previousMegaAccounts = (["api", "web"] as const)
|
||||
.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 previousMegaApiEnabled = previous.megaDebridApiEnabled !== false;
|
||||
const previousMegaWebEnabled = previous.megaDebridWebEnabled !== false;
|
||||
const nextMegaApiEnabled = next.megaDebridApiEnabled !== false;
|
||||
const nextMegaWebEnabled = next.megaDebridWebEnabled !== false;
|
||||
const previousMegaPool = [...previousMegaPoolEntries]
|
||||
.map(([key, password]) => `${key}:${password}`)
|
||||
.concat(`apiEnabled:${previousMegaApiEnabled}`, `webEnabled:${previousMegaWebEnabled}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const nextMegaAccounts = (["api", "web"] as const)
|
||||
@@ -2289,18 +2346,24 @@ export class DownloadManager extends EventEmitter {
|
||||
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
|
||||
const nextMegaPool = [...nextMegaPoolEntries]
|
||||
.map(([key, password]) => `${key}:${password}`)
|
||||
.concat(`apiEnabled:${nextMegaApiEnabled}`, `webEnabled:${nextMegaWebEnabled}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
|
||||
.map((account) => `${account.id}:${account.password}`)
|
||||
.concat(`webEnabled:${previousMegaWebEnabled}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
|
||||
.map((account) => `${account.id}:${account.password}`)
|
||||
.concat(`webEnabled:${nextMegaWebEnabled}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
const megaPoolChanged = previousMegaPool !== nextMegaPool;
|
||||
const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool;
|
||||
const previousDebridLinkPool = `${previous.debridLinkApiKeys || ""}\n${[...(previous.debridLinkDisabledKeyIds || [])].sort().join(",")}`;
|
||||
const nextDebridLinkPool = `${next.debridLinkApiKeys || ""}\n${[...(next.debridLinkDisabledKeyIds || [])].sort().join(",")}`;
|
||||
const debridLinkPoolChanged = previousDebridLinkPool !== nextDebridLinkPool;
|
||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||
const now = nowMs();
|
||||
@@ -2343,7 +2406,7 @@ export class DownloadManager extends EventEmitter {
|
||||
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
||||
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
||||
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
||||
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
|
||||
{ prev: previousDebridLinkPool, next: nextDebridLinkPool, providers: ["debridlink"] },
|
||||
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
|
||||
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
||||
{
|
||||
@@ -2375,6 +2438,16 @@ export class DownloadManager extends EventEmitter {
|
||||
changedAccountKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (previousMegaApiEnabled !== nextMegaApiEnabled) {
|
||||
for (const key of new Set<string>([...previousMegaPoolEntries.keys(), ...nextMegaPoolEntries.keys()])) {
|
||||
if (key.endsWith(":api")) changedAccountKeys.add(key);
|
||||
}
|
||||
}
|
||||
if (previousMegaWebEnabled !== nextMegaWebEnabled) {
|
||||
for (const key of new Set<string>([...previousMegaPoolEntries.keys(), ...nextMegaPoolEntries.keys()])) {
|
||||
if (key.endsWith(":web")) changedAccountKeys.add(key);
|
||||
}
|
||||
}
|
||||
clearMegaDebridAccountRuntimeStates(changedAccountKeys);
|
||||
for (const key of [...this.providerFailures.keys()]) {
|
||||
if (isMegaDebridProviderKey(key)) {
|
||||
@@ -2395,6 +2468,48 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
if (debridLinkPoolChanged) {
|
||||
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 !== "debridlink") {
|
||||
continue;
|
||||
}
|
||||
active.abortReason = "settings_refresh";
|
||||
active.abortController.abort("settings_refresh");
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport && this.session.running) {
|
||||
if (!this.hasUsableDownloadAccount()) {
|
||||
if (!this.session.paused) {
|
||||
logger.warn("Download-Queue pausiert: Kein aktiver Download-Account verfügbar");
|
||||
this.pausedForMissingAccount = true;
|
||||
this.session.paused = true;
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedEventsHead = 0;
|
||||
for (const active of this.activeTasks.values()) {
|
||||
if (active.abortController.signal.aborted) continue;
|
||||
active.abortReason = "pause";
|
||||
active.abortController.abort("pause");
|
||||
}
|
||||
}
|
||||
} else if (this.pausedForMissingAccount) {
|
||||
this.pausedForMissingAccount = false;
|
||||
this.session.paused = false;
|
||||
this.retryAfterByItem.clear();
|
||||
this.providerStartReservations.clear();
|
||||
this.pacedStartReservationByItem.clear();
|
||||
logger.info("Download-Queue fortgesetzt: Aktiver Download-Account wieder verfügbar");
|
||||
void this.ensureScheduler().catch((error) => logger.error(`Scheduler nach Account-Reaktivierung fehlgeschlagen: ${compactErrorText(error)}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport && megaPoolChanged && nextMegaAccounts.length > 0) {
|
||||
this.releaseMegaDebridResetParks();
|
||||
}
|
||||
@@ -2643,7 +2758,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public getStats(now = nowMs()): DownloadStats {
|
||||
const itemCount = this.itemCount;
|
||||
if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 1500) {
|
||||
if (this.statsCache && this.session.running && itemCount >= 500 && now - this.statsCacheAt < 500) {
|
||||
return this.statsCache;
|
||||
}
|
||||
|
||||
@@ -4323,21 +4438,20 @@ export class DownloadManager extends EventEmitter {
|
||||
const sourceName = path.basename(sourcePath);
|
||||
let result: VideoProcessResult | null = null;
|
||||
let remuxLease: DiskReservationLease | null = null;
|
||||
const remuxOwnerId = pkg?.id || sourcePath;
|
||||
try {
|
||||
try {
|
||||
const sourceBytes = (await fs.promises.stat(sourcePath)).size;
|
||||
remuxLease = await this.diskReservations.reserve({
|
||||
phase: "remux",
|
||||
ownerId: pkg?.id || sourcePath,
|
||||
ownerId: remuxOwnerId,
|
||||
targetPath: sourcePath,
|
||||
requiredBytes: sourceBytes
|
||||
});
|
||||
this.resolveDiskWait(remuxOwnerId, "remux");
|
||||
} catch (error) {
|
||||
if (error instanceof DiskCapacityError) {
|
||||
this.diskWaitEvents = [{
|
||||
...error.event,
|
||||
...(pkg ? { packageId: pkg.id } : {})
|
||||
}];
|
||||
this.recordDiskWait(error.event, pkg ? { packageId: pkg.id } : {});
|
||||
result = { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux" };
|
||||
} else {
|
||||
throw error;
|
||||
@@ -5452,20 +5566,44 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!pkg) return;
|
||||
|
||||
const itemIds = [...pkg.itemIds];
|
||||
for (const itemId of itemIds) {
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (!active) continue;
|
||||
active.abortReason = "reset";
|
||||
active.abortController.abort("reset");
|
||||
if (this.session.items[itemId]?.status === "validating") {
|
||||
this.activeTasks.delete(itemId);
|
||||
}
|
||||
}
|
||||
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
|
||||
if (postProcessTasks.length > 0) {
|
||||
await Promise.allSettled(postProcessTasks);
|
||||
}
|
||||
const blockedItemIds: string[] = [];
|
||||
|
||||
for (const itemId of itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "reset";
|
||||
active.abortController.abort("reset");
|
||||
}
|
||||
|
||||
const targetPath = String(item.targetPath || "").trim();
|
||||
if (targetPath && fs.existsSync(targetPath)) {
|
||||
const removed = await removeResumePartialForReset(targetPath);
|
||||
if (!removed) {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.resumeResetPending = true;
|
||||
item.lastError = "Teildatei konnte nicht entfernt werden";
|
||||
item.fullStatus = "Warte auf Teildatei-Freigabe";
|
||||
item.updatedAt = nowMs();
|
||||
blockedItemIds.push(itemId);
|
||||
this.logPackageForItem(item, "WARN", "Paket-Reset wartet auf Teildatei-Freigabe", {
|
||||
targetPath,
|
||||
downloadedBytes: item.downloadedBytes
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (targetPath) {
|
||||
try { fs.rmSync(targetPath, { force: true }); } catch { }
|
||||
this.releaseTargetPath(itemId);
|
||||
}
|
||||
|
||||
@@ -5491,7 +5629,6 @@ export class DownloadManager extends EventEmitter {
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
|
||||
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
|
||||
this.runCompletedPackages.delete(packageId);
|
||||
|
||||
pkg.status = "queued";
|
||||
@@ -5518,7 +5655,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.runPackageIds.add(packageId);
|
||||
}
|
||||
|
||||
await Promise.allSettled(postProcessTasks);
|
||||
if (pkg.outputDir) {
|
||||
await Promise.allSettled([
|
||||
clearExtractResumeState(pkg.outputDir, packageId),
|
||||
@@ -5531,26 +5667,60 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.session.running) {
|
||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetPackage): ${compactErrorText(err)}`));
|
||||
}
|
||||
if (blockedItemIds.length > 0) {
|
||||
throw new Error(`${blockedItemIds.length} Teildatei(en) sind noch gesperrt`);
|
||||
}
|
||||
}
|
||||
|
||||
public async resetItems(itemIds: string[]): Promise<void> {
|
||||
const affectedPackageIds = new Set<string>();
|
||||
const postProcessTasks = new Set<Promise<void>>();
|
||||
for (const itemId of itemIds) {
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (!active) continue;
|
||||
active.abortReason = "reset";
|
||||
active.abortController.abort("reset");
|
||||
if (this.session.items[itemId]?.status === "validating") {
|
||||
this.activeTasks.delete(itemId);
|
||||
}
|
||||
}
|
||||
for (const itemId of itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
for (const task of this.abortPackagePostProcessing(item.packageId, "reset")) postProcessTasks.add(task);
|
||||
}
|
||||
if (postProcessTasks.size > 0) {
|
||||
await Promise.allSettled([...postProcessTasks]);
|
||||
}
|
||||
const blockedItemIds: string[] = [];
|
||||
for (const itemId of itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) continue;
|
||||
|
||||
affectedPackageIds.add(item.packageId);
|
||||
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "reset";
|
||||
active.abortController.abort("reset");
|
||||
}
|
||||
|
||||
const targetPath = String(item.targetPath || "").trim();
|
||||
if (targetPath && fs.existsSync(targetPath)) {
|
||||
const removed = await removeResumePartialForReset(targetPath);
|
||||
if (!removed) {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.resumeResetPending = true;
|
||||
item.lastError = "Teildatei konnte nicht entfernt werden";
|
||||
item.fullStatus = "Warte auf Teildatei-Freigabe";
|
||||
item.updatedAt = nowMs();
|
||||
blockedItemIds.push(itemId);
|
||||
this.logPackageForItem(item, "WARN", "Item-Reset wartet auf Teildatei-Freigabe", {
|
||||
targetPath,
|
||||
downloadedBytes: item.downloadedBytes
|
||||
});
|
||||
if (this.session.running) {
|
||||
this.runItemIds.add(itemId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (targetPath) {
|
||||
try { fs.rmSync(targetPath, { force: true }); } catch { }
|
||||
this.releaseTargetPath(itemId);
|
||||
}
|
||||
|
||||
@@ -5582,7 +5752,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
for (const pkgId of affectedPackageIds) {
|
||||
for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task);
|
||||
this.runCompletedPackages.delete(pkgId);
|
||||
this.historyRecordedPackages.delete(pkgId);
|
||||
this.notifiedPackages.delete(pkgId);
|
||||
@@ -5601,7 +5770,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled([...postProcessTasks]);
|
||||
await Promise.allSettled([...affectedPackageIds].flatMap((pkgId) => {
|
||||
const pkg = this.session.packages[pkgId];
|
||||
return pkg?.outputDir ? [clearExtractResumeState(pkg.outputDir, pkgId)] : [];
|
||||
@@ -5613,6 +5781,9 @@ export class DownloadManager extends EventEmitter {
|
||||
if (this.session.running) {
|
||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetItems): ${compactErrorText(err)}`));
|
||||
}
|
||||
if (blockedItemIds.length > 0) {
|
||||
throw new Error(`${blockedItemIds.length} Teildatei(en) sind noch gesperrt`);
|
||||
}
|
||||
}
|
||||
|
||||
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||||
@@ -5689,6 +5860,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
this.ensureUsableDownloadAccount();
|
||||
this.pausedForMissingAccount = false;
|
||||
const targetSet = new Set(packageIds);
|
||||
|
||||
for (const pkgId of targetSet) {
|
||||
@@ -5784,6 +5956,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
public async startItems(itemIds: string[]): Promise<void> {
|
||||
this.ensureUsableDownloadAccount();
|
||||
this.pausedForMissingAccount = false;
|
||||
const targetSet = new Set(itemIds);
|
||||
|
||||
const affectedPackageIds = new Set<string>();
|
||||
@@ -5893,6 +6066,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
this.ensureUsableDownloadAccount();
|
||||
this.pausedForMissingAccount = false;
|
||||
this.schedulerGeneration += 1;
|
||||
|
||||
this.session.running = true;
|
||||
@@ -5997,7 +6171,6 @@ export class DownloadManager extends EventEmitter {
|
||||
this.providerStartReservations.clear();
|
||||
this.pacedStartReservationByItem.clear();
|
||||
this.retryStateByItem.clear();
|
||||
this.http416FreshRestartByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
@@ -6046,6 +6219,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.schedulerGeneration += 1;
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.pausedForMissingAccount = false;
|
||||
this.session.reconnectUntil = 0;
|
||||
this.session.reconnectReason = "";
|
||||
this.retryAfterByItem.clear();
|
||||
@@ -6121,6 +6295,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.pausedForMissingAccount = false;
|
||||
this.session.reconnectUntil = 0;
|
||||
this.session.reconnectReason = "";
|
||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||
@@ -6544,10 +6719,10 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private emitState(force = false): void {
|
||||
const now = nowMs();
|
||||
const MIN_FORCE_GAP_MS = 120;
|
||||
const minGapMs = this.session.running ? 500 : 120;
|
||||
if (force) {
|
||||
const sinceLastEmit = now - this.lastStateEmitAt;
|
||||
if (sinceLastEmit >= MIN_FORCE_GAP_MS) {
|
||||
if (this.lastStateEmitAt === 0 || sinceLastEmit >= minGapMs) {
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
@@ -6557,29 +6732,19 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
if (this.stateEmitTimer) {
|
||||
clearTimeout(this.stateEmitTimer);
|
||||
this.stateEmitTimer = null;
|
||||
return;
|
||||
}
|
||||
this.stateEmitTimer = setTimeout(() => {
|
||||
this.stateEmitTimer = null;
|
||||
this.lastStateEmitAt = nowMs();
|
||||
this.emit("state", this.getSnapshotForEmit());
|
||||
}, MIN_FORCE_GAP_MS - sinceLastEmit);
|
||||
}, minGapMs - sinceLastEmit);
|
||||
return;
|
||||
}
|
||||
if (this.stateEmitTimer) {
|
||||
return;
|
||||
}
|
||||
const itemCount = this.itemCount;
|
||||
const emitDelay = this.session.running
|
||||
? itemCount >= 1500
|
||||
? 500
|
||||
: itemCount >= 700
|
||||
? 500
|
||||
: itemCount >= 250
|
||||
? 300
|
||||
: 150
|
||||
: 200;
|
||||
const emitDelay = this.session.running ? 500 : 200;
|
||||
this.stateEmitTimer = setTimeout(() => {
|
||||
this.stateEmitTimer = null;
|
||||
this.lastStateEmitAt = nowMs();
|
||||
@@ -8267,11 +8432,11 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-api") {
|
||||
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0;
|
||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-api" || this.settings.megaDebridApiEnabled));
|
||||
return Boolean(hasMegaCreds && this.settings.megaDebridApiEnabled);
|
||||
}
|
||||
if (effectiveProvider === "megadebrid-web") {
|
||||
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "web").length > 0;
|
||||
return Boolean(hasMegaCreds && (resolveMegaDebridProvider(this.settings, "megadebrid") === "megadebrid-web" || this.settings.megaDebridWebEnabled));
|
||||
return Boolean(hasMegaCreds && this.settings.megaDebridWebEnabled);
|
||||
}
|
||||
if (effectiveProvider === "bestdebrid") {
|
||||
return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim());
|
||||
@@ -9103,28 +9268,23 @@ export class DownloadManager extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private scheduleHttp416Retry(
|
||||
private async scheduleHttp416Retry(
|
||||
item: DownloadItem,
|
||||
active: ActiveTask,
|
||||
retryDisplayLimit: string,
|
||||
errorText: string,
|
||||
claimedTargetPath: string
|
||||
): void {
|
||||
): Promise<void> {
|
||||
active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1;
|
||||
item.retries += 1;
|
||||
if (claimedTargetPath) {
|
||||
try {
|
||||
fs.rmSync(claimedTargetPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.releaseTargetPath(item.id);
|
||||
this.dropItemContribution(item.id);
|
||||
item.lastError = errorText;
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
item.speedBps = 0;
|
||||
item.resumeResetPending = true;
|
||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||
if (!resetApplied) {
|
||||
this.queueRetry(item, active, 1000, "Warte auf Teildatei-Freigabe");
|
||||
return;
|
||||
}
|
||||
const delayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
||||
logger.warn(
|
||||
`HTTP 416 erkannt: item=${item.fileName || item.id}, ` +
|
||||
@@ -9133,24 +9293,21 @@ export class DownloadManager extends EventEmitter {
|
||||
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
||||
}
|
||||
|
||||
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
|
||||
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
|
||||
private async escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): Promise<void> {
|
||||
const freshRestarts = Math.max(0, Number(item.http416FreshRestarts || 0));
|
||||
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
|
||||
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
|
||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||
if (resetTargetPath) {
|
||||
try {
|
||||
fs.rmSync(resetTargetPath, { force: true });
|
||||
} catch {
|
||||
item.resumeResetPending = true;
|
||||
item.lastError = errorText;
|
||||
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||
if (!resetApplied) {
|
||||
this.queueRetry(item, active, 1000, "Warte auf Teildatei-Freigabe");
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.releaseTargetPath(item.id);
|
||||
this.dropItemContribution(item.id);
|
||||
item.http416FreshRestarts = freshRestarts + 1;
|
||||
item.retries += 1;
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
item.speedBps = 0;
|
||||
item.lastError = "";
|
||||
active.genericErrorRetries = 0;
|
||||
active.freshRetryUsed = false;
|
||||
@@ -9164,7 +9321,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.http416FreshRestartByItem.delete(item.id);
|
||||
delete item.http416FreshRestarts;
|
||||
item.status = "failed";
|
||||
this.recordRunOutcome(item.id, "failed");
|
||||
item.lastError = errorText;
|
||||
@@ -9241,6 +9398,9 @@ export class DownloadManager extends EventEmitter {
|
||||
void this.processItem(active).catch((err) => {
|
||||
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
||||
}).finally(() => {
|
||||
if (this.activeTasks.get(itemId) !== active) {
|
||||
return;
|
||||
}
|
||||
this.diskLeasesByOwner.get(itemId)?.release();
|
||||
this.diskLeasesByOwner.delete(itemId);
|
||||
if (!this.retryAfterByItem.has(item.id)) {
|
||||
@@ -9366,12 +9526,15 @@ export class DownloadManager extends EventEmitter {
|
||||
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
|
||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||
let unrestricted;
|
||||
const conversionAttemptId = uuidv4();
|
||||
try {
|
||||
unrestricted = await runWithRotationItemSink(
|
||||
(event) => this.logRotationEventForItem(item, event),
|
||||
() => runWithConversionTrace(
|
||||
{
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
attemptId: conversionAttemptId,
|
||||
itemName: item.fileName || item.id,
|
||||
link: item.url,
|
||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
||||
@@ -9392,7 +9555,12 @@ export class DownloadManager extends EventEmitter {
|
||||
throw innerError;
|
||||
}
|
||||
}
|
||||
)
|
||||
),
|
||||
{
|
||||
attemptId: conversionAttemptId,
|
||||
itemId: item.id,
|
||||
packageId: item.packageId
|
||||
}
|
||||
);
|
||||
} catch (unrestrictError) {
|
||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||
@@ -9451,13 +9619,10 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
this.diskLeasesByOwner.get(item.id)?.release();
|
||||
this.diskLeasesByOwner.set(item.id, diskLease);
|
||||
this.resolveDiskWait(item.id, "download");
|
||||
} catch (error) {
|
||||
if (error instanceof DiskCapacityError) {
|
||||
this.diskWaitEvents = [{
|
||||
...error.event,
|
||||
itemId: item.id,
|
||||
packageId: pkg.id
|
||||
}];
|
||||
this.recordDiskWait(error.event, { itemId: item.id, packageId: pkg.id });
|
||||
this.releaseTargetPath(item.id);
|
||||
this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte");
|
||||
this.persistSoon();
|
||||
@@ -9473,14 +9638,14 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`);
|
||||
this.logPackageForItem(item, "INFO", "Link umgewandelt", {
|
||||
conversionAttemptId,
|
||||
provider: unrestricted.provider,
|
||||
providerLabel: unrestricted.providerLabel || "",
|
||||
accountId: unrestricted.sourceAccountId || "",
|
||||
accountLabel: unrestricted.sourceAccountLabel || "",
|
||||
sizeBytes: unrestricted.fileSize,
|
||||
targetPath: item.targetPath,
|
||||
directHost,
|
||||
directUrl: unrestricted.directUrl,
|
||||
directLink: unrestricted.directUrl,
|
||||
resumableHint: unrestricted.retriesUsed >= 0
|
||||
});
|
||||
|
||||
@@ -9644,6 +9809,9 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
const reason = active.abortReason;
|
||||
if (reason === "reset" && this.activeTasks.get(item.id) !== active) {
|
||||
return;
|
||||
}
|
||||
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
|
||||
if (reason === "cancel") {
|
||||
this.logPackageForItem(item, "WARN", "Download abgebrochen durch Entfernen", {
|
||||
@@ -9680,6 +9848,12 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
this.retryStateByItem.delete(item.id);
|
||||
} else if (reason === "pause") {
|
||||
this.logPackageForItem(item, "WARN", "Download pausiert", {
|
||||
reason,
|
||||
downloadedBytes: item.downloadedBytes,
|
||||
totalBytes: item.totalBytes,
|
||||
progressPercent: item.progressPercent
|
||||
});
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = "Pausiert";
|
||||
@@ -9715,6 +9889,12 @@ export class DownloadManager extends EventEmitter {
|
||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||
});
|
||||
} else if (reason === "reset") {
|
||||
this.logPackageForItem(item, "WARN", "Download für Reset beendet", {
|
||||
reason,
|
||||
downloadedBytes: item.downloadedBytes,
|
||||
totalBytes: item.totalBytes,
|
||||
progressPercent: item.progressPercent
|
||||
});
|
||||
this.retryStateByItem.delete(item.id);
|
||||
} else if (reason === "settings_refresh") {
|
||||
item.status = "queued";
|
||||
@@ -9850,12 +10030,12 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
if (isHttp416Text(exhaustedReason)) {
|
||||
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||
await this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||
await this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||
return;
|
||||
}
|
||||
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
||||
@@ -9905,12 +10085,12 @@ export class DownloadManager extends EventEmitter {
|
||||
const isHttp416 = isHttp416Text(errorText);
|
||||
if (isHttp416) {
|
||||
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, errorText, claimedTargetPath);
|
||||
await this.scheduleHttp416Retry(item, active, retryDisplayLimit, errorText, claimedTargetPath);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||
await this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||
return;
|
||||
}
|
||||
if (shouldFreshRetry) {
|
||||
@@ -10381,9 +10561,19 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(effectiveTargetPath, { force: true });
|
||||
} catch {
|
||||
const partialRemoved = await removeResumePartialForReset(effectiveTargetPath);
|
||||
if (!partialRemoved) {
|
||||
item.resumeResetPending = true;
|
||||
item.lastError = "HTTP 416";
|
||||
item.fullStatus = "Warte auf Teildatei-Freigabe";
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
logAttemptEvent("WARN", "HTTP 416 Vollreset wartet auf Teildatei-Freigabe", {
|
||||
attempt,
|
||||
existingBytes,
|
||||
expectedTotal: expectedTotal || null
|
||||
});
|
||||
throw new Error("disk_write_wait:Teildatei-Freigabe ausstehend");
|
||||
}
|
||||
this.dropItemContribution(active.itemId);
|
||||
item.downloadedBytes = 0;
|
||||
@@ -10626,6 +10816,31 @@ export class DownloadManager extends EventEmitter {
|
||||
start: preAllocated ? 0 : undefined,
|
||||
highWaterMark: STREAM_HIGH_WATER_MARK
|
||||
});
|
||||
let streamFailure: Error | null = null;
|
||||
let resolveStreamFailure: ((error: Error) => void) | null = null;
|
||||
const streamFailureSignal = new Promise<Error>((resolve) => {
|
||||
resolveStreamFailure = resolve;
|
||||
});
|
||||
const onStreamFailure = (error: Error): void => {
|
||||
if (streamFailure) {
|
||||
return;
|
||||
}
|
||||
streamFailure = error;
|
||||
resolveStreamFailure?.(error);
|
||||
};
|
||||
const throwStreamFailure = (): void => {
|
||||
if (streamFailure) {
|
||||
throw streamFailure;
|
||||
}
|
||||
};
|
||||
const raceStreamFailure = async <T>(operation: Promise<T>): Promise<T> => {
|
||||
throwStreamFailure();
|
||||
return Promise.race([
|
||||
operation,
|
||||
streamFailureSignal.then((error) => Promise.reject(error))
|
||||
]);
|
||||
};
|
||||
stream.on("error", onStreamFailure);
|
||||
written = writeMode === "a" ? existingBytes : 0;
|
||||
let windowBytes = 0;
|
||||
let windowStarted = nowMs();
|
||||
@@ -10734,6 +10949,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
const alignedFlush = async (final = false): Promise<void> => {
|
||||
if (writeBufPos === 0) return;
|
||||
throwStreamFailure();
|
||||
let toWrite = writeBufPos;
|
||||
if (!final && toWrite > ALLOCATION_UNIT_SIZE) {
|
||||
toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE);
|
||||
@@ -10742,6 +10958,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (!stream.write(slice)) {
|
||||
await waitDrain();
|
||||
}
|
||||
throwStreamFailure();
|
||||
if (toWrite < writeBufPos) {
|
||||
writeBuf.copy(writeBuf, 0, toWrite, writeBufPos);
|
||||
}
|
||||
@@ -10838,7 +11055,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await readWithTimeout();
|
||||
const { done, value } = await raceStreamFailure(readWithTimeout());
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
@@ -10996,6 +11213,7 @@ export class DownloadManager extends EventEmitter {
|
||||
} finally {
|
||||
try {
|
||||
await alignedFlush(true);
|
||||
throwStreamFailure();
|
||||
} catch (flushError) {
|
||||
if (!bodyError) {
|
||||
bodyError = flushError;
|
||||
@@ -11003,6 +11221,10 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (streamFailure) {
|
||||
reject(streamFailure);
|
||||
return;
|
||||
}
|
||||
if (stream.closed || stream.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -11023,18 +11245,47 @@ export class DownloadManager extends EventEmitter {
|
||||
stream.once("error", onError);
|
||||
stream.end();
|
||||
});
|
||||
throwStreamFailure();
|
||||
} catch (streamCloseError) {
|
||||
if (!stream.destroyed) {
|
||||
stream.destroy();
|
||||
}
|
||||
if (!bodyError) {
|
||||
throw streamCloseError;
|
||||
}
|
||||
bodyError = streamCloseError;
|
||||
} else {
|
||||
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
|
||||
}
|
||||
}
|
||||
if (!stream.destroyed) {
|
||||
stream.destroy();
|
||||
}
|
||||
if (streamFailure) {
|
||||
let durableWritten = (writeMode === "a" ? existingBytes : 0) + Math.max(0, Number(stream.bytesWritten || 0));
|
||||
if (preAllocated) {
|
||||
try {
|
||||
await fs.promises.truncate(effectiveTargetPath, durableWritten);
|
||||
} catch {
|
||||
if (await removeResumePartialForReset(effectiveTargetPath)) {
|
||||
durableWritten = 0;
|
||||
} else {
|
||||
item.resumeResetPending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const overcount = Math.max(0, written - durableWritten);
|
||||
if (overcount > 0) {
|
||||
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - overcount);
|
||||
this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes - overcount);
|
||||
this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) - overcount);
|
||||
this.recordProviderDownloadedBytes(item.provider, -overcount, item.providerAccountId);
|
||||
this.itemContributedBytes.set(active.itemId, Math.max(0, (this.itemContributedBytes.get(active.itemId) || 0) - overcount));
|
||||
written = durableWritten;
|
||||
item.downloadedBytes = durableWritten;
|
||||
item.progressPercent = item.totalBytes
|
||||
? Math.max(0, Math.min(99, Math.floor((durableWritten / item.totalBytes) * 100)))
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
if (!bodyError && preAllocated) {
|
||||
try {
|
||||
const syncFd = await fs.promises.open(effectiveTargetPath, "r");
|
||||
@@ -11046,8 +11297,10 @@ export class DownloadManager extends EventEmitter {
|
||||
} catch { }
|
||||
}
|
||||
if (bodyError) {
|
||||
stream.off("error", onStreamFailure);
|
||||
throw bodyError;
|
||||
}
|
||||
stream.off("error", onStreamFailure);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -11345,12 +11598,27 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
||||
this.retryStateByItem.delete(item.id);
|
||||
clearResumeRecoveryState(item);
|
||||
const targetPath = String(item.targetPath || "").trim();
|
||||
if (options.hardReset && targetPath) {
|
||||
let removalErrorCode = "";
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
removalErrorCode = String((error as NodeJS.ErrnoException)?.code || "unknown");
|
||||
}
|
||||
if (fs.existsSync(targetPath)) {
|
||||
item.resumeResetPending = true;
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.attempts = 0;
|
||||
item.resumable = true;
|
||||
item.fullStatus = "Warte auf Teildatei-Freigabe";
|
||||
item.updatedAt = nowMs();
|
||||
this.logPackageForItem(item, "WARN", "Vollreset wartet auf Teildatei-Freigabe", {
|
||||
reason: options.reason,
|
||||
errorCode: removalErrorCode || "still_exists"
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.releaseTargetPath(item.id);
|
||||
item.downloadedBytes = 0;
|
||||
@@ -11359,6 +11627,8 @@ export class DownloadManager extends EventEmitter {
|
||||
this.dropItemContribution(item.id);
|
||||
}
|
||||
|
||||
clearResumeRecoveryState(item, false);
|
||||
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
item.attempts = 0;
|
||||
@@ -12474,9 +12744,10 @@ export class DownloadManager extends EventEmitter {
|
||||
requiredBytes: archiveSizes.every((size) => size === null) ? null : archiveSizes.reduce<number>((total, size) => total + Math.max(0, size || 0), 0)
|
||||
});
|
||||
diskLease.release();
|
||||
this.resolveDiskWait(packageId, "extract");
|
||||
} catch (error) {
|
||||
if (error instanceof DiskCapacityError) {
|
||||
this.diskWaitEvents = [{ ...error.event, packageId }];
|
||||
this.recordDiskWait(error.event, { packageId });
|
||||
const retryAt = error.event.retryAt;
|
||||
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
|
||||
for (const entry of completedItems) {
|
||||
|
||||
+16
-5
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||
@@ -54,10 +55,11 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -165,9 +167,14 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
const headerFields = sanitizeDiagnosticFields({
|
||||
itemId: String(meta.itemId || ""),
|
||||
logKey: normalizedItemId,
|
||||
fileName: meta.fileName
|
||||
}) || {};
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(headerFields.itemId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | fileName=${sanitizeFieldValue(headerFields.fileName)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
@@ -197,7 +204,7 @@ export function logItemEvent(
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||
appendLine(itemId, line);
|
||||
}
|
||||
|
||||
@@ -209,12 +216,16 @@ export function getItemLogPath(itemId: string): string | null {
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
export function flushItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
flushItemLogs();
|
||||
for (const itemId of knownLogPaths.keys()) {
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
|
||||
+30
-18
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
import { sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
@@ -33,7 +34,7 @@ let legacyLogListener: LogListener | null = null;
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let flushInFlight = false;
|
||||
let flushInFlight: Promise<void> | null = null;
|
||||
let exitHookAttached = false;
|
||||
|
||||
export function setLogListener(listener: LogListener | null): void {
|
||||
@@ -71,6 +72,17 @@ export function flushLoggerSync(): void {
|
||||
flushSyncPending();
|
||||
}
|
||||
|
||||
export async function flushLogger(): Promise<void> {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (flushInFlight) {
|
||||
await flushInFlight;
|
||||
}
|
||||
flushLoggerSync();
|
||||
}
|
||||
|
||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
@@ -185,17 +197,7 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
async function performAsyncFlush(): Promise<void> {
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
@@ -216,9 +218,6 @@ async function flushAsync(): Promise<void> {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
@@ -230,13 +229,25 @@ async function flushAsync(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
flushInFlight = null;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
if (flushInFlight) {
|
||||
return flushInFlight;
|
||||
}
|
||||
if (pendingLines.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const operation = performAsyncFlush();
|
||||
flushInFlight = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
function ensureExitHook(): void {
|
||||
if (exitHookAttached) {
|
||||
return;
|
||||
@@ -249,14 +260,15 @@ function ensureExitHook(): void {
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
const safeMessage = sanitizeDiagnosticText(message);
|
||||
const line = `${ts} [${level}] ${safeMessage}\n`;
|
||||
pendingLines.push(line);
|
||||
pendingChars += line.length;
|
||||
|
||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||
// "what failed recently" is answerable even after the file rotates.
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, message, ts);
|
||||
recordRecentError(level, safeMessage, ts);
|
||||
}
|
||||
|
||||
for (const listener of logListeners) {
|
||||
|
||||
+6
-9
@@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
|
||||
import { writeClipboardTextFromIpc } from "./clipboard-ipc";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -612,13 +613,9 @@ function registerIpcHandlers(): void {
|
||||
updateClipboardWatcher();
|
||||
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;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (event, text: unknown) => (
|
||||
writeClipboardTextFromIpc(event, text, getTrustedIpcOptions())
|
||||
));
|
||||
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||
const options = {
|
||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||
@@ -684,10 +681,10 @@ function registerIpcHandlers(): void {
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
return result.canceled || !result.filePath ? null : result.filePath;
|
||||
},
|
||||
onStart: () => controller.recordSupportBundleExportSelected(),
|
||||
build: async () => (await controller.exportSupportBundle()).buffer,
|
||||
write: writeSupportBundleAtomically,
|
||||
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
|
||||
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
|
||||
onLifecycle: (event) => controller.recordSupportBundleExportLifecycle(event)
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
|
||||
|
||||
+16
-5
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||
@@ -53,10 +54,11 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -164,9 +166,14 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||
initializedThisProcess.add(normalizedPackageId);
|
||||
const startedAt = logTimestamp();
|
||||
const headerFields = sanitizeDiagnosticFields({
|
||||
packageId: String(meta.packageId || ""),
|
||||
logKey: normalizedPackageId,
|
||||
name: meta.name
|
||||
}) || {};
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(headerFields.packageId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | name=${sanitizeFieldValue(headerFields.name)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
@@ -195,7 +202,7 @@ export function logPackageEvent(
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||
appendLine(packageId, line);
|
||||
}
|
||||
|
||||
@@ -207,12 +214,16 @@ export function getPackageLogPath(packageId: string): string | null {
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
export function flushPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
flushPackageLogs();
|
||||
for (const packageId of knownLogPaths.keys()) {
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
|
||||
@@ -101,16 +101,20 @@ export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
export function flushSessionLog(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushSessionLog();
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
|
||||
@@ -788,6 +788,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
|
||||
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
|
||||
resumeResetPending: Boolean(item.resumeResetPending) || undefined,
|
||||
http416FreshRestarts: clampNumber(item.http416FreshRestarts, 0, 0, 100) || undefined,
|
||||
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
||||
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
|
||||
+365
-30
@@ -7,17 +7,25 @@ import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { flushLogger, getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { flushSessionLog, getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { flushTraceLog, getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { flushPackageLogs, getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||
import { flushItemLogs, getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import {
|
||||
getMegaDebridAccountsForMode,
|
||||
getMegaDebridDisabledAccountIdsForMode,
|
||||
maskMegaDebridLogin,
|
||||
type MegaDebridAccountMode
|
||||
} from "../shared/mega-debrid-accounts";
|
||||
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown, type ProviderRuntimeSnapshot } from "./debrid";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
||||
|
||||
@@ -104,13 +112,14 @@ function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>):
|
||||
return String.fromCharCode(0xf8ff - offset);
|
||||
};
|
||||
const urlMarker = findMarker(raw, 0);
|
||||
let output = raw.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, urlMarker);
|
||||
let output = raw.replace(/\b(?:https?|file):(?:\\?\/){2}[^\s"'<>]+/gi, urlMarker);
|
||||
const pathMarker = findMarker(output, 1);
|
||||
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
|
||||
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
|
||||
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker);
|
||||
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((?:Account|Key)\s+\d+(?:\/\d+)?)\s*,\s*[^)\r\n]+(?=\))/gi, "$1, <redacted-account>");
|
||||
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 (<redacted-account>)");
|
||||
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(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>");
|
||||
@@ -151,10 +160,20 @@ function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>
|
||||
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")
|
||||
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>, redactFileName: boolean): string {
|
||||
const parts = zipPath.split("/");
|
||||
return parts
|
||||
.map((part, index) => {
|
||||
const redacted = redactFileName && index === parts.length - 1
|
||||
? redactSupportText(part, sensitiveValues)
|
||||
: part;
|
||||
const extension = path.posix.extname(redacted);
|
||||
const name = extension ? redacted.slice(0, -extension.length) : redacted;
|
||||
const safeName = name.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry";
|
||||
const safeExtension = extension.replace(/[<>:"\\|?*\x00-\x1f]/g, "_");
|
||||
const uniqueness = redacted === part ? "" : `-${randomUUID().slice(0, 12)}`;
|
||||
return `${safeName}${uniqueness}${safeExtension}`;
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
@@ -211,7 +230,8 @@ async function addTextFileIfExists(
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget,
|
||||
maxFileBytes: number,
|
||||
maxAgeMs?: number
|
||||
maxAgeMs?: number,
|
||||
redactArchiveFileName = false
|
||||
): Promise<boolean> {
|
||||
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||
return false;
|
||||
@@ -231,7 +251,7 @@ async function addTextFileIfExists(
|
||||
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
||||
}
|
||||
await yieldToEventLoop();
|
||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
|
||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
||||
includedSourcePaths.add(sourcePathKey);
|
||||
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
|
||||
return true;
|
||||
@@ -250,6 +270,9 @@ async function addRecentDirectoryFiles(
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget
|
||||
): Promise<number> {
|
||||
if (maxFiles <= 0 || budget.remainingBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
|
||||
let directory;
|
||||
try {
|
||||
@@ -292,7 +315,45 @@ async function addRecentDirectoryFiles(
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
budget,
|
||||
MAX_TEXT_FILE_BYTES
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
undefined,
|
||||
true
|
||||
)) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
async function addRelevantLogFiles<T extends { id: string }>(
|
||||
zip: AdmZip,
|
||||
entries: readonly T[],
|
||||
resolveSourcePath: (id: string) => string | null,
|
||||
zipRoot: string,
|
||||
maxFiles: number,
|
||||
includedSourcePaths: Set<string>,
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget
|
||||
): Promise<number> {
|
||||
let added = 0;
|
||||
for (const entry of entries.slice(0, maxFiles)) {
|
||||
if (budget.remainingBytes <= 0) {
|
||||
break;
|
||||
}
|
||||
const sourcePath = resolveSourcePath(entry.id);
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
if (await addTextFileIfExists(
|
||||
zip,
|
||||
sourcePath,
|
||||
path.posix.join(zipRoot, path.basename(sourcePath)),
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
budget,
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
undefined,
|
||||
true
|
||||
)) {
|
||||
added += 1;
|
||||
}
|
||||
@@ -304,12 +365,38 @@ function isActiveStatus(status: unknown): boolean {
|
||||
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
|
||||
}
|
||||
|
||||
function createPackageDto(entry: PackageEntry): Record<string, unknown> {
|
||||
function getBundleAliasExtension(value: string): string {
|
||||
const extension = path.extname(String(value || "")).toLowerCase();
|
||||
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : "";
|
||||
}
|
||||
|
||||
function createBundleAlias(prefix: "package" | "item" | "history", index: number, sourceName = ""): string {
|
||||
const extension = getBundleAliasExtension(sourceName);
|
||||
return `${prefix}-${String(index + 1).padStart(3, "0")}${extension}`;
|
||||
}
|
||||
|
||||
function createPackageDto(
|
||||
entry: PackageEntry,
|
||||
name: string,
|
||||
items: Readonly<Record<string, DownloadItem>>
|
||||
): Record<string, unknown> {
|
||||
const currentItems = entry.itemIds.map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
|
||||
const downloadedBytes = Math.max(0, Number(entry.cleanedDownloadedBytes || 0))
|
||||
+ currentItems.reduce((sum, item) => sum + Math.max(0, Number(item.downloadedBytes || 0)), 0);
|
||||
const currentKnownTotals = currentItems
|
||||
.map((item) => item.totalBytes)
|
||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0);
|
||||
const hasKnownTotal = typeof entry.cleanedTotalBytes === "number" || currentKnownTotals.length > 0;
|
||||
const totalBytes = hasKnownTotal
|
||||
? Math.max(0, Number(entry.cleanedTotalBytes || 0)) + currentKnownTotals.reduce((sum, value) => sum + value, 0)
|
||||
: null;
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
name,
|
||||
status: entry.status,
|
||||
itemCount: entry.itemIds.length,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
cancelled: entry.cancelled,
|
||||
enabled: entry.enabled,
|
||||
priority: entry.priority,
|
||||
@@ -331,7 +418,7 @@ function getSourceHost(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
||||
function createItemDto(entry: DownloadItem, fileName: string): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
packageId: entry.packageId,
|
||||
@@ -345,12 +432,16 @@ function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
||||
downloadedBytes: entry.downloadedBytes,
|
||||
totalBytes: entry.totalBytes,
|
||||
progressPercent: entry.progressPercent,
|
||||
fileName: entry.fileName,
|
||||
fileName,
|
||||
targetPath: entry.targetPath ? "<local-path>" : "",
|
||||
resumable: entry.resumable,
|
||||
attempts: entry.attempts,
|
||||
lastError: entry.lastError,
|
||||
fullStatus: entry.fullStatus,
|
||||
resumeLinkRenewalFailures: entry.resumeLinkRenewalFailures,
|
||||
resumeHardResetUsed: entry.resumeHardResetUsed,
|
||||
resumeResetPending: entry.resumeResetPending,
|
||||
http416FreshRestarts: entry.http416FreshRestarts,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
onlineStatus: entry.onlineStatus
|
||||
@@ -377,10 +468,10 @@ function createSessionDto(session: SessionState): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function createHistoryDto(entry: HistoryEntry): Record<string, unknown> {
|
||||
function createHistoryDto(entry: HistoryEntry, name: string): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount,
|
||||
@@ -403,7 +494,11 @@ async function loadBoundedHistory(filePath: string): Promise<{ total: number | n
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { total: 0, entries: [], omitted: 0 };
|
||||
}
|
||||
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry));
|
||||
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES)
|
||||
.map((entry, index) => createHistoryDto(
|
||||
entry as HistoryEntry,
|
||||
createBundleAlias("history", index, String((entry as HistoryEntry).name || ""))
|
||||
));
|
||||
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
|
||||
} catch {
|
||||
return { total: 0, entries: [], omitted: 0 };
|
||||
@@ -436,20 +531,64 @@ interface SupportBundleExportSuccess {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type SupportBundleExportPhase = "busy" | "cancel" | "build" | "write" | "success" | "failure";
|
||||
|
||||
export interface SupportBundleExportLifecycleEvent {
|
||||
phase: SupportBundleExportPhase;
|
||||
durationMs: number;
|
||||
totalDurationMs: number;
|
||||
bytes?: number;
|
||||
failedPhase?: "choose" | "build" | "write";
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export class SupportBundleExportError extends Error {
|
||||
public readonly phase: "choose" | "build" | "write";
|
||||
public readonly durationMs: number;
|
||||
public readonly code?: string;
|
||||
|
||||
public constructor(phase: "choose" | "build" | "write", durationMs: number, code?: string) {
|
||||
super(`Support-Bundle-Export fehlgeschlagen (${phase}${code ? `, ${code}` : ""}).`);
|
||||
this.name = "SupportBundleExportError";
|
||||
this.phase = phase;
|
||||
this.durationMs = durationMs;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface SupportBundleExportRunnerOptions {
|
||||
chooseFile: () => Promise<string | null>;
|
||||
build: () => Promise<Buffer>;
|
||||
write: (filePath: string, buffer: Buffer) => Promise<void>;
|
||||
now?: () => number;
|
||||
onStart?: (result: { filePath: string }) => Promise<void> | void;
|
||||
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
|
||||
onFailure?: (error: unknown) => Promise<void> | void;
|
||||
onFailure?: (error: SupportBundleExportError) => Promise<void> | void;
|
||||
onLifecycle?: (event: SupportBundleExportLifecycleEvent) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function getExportErrorCode(error: unknown): string | undefined {
|
||||
const code = String((error as NodeJS.ErrnoException | null)?.code || "").trim().toUpperCase();
|
||||
return /^[A-Z0-9_]{1,32}$/.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export function createSupportBundleExportRunner(
|
||||
options: SupportBundleExportRunnerOptions
|
||||
): () => Promise<SupportBundleExportResult> {
|
||||
let active = false;
|
||||
const now = options.now || Date.now;
|
||||
const emitLifecycle = async (event: SupportBundleExportLifecycleEvent): Promise<void> => {
|
||||
if (!options.onLifecycle) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.onLifecycle(event);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
return async () => {
|
||||
if (active) {
|
||||
await emitLifecycle({ phase: "busy", durationMs: 0, totalDurationMs: 0 });
|
||||
return {
|
||||
saved: false,
|
||||
busy: true,
|
||||
@@ -457,28 +596,77 @@ export function createSupportBundleExportRunner(
|
||||
};
|
||||
}
|
||||
active = true;
|
||||
const startedAt = now();
|
||||
let phase: "choose" | "build" | "write" = "choose";
|
||||
let phaseStartedAt = startedAt;
|
||||
try {
|
||||
const filePath = await options.chooseFile();
|
||||
if (!filePath) {
|
||||
const finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "cancel",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt)
|
||||
});
|
||||
return { saved: false, busy: false };
|
||||
}
|
||||
if (options.onStart) {
|
||||
try {
|
||||
await options.onStart({ filePath });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
phase = "build";
|
||||
phaseStartedAt = now();
|
||||
const buffer = await options.build();
|
||||
let finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "build",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
phase = "write";
|
||||
phaseStartedAt = now();
|
||||
await options.write(filePath, buffer);
|
||||
finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "write",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
if (options.onSuccess) {
|
||||
try {
|
||||
await options.onSuccess({ filePath, bytes: buffer.length });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
await emitLifecycle({
|
||||
phase: "success",
|
||||
durationMs: Math.max(0, finishedAt - startedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
return { saved: true, busy: false, filePath };
|
||||
} catch (error) {
|
||||
const failedAt = now();
|
||||
const code = getExportErrorCode(error);
|
||||
const safeError = new SupportBundleExportError(phase, Math.max(0, failedAt - startedAt), code);
|
||||
await emitLifecycle({
|
||||
phase: "failure",
|
||||
failedPhase: phase,
|
||||
durationMs: Math.max(0, failedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, failedAt - startedAt),
|
||||
...(code ? { code } : {})
|
||||
});
|
||||
if (options.onFailure) {
|
||||
try {
|
||||
await options.onFailure(error);
|
||||
await options.onFailure(safeError);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
throw safeError;
|
||||
} finally {
|
||||
active = false;
|
||||
}
|
||||
@@ -545,27 +733,136 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
return getWindowsHostDiagnostics();
|
||||
}
|
||||
|
||||
function createCooldownDto(cooldown: ProviderRuntimeCooldown | null): Record<string, unknown> | null {
|
||||
if (!cooldown) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
category: cooldown.category,
|
||||
remainingMs: Math.max(0, cooldown.remainingMs),
|
||||
untilRestart: cooldown.untilRestart === true
|
||||
};
|
||||
}
|
||||
|
||||
function createMegaDebridPoolRuntime(
|
||||
settings: ReturnType<typeof loadSettings>,
|
||||
runtime: ProviderRuntimeSnapshot,
|
||||
mode: MegaDebridAccountMode
|
||||
): Record<string, unknown> {
|
||||
const accounts = getMegaDebridAccountsForMode(settings, mode);
|
||||
const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode));
|
||||
const enabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled;
|
||||
const runtimeByKey = new Map(runtime.megaDebrid.accounts.map((entry) => [entry.key, entry]));
|
||||
const runtimeAccounts = accounts.flatMap((account, index) => {
|
||||
const state = runtimeByKey.get(`${account.id}:${mode}`);
|
||||
if (!state || (!state.cooldown && state.inFlight <= 0 && state.emptyResponseStreak <= 0)) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
account: `Account ${index + 1}/${accounts.length}`,
|
||||
inFlight: state.inFlight,
|
||||
emptyResponseStreak: state.emptyResponseStreak,
|
||||
cooldown: createCooldownDto(state.cooldown)
|
||||
}];
|
||||
});
|
||||
const configuredKeys = new Set(accounts.map((account) => `${account.id}:${mode}`));
|
||||
return {
|
||||
enabled,
|
||||
configuredCount: accounts.length,
|
||||
activeCount: enabled ? accounts.filter((account) => !disabledIds.has(account.id)).length : 0,
|
||||
disabledCount: accounts.filter((account) => disabledIds.has(account.id)).length,
|
||||
inFlight: accounts.reduce((sum, account) => sum + (runtimeByKey.get(`${account.id}:${mode}`)?.inFlight || 0), 0),
|
||||
accounts: runtimeAccounts,
|
||||
unmappedRuntimeEntryCount: runtime.megaDebrid.accounts
|
||||
.filter((entry) => entry.key.endsWith(`:${mode}`) && !configuredKeys.has(entry.key)).length
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderRuntimeDto(settings: ReturnType<typeof loadSettings>): Record<string, unknown> {
|
||||
const runtime = getProviderRuntimeSnapshot();
|
||||
const debridKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
|
||||
const disabledDebridKeys = new Set(settings.debridLinkDisabledKeyIds || []);
|
||||
const debridRuntimeById = new Map(runtime.debridLink.keys.map((entry) => [entry.keyId, entry]));
|
||||
const configuredDebridIds = new Set(debridKeys.map((entry) => entry.id));
|
||||
const debridRuntimeKeys = debridKeys.flatMap((entry, index) => {
|
||||
const state = debridRuntimeById.get(entry.id);
|
||||
if (!state || (!state.cooldown && !state.runtimeStatus)) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
account: `Key ${index + 1}/${debridKeys.length}`,
|
||||
cooldown: createCooldownDto(state.cooldown),
|
||||
runtimeState: state.runtimeStatus?.state || null,
|
||||
runtimeUpdatedAt: state.runtimeStatus?.updatedAt || null
|
||||
}];
|
||||
});
|
||||
const hostCooldowns = runtime.debridLink.hostCooldowns.flatMap((entry) => {
|
||||
const separator = entry.key.indexOf("|");
|
||||
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
|
||||
const host = separator >= 0 ? entry.key.slice(separator + 1) : "";
|
||||
const index = debridKeys.findIndex((candidate) => candidate.id === keyId);
|
||||
if (index < 0) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
account: `Key ${index + 1}/${debridKeys.length}`,
|
||||
host,
|
||||
cooldown: createCooldownDto(entry.cooldown)
|
||||
}];
|
||||
});
|
||||
return {
|
||||
capturedAtMs: runtime.capturedAtMs,
|
||||
megaDebrid: {
|
||||
rotationCursor: runtime.megaDebrid.rotationCursor,
|
||||
stickyCount: runtime.megaDebrid.stickyCount,
|
||||
pools: {
|
||||
api: createMegaDebridPoolRuntime(settings, runtime, "api"),
|
||||
web: createMegaDebridPoolRuntime(settings, runtime, "web")
|
||||
}
|
||||
},
|
||||
debridLink: {
|
||||
configuredCount: debridKeys.length,
|
||||
activeCount: debridKeys.filter((entry) => !disabledDebridKeys.has(entry.id)).length,
|
||||
disabledCount: debridKeys.filter((entry) => disabledDebridKeys.has(entry.id)).length,
|
||||
keys: debridRuntimeKeys,
|
||||
hostCooldowns,
|
||||
unmappedRuntimeEntryCount: runtime.debridLink.keys.filter((entry) => !configuredDebridIds.has(entry.keyId)).length,
|
||||
unmappedHostCooldownCount: runtime.debridLink.hostCooldowns.filter((entry) => {
|
||||
const separator = entry.key.indexOf("|");
|
||||
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
|
||||
return !configuredDebridIds.has(keyId);
|
||||
}).length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||
const zip = new AdmZip();
|
||||
const includedSourcePaths = new Set<string>();
|
||||
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
|
||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||
const debugSetupMode = options.debugSetupMode || "full";
|
||||
const generatedAt = new Date().toISOString();
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const sensitiveValues = collectSensitiveValues(settings);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageEntries = Object.values(snapshot.session.packages);
|
||||
const itemEntries = Object.values(snapshot.session.items);
|
||||
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
|
||||
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
|
||||
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
||||
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
|
||||
const selectedPackages = selectedPackageEntries
|
||||
.map((entry, index) => createPackageDto(entry, createBundleAlias("package", index, entry.name), snapshot.session.items));
|
||||
const selectedItems = selectedItemEntries
|
||||
.map((entry, index) => createItemDto(entry, createBundleAlias("item", index, entry.fileName)));
|
||||
const history = await loadBoundedHistory(storagePaths.historyFile);
|
||||
const debugSetup = options.debugSetupMode === "deferred"
|
||||
const debugSetup = debugSetupMode === "deferred"
|
||||
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
|
||||
: getDebugSetupCheck(baseDir);
|
||||
|
||||
await addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
runtimeBaseDir: "<local-path>",
|
||||
packageCount: packageEntries.length,
|
||||
itemCount: itemEntries.length,
|
||||
@@ -574,7 +871,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
itemDtos: MAX_ITEM_DTOS,
|
||||
textBytes: MAX_TOTAL_TEXT_BYTES,
|
||||
textFileBytes: MAX_TEXT_FILE_BYTES,
|
||||
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000
|
||||
directoryLogDiscoveryWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000,
|
||||
currentAndRelevantLogsIgnoreAgeFilter: true
|
||||
}
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
|
||||
@@ -589,7 +887,6 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
}
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
|
||||
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
|
||||
await addJson(zip, "overview/history.json", history, sensitiveValues);
|
||||
await addJson(zip, "overview/packages.json", {
|
||||
count: packageEntries.length,
|
||||
@@ -603,6 +900,17 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
omitted: Math.max(0, itemEntries.length - selectedItems.length),
|
||||
items: selectedItems
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/runtime-diagnostics.json", {
|
||||
bundleBuild: {
|
||||
state: "building",
|
||||
startedAt: generatedAt,
|
||||
hostDiagnosticsMode,
|
||||
debugSetupMode
|
||||
},
|
||||
rotationEvents: (snapshot.rotationEvents || []).slice(0, 60),
|
||||
diskWaitEvents: (snapshot.diskWaitEvents || []).slice(-60),
|
||||
providerRuntime: createProviderRuntimeDto(settings)
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
|
||||
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
|
||||
const recentErrors = getRecentErrors().slice(-100);
|
||||
@@ -642,6 +950,33 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
|
||||
|
||||
await flushLogger();
|
||||
flushSessionLog();
|
||||
flushPackageLogs();
|
||||
flushItemLogs();
|
||||
flushTraceLog();
|
||||
|
||||
const relevantPackageLogCount = await addRelevantLogFiles(
|
||||
zip,
|
||||
selectedPackageEntries,
|
||||
getPersistedPackageLogPath,
|
||||
"logs/package-logs",
|
||||
MAX_PACKAGE_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
);
|
||||
const relevantItemLogCount = await addRelevantLogFiles(
|
||||
zip,
|
||||
selectedItemEntries,
|
||||
getPersistedItemLogPath,
|
||||
"logs/item-logs",
|
||||
MAX_ITEM_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
);
|
||||
|
||||
const mainLogPath = getLogFilePath();
|
||||
const auditLogPath = getAuditLogPath();
|
||||
const renameLogPath = getRenameLogPath();
|
||||
@@ -664,8 +999,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
|
||||
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||
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);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_PACKAGE_LOG_FILES - relevantPackageLogCount), includedSourcePaths, sensitiveValues, textBudget);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_ITEM_LOG_FILES - relevantItemLogCount), includedSourcePaths, sensitiveValues, textBudget);
|
||||
|
||||
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
|
||||
if (supportManifest) {
|
||||
|
||||
+17
-11
@@ -3,6 +3,7 @@ import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -33,23 +34,24 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -288,7 +290,15 @@ export function logTraceEvent(
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${sanitizeDiagnosticText(category)}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`);
|
||||
}
|
||||
|
||||
export function flushTraceLog(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownTraceLog(): void {
|
||||
@@ -297,11 +307,7 @@ export function shutdownTraceLog(): void {
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
flushTraceLog();
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
|
||||
+150
-91
@@ -38,7 +38,12 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import {
|
||||
preservePackageOrderForDisplay,
|
||||
reconcileCollapsedPackageState,
|
||||
reconcileOptimisticPackageOrder,
|
||||
sortPackageOrderByName
|
||||
} from "./package-order";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
@@ -869,11 +874,56 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
|
||||
|
||||
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||
|
||||
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
||||
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
||||
if (!running) delay = Math.min(delay, 200);
|
||||
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||
return delay;
|
||||
export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _activeTab: MainView): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ResetUiActionGate {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
interface ResetUiActionOptions {
|
||||
gate: ResetUiActionGate;
|
||||
reset: () => Promise<void>;
|
||||
reconcile: () => Promise<void>;
|
||||
setBusy: (busy: boolean) => void;
|
||||
onError: (error: unknown) => void;
|
||||
onBusy?: () => void;
|
||||
}
|
||||
|
||||
export async function runResetUiAction(options: ResetUiActionOptions): Promise<"completed" | "failed" | "busy"> {
|
||||
if (options.gate.busy) {
|
||||
options.onBusy?.();
|
||||
return "busy";
|
||||
}
|
||||
options.gate.busy = true;
|
||||
options.setBusy(true);
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
await options.reset();
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
await options.reconcile();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failure = error;
|
||||
}
|
||||
failed = true;
|
||||
}
|
||||
try {
|
||||
if (failed) {
|
||||
options.onError(failure);
|
||||
return "failed";
|
||||
}
|
||||
return "completed";
|
||||
} finally {
|
||||
options.gate.busy = false;
|
||||
options.setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
interface SupportBundleExportUiOptions {
|
||||
@@ -1466,18 +1516,6 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "
|
||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
||||
|
||||
function sameStringArray(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatMbpsInputFromKbps(kbps: number): string {
|
||||
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
||||
return String(Number(mbps.toFixed(2)));
|
||||
@@ -1571,9 +1609,49 @@ export function App(): ReactElement {
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const tabRef = useRef(tab);
|
||||
tabRef.current = tab;
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stageAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): UiSnapshot => {
|
||||
masterSnapshotRef.current = fresh;
|
||||
serverPackageOrderRef.current = fresh.session.packageOrder;
|
||||
const order = reconcileOptimisticPackageOrder(
|
||||
fresh.session.packageOrder,
|
||||
pendingPackageOrderRef.current,
|
||||
pendingPackageOrderAtRef.current,
|
||||
Date.now()
|
||||
);
|
||||
pendingPackageOrderRef.current = order.pendingOrder;
|
||||
pendingPackageOrderAtRef.current = order.pendingAt;
|
||||
packageOrderRef.current = order.displayOrder;
|
||||
if (order.displayOrder === fresh.session.packageOrder) {
|
||||
return fresh;
|
||||
}
|
||||
return {
|
||||
...fresh,
|
||||
session: {
|
||||
...fresh.session,
|
||||
packageOrder: order.displayOrder
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const applyAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): void => {
|
||||
if (stateFlushTimerRef.current) {
|
||||
clearTimeout(stateFlushTimerRef.current);
|
||||
stateFlushTimerRef.current = null;
|
||||
}
|
||||
const displaySnapshot = stageAuthoritativeSnapshot(fresh);
|
||||
latestStateRef.current = null;
|
||||
snapshotRef.current = displaySnapshot;
|
||||
setSnapshot(displaySnapshot);
|
||||
}, [stageAuthoritativeSnapshot]);
|
||||
const reconcileAuthoritativeSnapshot = useCallback(async (): Promise<void> => {
|
||||
applyAuthoritativeSnapshot(await window.rd.getSnapshot());
|
||||
}, [applyAuthoritativeSnapshot]);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
@@ -1592,10 +1670,6 @@ export function App(): ReactElement {
|
||||
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||
const [downloadSearch, setDownloadSearch] = useState("");
|
||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||
@@ -1605,6 +1679,7 @@ export function App(): ReactElement {
|
||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
|
||||
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
|
||||
@@ -1612,6 +1687,7 @@ export function App(): ReactElement {
|
||||
const accountToggleRevisionRef = useRef(0);
|
||||
const accountTogglePendingRef = useRef(0);
|
||||
const actionBusyRef = useRef(false);
|
||||
const resetUiActionGateRef = useRef<ResetUiActionGate>({ busy: false });
|
||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
||||
@@ -1734,34 +1810,6 @@ export function App(): ReactElement {
|
||||
activeTabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = snapshot.session.packageOrder;
|
||||
serverPackageOrderRef.current = incoming;
|
||||
|
||||
const pending = pendingPackageOrderRef.current;
|
||||
if (!pending) {
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sameStringArray(pending, incoming)) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxOptimisticHoldMs = 1500;
|
||||
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
packageOrderRef.current = pending;
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||
}, [settingsDraft.speedLimitKbps]);
|
||||
@@ -1805,6 +1853,23 @@ export function App(): ReactElement {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const performReset = useCallback(async (reset: () => Promise<void>): Promise<void> => {
|
||||
if (!resetUiActionGateRef.current.busy) {
|
||||
showToast("Zurücksetzen läuft …", 60_000);
|
||||
}
|
||||
const result = await runResetUiAction({
|
||||
gate: resetUiActionGateRef.current,
|
||||
reset,
|
||||
reconcile: reconcileAuthoritativeSnapshot,
|
||||
setBusy: setResetBusy,
|
||||
onError: (error) => { showToast(`Zurücksetzen fehlgeschlagen: ${String(error)}`, 3200); },
|
||||
onBusy: () => { showToast("Zurücksetzen läuft bereits …", 2200); }
|
||||
});
|
||||
if (result === "completed") {
|
||||
showToast("Zurücksetzen abgeschlossen", 1800);
|
||||
}
|
||||
}, [reconcileAuthoritativeSnapshot, showToast]);
|
||||
|
||||
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
||||
const availableIds = entries.map((entry) => entry.id);
|
||||
const availableSet = new Set(availableIds);
|
||||
@@ -1943,8 +2008,7 @@ export function App(): ReactElement {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
masterSnapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
applyAuthoritativeSnapshot(state);
|
||||
if (state.settings.columnOrder?.length > 0) {
|
||||
setColumnOrder(state.settings.columnOrder);
|
||||
}
|
||||
@@ -1989,8 +2053,7 @@ export function App(): ReactElement {
|
||||
} else {
|
||||
merged = wireState;
|
||||
}
|
||||
masterSnapshotRef.current = merged;
|
||||
latestStateRef.current = merged;
|
||||
latestStateRef.current = stageAuthoritativeSnapshot(merged);
|
||||
if (stateFlushTimerRef.current) { return; }
|
||||
|
||||
const itemCount = Object.keys(merged.session.items).length;
|
||||
@@ -2000,6 +2063,7 @@ export function App(): ReactElement {
|
||||
stateFlushTimerRef.current = null;
|
||||
if (latestStateRef.current) {
|
||||
const next = latestStateRef.current;
|
||||
snapshotRef.current = next;
|
||||
setSnapshot(next);
|
||||
if (next.settings.columnOrder?.length > 0) {
|
||||
setColumnOrder(next.settings.columnOrder);
|
||||
@@ -2055,7 +2119,7 @@ export function App(): ReactElement {
|
||||
if (unsubClipboard) { unsubClipboard(); }
|
||||
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
||||
};
|
||||
}, [clearImportQueueFocusListener]);
|
||||
}, [applyAuthoritativeSnapshot, clearImportQueueFocusListener, stageAuthoritativeSnapshot]);
|
||||
|
||||
const downloadsTabActive = tab === "downloads";
|
||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||
@@ -2093,24 +2157,12 @@ export function App(): ReactElement {
|
||||
if (!downloadsTabActive) {
|
||||
return;
|
||||
}
|
||||
setCollapsedPackages((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, boolean> = { ...prev };
|
||||
const defaultCollapsed = totalPackageCount >= 24;
|
||||
for (const packageId of snapshot.session.packageOrder) {
|
||||
if (!(packageId in prev)) {
|
||||
next[packageId] = defaultCollapsed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const packageId of Object.keys(next)) {
|
||||
if (!snapshot.session.packages[packageId]) {
|
||||
delete next[packageId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setCollapsedPackages((prev) => reconcileCollapsedPackageState(
|
||||
prev,
|
||||
snapshot.session.packageOrder,
|
||||
snapshot.session.packages,
|
||||
totalPackageCount >= 24
|
||||
));
|
||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||
|
||||
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
||||
@@ -3316,7 +3368,11 @@ export function App(): ReactElement {
|
||||
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
|
||||
}
|
||||
|
||||
try {
|
||||
await window.rd.start();
|
||||
} finally {
|
||||
await reconcileAuthoritativeSnapshot().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4686,7 +4742,7 @@ export function App(): ReactElement {
|
||||
canStart: snapshot.canStart,
|
||||
canPause: snapshot.canPause,
|
||||
canStop: snapshot.canStop,
|
||||
actionBusy,
|
||||
actionBusy: actionBusy || resetBusy,
|
||||
reconnectSeconds: snapshot.reconnectSeconds,
|
||||
reconnectReason: snapshot.session.reconnectReason,
|
||||
clipboardWatcher: snapshot.clipboardActive,
|
||||
@@ -4712,7 +4768,7 @@ export function App(): ReactElement {
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, resetBusy, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const downloadsActions: DownloadsViewActions = {
|
||||
onDisplayModeChange: setDownloadDisplayMode,
|
||||
@@ -4732,10 +4788,7 @@ export function App(): ReactElement {
|
||||
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
|
||||
} finally {
|
||||
try {
|
||||
const fresh = await window.rd.getSnapshot();
|
||||
masterSnapshotRef.current = fresh;
|
||||
latestStateRef.current = null;
|
||||
setSnapshot(fresh);
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -4746,11 +4799,11 @@ export function App(): ReactElement {
|
||||
},
|
||||
onPauseDownloads: () => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
||||
void window.rd.togglePause().then((paused) => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
|
||||
void window.rd.togglePause().then(async () => {
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
}).catch(async (error) => {
|
||||
try {
|
||||
setSnapshot(await window.rd.getSnapshot());
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
|
||||
@@ -4758,9 +4811,11 @@ export function App(): ReactElement {
|
||||
},
|
||||
onStopDownloads: () => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
|
||||
void window.rd.stop().catch(async (error) => {
|
||||
void window.rd.stop().then(async () => {
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
}).catch(async (error) => {
|
||||
try {
|
||||
setSnapshot(await window.rd.getSnapshot());
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
|
||||
@@ -4920,7 +4975,7 @@ export function App(): ReactElement {
|
||||
if (failedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
void window.rd.resetItems(failedIds).catch(() => {});
|
||||
void performReset(() => window.rd.resetItems(failedIds));
|
||||
}
|
||||
};
|
||||
const collectorActions: CollectorViewActions = {
|
||||
@@ -6097,17 +6152,21 @@ export function App(): ReactElement {
|
||||
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
||||
)}
|
||||
{hasPackages && !contextMenu.itemId && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
|
||||
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||
void performReset(async () => {
|
||||
for (const id of selectedPackageIds) {
|
||||
await window.rd.resetPackage(id);
|
||||
}
|
||||
});
|
||||
setContextMenu(null);
|
||||
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
||||
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedPackageIds.length})` : ""}`}</button>
|
||||
)}
|
||||
{contextMenu.itemId && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||
void window.rd.resetItems(itemIds).catch(() => {});
|
||||
void performReset(() => window.rd.resetItems(itemIds));
|
||||
setContextMenu(null);
|
||||
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
||||
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedItemIds.length})` : ""}`}</button>
|
||||
)}
|
||||
{hasPackages && !multi && (() => {
|
||||
const pkg = snapshot.session.packages[contextMenu.packageId];
|
||||
|
||||
@@ -9,6 +9,8 @@ export type AccountToggleTarget =
|
||||
export interface AccountToggleSettings {
|
||||
disabledProviders: DebridProvider[];
|
||||
debridLinkDisabledKeyIds: string[];
|
||||
megaDebridApiEnabled: boolean;
|
||||
megaDebridWebEnabled: boolean;
|
||||
megaDebridDisabledAccountIds: string[];
|
||||
megaDebridApiDisabledAccountIds: string[];
|
||||
megaDebridWebDisabledAccountIds: string[];
|
||||
@@ -45,6 +47,8 @@ export function setAccountTargetEnabled<T extends AccountToggleSettings>(
|
||||
: settings.megaDebridWebDisabledAccountIds;
|
||||
return {
|
||||
...settings,
|
||||
megaDebridApiEnabled: target.kind === "mega-api" && enabled ? true : settings.megaDebridApiEnabled,
|
||||
megaDebridWebEnabled: target.kind === "mega-web" && enabled ? true : settings.megaDebridWebEnabled,
|
||||
megaDebridApiDisabledAccountIds: apiDisabled,
|
||||
megaDebridWebDisabledAccountIds: webDisabled,
|
||||
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
|
||||
@@ -55,6 +59,8 @@ export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings
|
||||
return {
|
||||
disabledProviders: settings.disabledProviders,
|
||||
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
|
||||
megaDebridApiEnabled: settings.megaDebridApiEnabled,
|
||||
megaDebridWebEnabled: settings.megaDebridWebEnabled,
|
||||
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
|
||||
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
|
||||
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
|
||||
|
||||
@@ -27,3 +27,78 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
export type OptimisticPackageOrderStatus = "idle" | "pending" | "acknowledged" | "timed-out";
|
||||
|
||||
export interface OptimisticPackageOrderReconciliation {
|
||||
displayOrder: string[];
|
||||
pendingOrder: string[] | null;
|
||||
pendingAt: number;
|
||||
status: OptimisticPackageOrderStatus;
|
||||
}
|
||||
|
||||
function samePackageOrder(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((packageId, index) => packageId === right[index]);
|
||||
}
|
||||
|
||||
export function reconcileOptimisticPackageOrder(
|
||||
authoritativeOrder: string[],
|
||||
pendingOrder: string[] | null,
|
||||
pendingAt: number,
|
||||
now: number,
|
||||
holdMs = 1_500
|
||||
): OptimisticPackageOrderReconciliation {
|
||||
if (!pendingOrder) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "idle"
|
||||
};
|
||||
}
|
||||
if (samePackageOrder(authoritativeOrder, pendingOrder)) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "acknowledged"
|
||||
};
|
||||
}
|
||||
if (now - pendingAt >= holdMs) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "timed-out"
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayOrder: pendingOrder,
|
||||
pendingOrder,
|
||||
pendingAt,
|
||||
status: "pending"
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileCollapsedPackageState(
|
||||
previous: Record<string, boolean>,
|
||||
packageOrder: string[],
|
||||
packages: Record<string, PackageEntry>,
|
||||
defaultCollapsed: boolean
|
||||
): Record<string, boolean> {
|
||||
let changed = false;
|
||||
const next = { ...previous };
|
||||
for (const packageId of packageOrder) {
|
||||
if (!(packageId in previous)) {
|
||||
next[packageId] = defaultCollapsed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const packageId of Object.keys(next)) {
|
||||
if (!packages[packageId]) {
|
||||
delete next[packageId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : previous;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export function getRollingMetricDirection(previous: number, next: number): Rolli
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function shouldAnimateRollingMetric(
|
||||
direction: RollingMetricDirection,
|
||||
reducedMotion: boolean
|
||||
): direction is Exclude<RollingMetricDirection, "none"> {
|
||||
return direction !== "none" && !reducedMotion;
|
||||
}
|
||||
|
||||
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
|
||||
const previousRef = useRef({ numericValue, value });
|
||||
const sequenceRef = useRef(0);
|
||||
@@ -34,7 +41,10 @@ export function RollingMetricValue({ numericValue, value }: RollingMetricValuePr
|
||||
if (previous.value === value && previous.numericValue === numericValue) return;
|
||||
previousRef.current = { numericValue, value };
|
||||
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
|
||||
if (direction === "none") {
|
||||
const reducedMotion = typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (!shouldAnimateRollingMetric(direction, reducedMotion)) {
|
||||
setTransition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ export interface DownloadItem {
|
||||
resumeLinkRenewalFailures?: number;
|
||||
resumeHardResetUsed?: boolean;
|
||||
resumeResetPending?: boolean;
|
||||
http416FreshRestarts?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
onlineStatus?: "online" | "offline" | "checking";
|
||||
@@ -460,6 +461,9 @@ export interface RotationEvent {
|
||||
category?: string;
|
||||
cooldownSec?: number;
|
||||
next?: string;
|
||||
attemptId?: string;
|
||||
itemId?: string;
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
export interface UiSnapshot {
|
||||
@@ -485,7 +489,11 @@ export interface UiSnapshot {
|
||||
requiredBytes: number;
|
||||
availableBytes: number;
|
||||
deficitBytes: number;
|
||||
safetyBytes: number;
|
||||
retryAt: number;
|
||||
at: number;
|
||||
state: "waiting" | "resolved";
|
||||
resolvedAt?: number;
|
||||
}>;
|
||||
payloadKind?: "full" | "delta";
|
||||
removedItemIds?: string[];
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import {
|
||||
getAccountRotationLogPath,
|
||||
getRecentRotationEvents,
|
||||
initAccountRotationLog,
|
||||
logAccountRotation,
|
||||
runWithRotationItemSink,
|
||||
shutdownAccountRotationLog,
|
||||
type CorrelatedRotationEvent
|
||||
} from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownAccountRotationLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
@@ -17,7 +37,107 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||
expect(failed?.accountLabel).toBe("Account 1/3");
|
||||
expect(failed?.next).toBe("Account 2/3");
|
||||
});
|
||||
|
||||
it("removes account identities, credentials and source URLs before events reach a sink or ring", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
await runWithRotationItemSink((event) => captured.push(event), async () => {
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ce)", "FAILED", {
|
||||
reason: `Incorrect password for alice@example.test password=provider-secret masked=al***ce@identity.invalid source=${sourceUrl}`,
|
||||
category: "invalid",
|
||||
cooldownSec: 30,
|
||||
next: "Account 2/3 (bo***ob)",
|
||||
token: "direct-token-secret",
|
||||
authorization: "Bearer direct-bearer-secret",
|
||||
credentials: {
|
||||
username: "nested-user",
|
||||
password: "nested-password-secret",
|
||||
apiKey: "nested-api-key-secret",
|
||||
sourceUrl
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const event = captured[0];
|
||||
const serialized = JSON.stringify(event);
|
||||
expect(event).toMatchObject({
|
||||
accountLabel: "Account 1/3",
|
||||
category: "invalid",
|
||||
cooldownSec: 30,
|
||||
next: "Account 2/3"
|
||||
});
|
||||
expect(event.reason).toContain("Incorrect password");
|
||||
expect(event.reason).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(serialized).not.toContain("alice@example.test");
|
||||
expect(serialized).not.toContain("provider-secret");
|
||||
expect(serialized).not.toContain("source-user");
|
||||
expect(serialized).not.toContain("source-pass");
|
||||
expect(serialized).not.toContain("query-secret");
|
||||
expect(serialized).not.toContain("al***ce");
|
||||
expect(serialized).not.toContain("bo***ob");
|
||||
for (const sensitive of ["identity.invalid", "direct-token-secret", "direct-bearer-secret", "nested-user", "nested-password-secret", "nested-api-key-secret"]) {
|
||||
expect(serialized).not.toContain(sensitive);
|
||||
}
|
||||
expect(JSON.stringify(getRecentRotationEvents(10))).not.toContain("provider-secret");
|
||||
});
|
||||
|
||||
it("writes only anonymous accounts and fingerprinted links to the account rotation log", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-safety-"));
|
||||
tempDirs.push(root);
|
||||
const sourceUrl = "https://log-user:log-pass@rapidgator.net/file/private?token=rotation-secret";
|
||||
initAccountRotationLog(root);
|
||||
|
||||
logAccountRotation("WARN", "Mega-Debrid API", "Account 1/2 (lo***in)", "FAILED", {
|
||||
reason: `Unauthorized login=private-login password=private-password at ${sourceUrl}`,
|
||||
category: "invalid",
|
||||
link: sourceUrl,
|
||||
next: "Account 2/2 (ne***xt)",
|
||||
token: "direct-log-token-secret",
|
||||
authorization: "Bearer direct-log-bearer-secret",
|
||||
credentials: {
|
||||
username: "nested-log-user",
|
||||
password: "nested-log-password-secret",
|
||||
apiKey: "nested-log-api-key-secret",
|
||||
sourceUrl
|
||||
}
|
||||
});
|
||||
|
||||
const logPath = getAccountRotationLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownAccountRotationLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Account 1/2 | FAILED");
|
||||
expect(content).toContain("category=invalid");
|
||||
expect(content).toMatch(/link=rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("next=Account 2/2");
|
||||
for (const sensitive of ["log-user", "log-pass", "rotation-secret", "private-login", "private-password", "lo***in", "ne***xt", "direct-log-token-secret", "direct-log-bearer-secret", "nested-log-user", "nested-log-password-secret", "nested-log-api-key-secret", sourceUrl]) {
|
||||
expect(content).not.toContain(sensitive);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the active correlation IDs into the account rotation log", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-correlation-"));
|
||||
tempDirs.push(root);
|
||||
initAccountRotationLog(root);
|
||||
|
||||
await runWithRotationItemSink(
|
||||
() => undefined,
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 1/1", "OK");
|
||||
},
|
||||
{ attemptId: "attempt-log", itemId: "item-log", packageId: "package-log" }
|
||||
);
|
||||
|
||||
const logPath = getAccountRotationLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownAccountRotationLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("attemptId=attempt-log");
|
||||
expect(content).toContain("itemId=item-log");
|
||||
expect(content).toContain("packageId=package-log");
|
||||
});
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
@@ -47,12 +167,48 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
|
||||
it("attaches and isolates optional attempt, item and package IDs across parallel rotations", async () => {
|
||||
const first: CorrelatedRotationEvent[] = [];
|
||||
const second: CorrelatedRotationEvent[] = [];
|
||||
|
||||
await Promise.all([
|
||||
runWithRotationItemSink(
|
||||
(event) => first.push(event),
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "TEST");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/2", "OK");
|
||||
},
|
||||
{ attemptId: "attempt-a", itemId: "item-a", packageId: "package-a" }
|
||||
),
|
||||
runWithRotationItemSink(
|
||||
(event) => second.push(event),
|
||||
async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/2", "TEST");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 2/2", "FAILED");
|
||||
},
|
||||
{ attemptId: "attempt-b", itemId: "item-b", packageId: "package-b" }
|
||||
)
|
||||
]);
|
||||
|
||||
expect(first).toHaveLength(2);
|
||||
expect(first.every((event) => event.attemptId === "attempt-a" && event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
|
||||
expect(second).toHaveLength(2);
|
||||
expect(second.every((event) => event.attemptId === "attempt-b" && event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
|
||||
|
||||
const correlatedRing = getRecentRotationEvents(10).filter((event) => event.attemptId === "attempt-a" || event.attemptId === "attempt-b");
|
||||
expect(correlatedRing).toHaveLength(4);
|
||||
expect(correlatedRing.filter((event) => event.attemptId === "attempt-a").every((event) => event.itemId === "item-a" && event.packageId === "package-a")).toBe(true);
|
||||
expect(correlatedRing.filter((event) => event.attemptId === "attempt-b").every((event) => event.itemId === "item-b" && event.packageId === "package-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("feeds the global UI ring with TEST and outcome events", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => {
|
||||
@@ -62,7 +218,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
next: "Account 11 (yz)"
|
||||
});
|
||||
|
||||
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10 (xy)");
|
||||
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10");
|
||||
|
||||
expect(event).toMatchObject({
|
||||
event: "TIMEOUT_COOLDOWN",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAccountToggleSettingsUpdate,
|
||||
SerialTaskQueue,
|
||||
setAccountTargetEnabled,
|
||||
type AccountToggleTarget
|
||||
@@ -47,6 +48,8 @@ describe("account toggle queue", () => {
|
||||
let settings = {
|
||||
disabledProviders: [],
|
||||
debridLinkDisabledKeyIds: [],
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiDisabledAccountIds: [],
|
||||
megaDebridWebDisabledAccountIds: [...accountIds],
|
||||
megaDebridDisabledAccountIds: [...accountIds]
|
||||
@@ -60,4 +63,25 @@ describe("account toggle queue", () => {
|
||||
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||
expect(settings.megaDebridDisabledAccountIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("reactivates a disabled Mega-Debrid mode when one of its accounts is enabled", () => {
|
||||
const settings = {
|
||||
disabledProviders: [],
|
||||
debridLinkDisabledKeyIds: [],
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridApiDisabledAccountIds: ["api-1"],
|
||||
megaDebridWebDisabledAccountIds: ["web-1"],
|
||||
megaDebridDisabledAccountIds: ["api-1", "web-1"]
|
||||
};
|
||||
|
||||
const next = setAccountTargetEnabled(settings, { kind: "mega-web", accountId: "web-1" }, true);
|
||||
const update = buildAccountToggleSettingsUpdate(next);
|
||||
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridApiEnabled).toBe(false);
|
||||
expect(update.megaDebridWebEnabled).toBe(true);
|
||||
expect(update.megaDebridApiEnabled).toBe(false);
|
||||
expect(update.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
|
||||
import { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||
import { getSnapshotRenderDelay, runResetUiAction, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||
|
||||
describe("desktop shell", () => {
|
||||
it("uses keyboard-focusable controls for every copy target", () => {
|
||||
@@ -35,9 +35,78 @@ describe("desktop shell", () => {
|
||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||
});
|
||||
|
||||
it("renders active download telemetry at a stable half-second cadence", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500);
|
||||
it("does not add a second renderer debounce after main-process telemetry throttling", () => {
|
||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(0);
|
||||
});
|
||||
|
||||
it("guards a pending reset, reconciles the snapshot, and releases the busy state", async () => {
|
||||
let finishReset: () => void = () => undefined;
|
||||
const pendingReset = new Promise<void>((resolve) => { finishReset = resolve; });
|
||||
const gate = { busy: false };
|
||||
const busyStates: boolean[] = [];
|
||||
const events: string[] = [];
|
||||
let resetCalls = 0;
|
||||
|
||||
const first = runResetUiAction({
|
||||
gate,
|
||||
reset: async () => {
|
||||
resetCalls += 1;
|
||||
await pendingReset;
|
||||
events.push("reset");
|
||||
},
|
||||
reconcile: async () => { events.push("reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: () => { events.push("error"); }
|
||||
});
|
||||
const duplicate = await runResetUiAction({
|
||||
gate,
|
||||
reset: async () => { resetCalls += 1; },
|
||||
reconcile: async () => { events.push("duplicate-reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: () => { events.push("duplicate-error"); }
|
||||
});
|
||||
|
||||
expect(duplicate).toBe("busy");
|
||||
expect(gate.busy).toBe(true);
|
||||
expect(resetCalls).toBe(1);
|
||||
expect(busyStates).toEqual([true]);
|
||||
|
||||
finishReset();
|
||||
await expect(first).resolves.toBe("completed");
|
||||
expect(events).toEqual(["reset", "reconcile"]);
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(gate.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("reports reset failures and still reconciles the authoritative snapshot", async () => {
|
||||
const gate = { busy: false };
|
||||
const busyStates: boolean[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const events: string[] = [];
|
||||
const failure = new Error("Teildatei ist gesperrt");
|
||||
|
||||
await expect(runResetUiAction({
|
||||
gate,
|
||||
reset: async () => { throw failure; },
|
||||
reconcile: async () => { events.push("reconcile"); },
|
||||
setBusy: (busy) => { busyStates.push(busy); },
|
||||
onError: (error) => { errors.push(error); }
|
||||
})).resolves.toBe("failed");
|
||||
|
||||
expect(errors).toEqual([failure]);
|
||||
expect(events).toEqual(["reconcile"]);
|
||||
expect(busyStates).toEqual([true, false]);
|
||||
expect(gate.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("routes every renderer reset through the guarded authoritative workflow", () => {
|
||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toMatch(/window\.rd\.reset(?:Package|Items)\([^\n]*\.catch\(\(\) => \{\}\)/);
|
||||
expect(source.match(/performReset\(/g)).toHaveLength(3);
|
||||
expect(source).toContain('disabled={resetBusy}');
|
||||
expect(source).toContain('actionBusy: actionBusy || resetBusy');
|
||||
});
|
||||
|
||||
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||
|
||||
@@ -45,4 +45,41 @@ describe("audit-log", () => {
|
||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(content).toContain("Audit-Log Start");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from audit logs", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-sensitive-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initAuditLog(baseDir);
|
||||
logAuditEvent(
|
||||
"ERROR",
|
||||
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=url-secret für audit@example.com in C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
|
||||
{
|
||||
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=url-secret",
|
||||
authorization: "Bearer authorization-secret-value",
|
||||
details: {
|
||||
password: "password-secret-value",
|
||||
cookie: "sid=cookie-secret-value",
|
||||
email: "audit@example.com",
|
||||
extractPath: "/var/lib/downloader/archive.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const logPath = getAuditLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("private-id");
|
||||
expect(content).not.toContain("url-secret");
|
||||
expect(content).not.toContain("authorization-secret-value");
|
||||
expect(content).not.toContain("password-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("audit@example.com");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/var/lib/downloader");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeClipboardTextFromIpc } from "../src/main/clipboard-ipc";
|
||||
import type { TrustedIpcOptions } from "../src/main/ipc-security";
|
||||
|
||||
const electronMocks = vi.hoisted(() => ({
|
||||
writeText: vi.fn()
|
||||
}));
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
clipboard: {
|
||||
writeText: electronMocks.writeText
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../src/main/logger", () => ({
|
||||
logger: loggerMocks
|
||||
}));
|
||||
|
||||
const trustedOptions: TrustedIpcOptions = {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
};
|
||||
|
||||
function eventFor(url: string) {
|
||||
return {
|
||||
senderFrame: { url },
|
||||
sender: {
|
||||
getURL: () => url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
electronMocks.writeText.mockReset();
|
||||
loggerMocks.info.mockReset();
|
||||
loggerMocks.warn.mockReset();
|
||||
});
|
||||
|
||||
describe("clipboard IPC", () => {
|
||||
it("writes trusted renderer text through Electron clipboard", () => {
|
||||
const result = writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
"https://rapidgator.net/file/example",
|
||||
trustedOptions
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(electronMocks.writeText).toHaveBeenCalledOnce();
|
||||
expect(electronMocks.writeText).toHaveBeenCalledWith("https://rapidgator.net/file/example");
|
||||
expect(loggerMocks.info).toHaveBeenCalledWith("Zwischenablage geschrieben: bytes=35");
|
||||
expect(JSON.stringify(loggerMocks.info.mock.calls)).not.toContain("rapidgator.net");
|
||||
});
|
||||
|
||||
it("rejects untrusted renderer calls before touching the clipboard", () => {
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("https://attacker.example/downloads"),
|
||||
"private value",
|
||||
trustedOptions
|
||||
)).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
|
||||
expect(electronMocks.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs native clipboard failures without the copied value", () => {
|
||||
electronMocks.writeText.mockImplementationOnce(() => {
|
||||
throw new Error("Clipboard busy");
|
||||
});
|
||||
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
"private-link-value",
|
||||
trustedOptions
|
||||
)).toThrow("Clipboard busy");
|
||||
|
||||
expect(loggerMocks.warn).toHaveBeenCalledWith("Zwischenablage-Schreiben fehlgeschlagen: Error: Clipboard busy");
|
||||
expect(JSON.stringify(loggerMocks.warn.mock.calls)).not.toContain("private-link-value");
|
||||
});
|
||||
|
||||
it("rejects UTF-8 payloads larger than 16 MiB before touching the clipboard", () => {
|
||||
const oversizedText = "ä".repeat((8 * 1024 * 1024) + 1);
|
||||
|
||||
expect(Buffer.byteLength(oversizedText, "utf8")).toBe((16 * 1024 * 1024) + 2);
|
||||
expect(() => writeClipboardTextFromIpc(
|
||||
eventFor("http://localhost:5180/downloads"),
|
||||
oversizedText,
|
||||
trustedOptions
|
||||
)).toThrow("Ungültiger Zwischenablageinhalt");
|
||||
|
||||
expect(electronMocks.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConversionBlock,
|
||||
getConversionLogPath,
|
||||
hasActiveConversionTrace,
|
||||
initConversionLog,
|
||||
runWithConversionTrace,
|
||||
shutdownConversionLog,
|
||||
traceConversionPhase,
|
||||
type ConversionTrace
|
||||
} from "../src/main/conversion-trace";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownConversionLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("formatConversionBlock", () => {
|
||||
it("renders a header with verdict + total and one indented line per phase", () => {
|
||||
const trace: ConversionTrace = {
|
||||
@@ -26,12 +41,15 @@ describe("formatConversionBlock", () => {
|
||||
|
||||
const lines = block.split("\n");
|
||||
expect(lines[0]).toContain("[CONV]");
|
||||
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
|
||||
expect(lines[0]).toContain("itemId=id1");
|
||||
expect(lines[0]).not.toContain("tvs-foo.part5.rar");
|
||||
expect(lines[0]).not.toContain("rapidgator.net");
|
||||
expect(lines[0]).toContain("result=OK");
|
||||
expect(lines[0]).toContain("total=1450ms");
|
||||
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[2]).toContain("+5ms token");
|
||||
expect(lines[2]).toContain("account=Account 2/2");
|
||||
expect(lines[2]).toContain("token=fresh");
|
||||
expect(lines[2]).toContain("workMs=812");
|
||||
});
|
||||
@@ -45,6 +63,66 @@ describe("formatConversionBlock", () => {
|
||||
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
|
||||
expect(block).toContain("caller-timeout");
|
||||
});
|
||||
|
||||
it("keeps diagnostic value while removing identities, credentials and source URLs", () => {
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0,
|
||||
itemId: "item-safe",
|
||||
itemName: "file.rar",
|
||||
link: sourceUrl,
|
||||
providerOrder: "megadebrid-web",
|
||||
notes: {
|
||||
retry: 1,
|
||||
auth: "login=trace-user password=trace-password",
|
||||
token: "note-token-secret",
|
||||
credentials: JSON.stringify({ login: "nested-trace-user", password: "nested-trace-password" })
|
||||
},
|
||||
phases: [{
|
||||
atMs: 25,
|
||||
phase: "mega-account",
|
||||
provider: "megadebrid-web",
|
||||
account: "Account 1/3 (tr***ce)",
|
||||
outcome: "failed",
|
||||
detail: `Incorrect password for trace@example.test token=provider-token masked=tr***ce@identity.invalid source=${sourceUrl}`
|
||||
}]
|
||||
};
|
||||
|
||||
const block = formatConversionBlock(trace, "FAIL", `Provider rejected password=header-secret at ${sourceUrl}`, 30);
|
||||
expect(block).toContain("result=FAIL");
|
||||
expect(block).toContain("Incorrect password");
|
||||
expect(block).toContain("account=Account 1/3");
|
||||
expect(block).toContain("itemId=item-safe");
|
||||
expect(block).not.toContain("files.example.test");
|
||||
for (const sensitive of ["source-user", "source-pass", "query-secret", "trace-user", "trace-password", "trace@example.test", "provider-token", "header-secret", "tr***ce", "identity.invalid", "note-token-secret", "nested-trace-user", "nested-trace-password", sourceUrl]) {
|
||||
expect(block).not.toContain(sensitive);
|
||||
}
|
||||
expect(block).not.toContain("https://");
|
||||
});
|
||||
|
||||
it("renders shared opaque correlation IDs without exposing item names or source links", () => {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0,
|
||||
attemptId: "attempt-42",
|
||||
itemId: "item-42",
|
||||
packageId: "package-42",
|
||||
itemName: "Private.Release.Name.part1.rar",
|
||||
link: "https://private-user:private-password@rapidgator.net/file/private-token/Private.Release.Name.part1.rar",
|
||||
providerOrder: "megadebrid-web",
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
|
||||
const block = formatConversionBlock(trace, "OK", "", 25);
|
||||
|
||||
expect(block).toContain("attemptId=attempt-42");
|
||||
expect(block).toContain("itemId=item-42");
|
||||
expect(block).toContain("packageId=package-42");
|
||||
expect(block).not.toContain("Private.Release.Name");
|
||||
expect(block).not.toContain("rapidgator.net");
|
||||
expect(block).not.toContain("private-token");
|
||||
expect(block).not.toContain("link=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversion trace context", () => {
|
||||
@@ -68,4 +146,36 @@ describe("conversion trace context", () => {
|
||||
expect(seen).toBe(true);
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
});
|
||||
|
||||
it("carries optional correlation IDs through the async trace into the written block", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-conversion-correlation-"));
|
||||
tempDirs.push(root);
|
||||
initConversionLog(root);
|
||||
|
||||
await runWithConversionTrace(
|
||||
{
|
||||
attemptId: "attempt-written",
|
||||
itemId: "item-written",
|
||||
packageId: "package-written",
|
||||
itemName: "Clear.Release.Name.rar",
|
||||
link: "https://rapidgator.net/file/clear-link-token/Clear.Release.Name.rar",
|
||||
providerOrder: "megadebrid-api"
|
||||
},
|
||||
async () => {
|
||||
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
|
||||
await Promise.resolve();
|
||||
}
|
||||
);
|
||||
|
||||
const logPath = getConversionLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
shutdownConversionLog();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("attemptId=attempt-written");
|
||||
expect(content).toContain("itemId=item-written");
|
||||
expect(content).toContain("packageId=package-written");
|
||||
expect(content).not.toContain("Clear.Release.Name");
|
||||
expect(content).not.toContain("rapidgator.net");
|
||||
expect(content).not.toContain("clear-link-token");
|
||||
});
|
||||
});
|
||||
|
||||
+408
-1
@@ -4,7 +4,7 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeDebridLinkRuntimeCooldownForTests, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -371,6 +371,21 @@ describe("debrid service", () => {
|
||||
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
||||
});
|
||||
|
||||
it("clears Debrid-Link runtime cooldown when a key is reactivated live", () => {
|
||||
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||
debridLinkDisabledKeyIds: [keys[0].id]
|
||||
};
|
||||
primeDebridLinkRuntimeCooldownForTests(keys[0].id, 60_000, "stale cooldown");
|
||||
const service = new DebridService(settings);
|
||||
|
||||
service.setSettings({ ...settings, debridLinkDisabledKeyIds: [] });
|
||||
|
||||
expect(getDebridLinkKeyCooldownStateForTests(keys[0].id)).toBeNull();
|
||||
});
|
||||
|
||||
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -1366,6 +1381,121 @@ describe("debrid service", () => {
|
||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
||||
});
|
||||
|
||||
it("keeps dedicated Mega-Debrid pools disabled when both explicit mode flags are false", async () => {
|
||||
const fetchSpy = vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("action=connectUser")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "disabled-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: "https://mega-cdn.example/disabled-api.rar",
|
||||
filename: "disabled-api.rar"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as typeof fetch;
|
||||
const megaWeb = vi.fn(async () => ({
|
||||
fileName: "disabled-web.rar",
|
||||
directUrl: "https://mega-web.example/disabled-web.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
|
||||
for (const preferApi of [true, false]) {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-user",
|
||||
megaPassword: "legacy-pass",
|
||||
megaCredentials: "legacy-user:legacy-pass\napi-user:api-pass\nweb-user:web-pass",
|
||||
megaDebridApiCredentials: "api-user:api-pass",
|
||||
megaDebridWebCredentials: "web-user:web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: preferApi,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
await expect(service.unrestrictLink(`https://rapidgator.net/file/dedicated-disabled-${preferApi}`)).rejects.toThrow(/nicht konfiguriert/i);
|
||||
}
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(megaWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the preferred API fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-api-user",
|
||||
megaPassword: "legacy-api-pass",
|
||||
megaCredentials: "legacy-api-user:legacy-api-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
||||
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
||||
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")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "legacy-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
return new Response(JSON.stringify({
|
||||
response_code: "ok",
|
||||
debridLink: "https://mega-cdn.example/legacy-api.rar",
|
||||
filename: "legacy-api.rar"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const service = new DebridService(settings);
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-api");
|
||||
expect(result.directUrl).toBe("https://mega-cdn.example/legacy-api.rar");
|
||||
});
|
||||
|
||||
it("keeps the preferred Web fallback for legacy Mega-Debrid settings without dedicated pool fields", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy-web-user",
|
||||
megaPassword: "legacy-web-pass",
|
||||
megaCredentials: "legacy-web-user:legacy-web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
delete (settings as Partial<typeof settings>).megaDebridApiCredentials;
|
||||
delete (settings as Partial<typeof settings>).megaDebridWebCredentials;
|
||||
const megaWeb = vi.fn(async () => ({
|
||||
fileName: "legacy-web.rar",
|
||||
directUrl: "https://mega-web.example/legacy-web.rar",
|
||||
fileSize: null,
|
||||
retriesUsed: 0
|
||||
}));
|
||||
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const result = await service.unrestrictLink("https://rapidgator.net/file/legacy-web");
|
||||
expect(result.directUrl).toBe("https://mega-web.example/legacy-web.rar");
|
||||
expect(megaWeb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses Mega web fallback when API fails", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
@@ -1524,6 +1654,201 @@ describe("debrid service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid Web in-flight state when the provider ignores caller abort", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-web-user:ignored-web-pass",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "ignored-web-user:ignored-web-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markProviderStarted: () => void = () => {};
|
||||
const providerStarted = new Promise<void>((resolve) => {
|
||||
markProviderStarted = resolve;
|
||||
});
|
||||
let rejectProvider: (reason?: unknown) => void = () => {};
|
||||
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
||||
rejectProvider = reject;
|
||||
});
|
||||
const megaWeb = vi.fn(() => {
|
||||
markProviderStarted();
|
||||
return ignoredProviderPromise;
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const controller = new AbortController();
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-abort", controller.signal);
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await providerStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
controller.abort("settings_refresh");
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 100);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectProvider(new Error("late Mega-Web provider failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid Web in-flight state when the provider ignores the account timeout", async () => {
|
||||
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: "ignored-timeout-user:ignored-timeout-pass",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markProviderStarted: () => void = () => {};
|
||||
const providerStarted = new Promise<void>((resolve) => {
|
||||
markProviderStarted = resolve;
|
||||
});
|
||||
let rejectProvider: (reason?: unknown) => void = () => {};
|
||||
const ignoredProviderPromise = new Promise<never>((_resolve, reject) => {
|
||||
rejectProvider = reject;
|
||||
});
|
||||
const megaWeb = vi.fn(() => {
|
||||
markProviderStarted();
|
||||
return ignoredProviderPromise;
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-web-timeout");
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await providerStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 200);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/mega_debrid_slow_link|aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectProvider(new Error("late Mega-Web timeout failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("web")).toBe(0);
|
||||
});
|
||||
|
||||
it("releases Mega-Debrid API in-flight state when getLink ignores caller abort", async () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaCredentials: "ignored-api-user:ignored-api-pass",
|
||||
megaDebridApiCredentials: "ignored-api-user:ignored-api-pass",
|
||||
megaDebridWebCredentials: "",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-api" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
let markGetLinkStarted: () => void = () => {};
|
||||
const getLinkStarted = new Promise<void>((resolve) => {
|
||||
markGetLinkStarted = resolve;
|
||||
});
|
||||
let rejectGetLink: (reason?: unknown) => void = () => {};
|
||||
const ignoredGetLinkPromise = new Promise<Response>((_resolve, reject) => {
|
||||
rejectGetLink = reject;
|
||||
});
|
||||
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")) {
|
||||
return new Response(JSON.stringify({ response_code: "ok", token: "ignored-api-token" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("action=getLink")) {
|
||||
markGetLinkStarted();
|
||||
return ignoredGetLinkPromise;
|
||||
}
|
||||
return new Response("not-found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
const service = new DebridService(settings);
|
||||
const controller = new AbortController();
|
||||
const request = service.unrestrictLink("https://rapidgator.net/file/ignored-api-abort", controller.signal);
|
||||
const outcome = request.then(
|
||||
() => ({ status: "fulfilled" as const, error: null }),
|
||||
(error: unknown) => ({ status: "rejected" as const, error })
|
||||
);
|
||||
await getLinkStarted;
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(1);
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
controller.abort("reset");
|
||||
const settled = await Promise.race([
|
||||
outcome,
|
||||
new Promise<null>((resolve) => {
|
||||
timeout = setTimeout(() => resolve(null), 100);
|
||||
})
|
||||
]);
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled?.status).toBe("rejected");
|
||||
expect(String(settled?.error)).toMatch(/aborted/i);
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
rejectGetLink(new Error("late Mega-Debrid API provider failure"));
|
||||
await outcome;
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(getMegaDebridInFlightCountForMode("api")).toBe(0);
|
||||
});
|
||||
|
||||
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
||||
const oldSettings = {
|
||||
...defaultSettings(),
|
||||
@@ -2351,6 +2676,88 @@ describe("debrid service", () => {
|
||||
expect(genuineEmpty.limitSignal).toBe(true);
|
||||
});
|
||||
|
||||
it("sanitizes provider-supplied account failures before they leave Mega-Debrid rotation", async () => {
|
||||
const login = "private-user@example.test";
|
||||
const password = "provider-password-secret";
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
megaLogin: login,
|
||||
megaPassword: password,
|
||||
megaCredentials: `${login}:${password}`,
|
||||
megaDebridApiCredentials: "",
|
||||
megaDebridWebCredentials: `${login}:${password}`,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "megadebrid-web" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
const megaWeb = vi.fn(async () => {
|
||||
throw new Error(`Incorrect password for ${login} login=${login} password=${password} source=${sourceUrl}`);
|
||||
});
|
||||
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
|
||||
|
||||
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-error").then(() => null, (caught: unknown) => caught as Error);
|
||||
const message = String(error?.message || error || "");
|
||||
const cooldown = getMegaDebridAccountCooldownState(`${getMegaDebridAccountId(login)}:web`);
|
||||
expect(message).toContain("ungueltiger Account");
|
||||
expect(message).toContain("Account 1/1");
|
||||
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(cooldown?.category).toBe("invalid");
|
||||
for (const sensitive of [login, password, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
||||
expect(message).not.toContain(sensitive);
|
||||
expect(cooldown?.message || "").not.toContain(sensitive);
|
||||
}
|
||||
expect(message).not.toContain("*");
|
||||
});
|
||||
|
||||
it("sanitizes provider-supplied API key failures before they leave Debrid-Link rotation", async () => {
|
||||
const apiKey = "provider-debrid-link-secret";
|
||||
const sourceUrl = "https://source-user:source-pass@files.example.test/private/file.rar?token=query-secret";
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
bestToken: "",
|
||||
allDebridToken: "",
|
||||
debridLinkApiKeys: apiKey,
|
||||
providerOrder: [] as const,
|
||||
providerPrimary: "debridlink" as const,
|
||||
providerSecondary: "none" as const,
|
||||
providerTertiary: "none" as const,
|
||||
autoProviderFallback: false
|
||||
};
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: "badToken",
|
||||
error_description: `Rejected api_key=${apiKey} source=${sourceUrl}`
|
||||
}), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})) as typeof fetch;
|
||||
const service = new DebridService(settings);
|
||||
|
||||
const error = await service.unrestrictLink("https://rapidgator.net/file/provider-key-error").then(() => null, (caught: unknown) => caught as Error);
|
||||
const message = String(error?.message || error || "");
|
||||
const keyId = parseDebridLinkApiKeys(apiKey)[0].id;
|
||||
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
|
||||
expect(message).toContain("ungueltiger oder deaktivierter API-Key");
|
||||
expect(message).toContain("Key 1/1");
|
||||
expect(message).toMatch(/files\.example\.test#[a-f0-9]{10}/);
|
||||
expect(getDebridLinkKeyRuntimeStateForTests(keyId)).toBe("invalid");
|
||||
for (const sensitive of [apiKey, "source-user", "source-pass", "query-secret", sourceUrl]) {
|
||||
expect(message).not.toContain(sensitive);
|
||||
expect(cooldown?.message || "").not.toContain(sensitive);
|
||||
}
|
||||
expect(message).not.toContain("*");
|
||||
});
|
||||
|
||||
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
|
||||
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
||||
expect(result.fatal).toBe(false);
|
||||
|
||||
@@ -583,7 +583,7 @@ describe("debug-server", () => {
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("overview/accounts.json");
|
||||
expect(entries).toContain("overview/debug-setup.json");
|
||||
expect(entries).toContain("overview/self-check.json");
|
||||
expect(entries).not.toContain("overview/self-check.json");
|
||||
expect(entries).toContain("overview/trace-config.json");
|
||||
expect(entries).toContain("logs/audit.log");
|
||||
expect(entries).toContain("logs/rename.log");
|
||||
|
||||
@@ -226,6 +226,10 @@ describe("disk write recovery", () => {
|
||||
availableBytes: 384,
|
||||
deficitBytes: 640
|
||||
}));
|
||||
expect(manager.getSnapshot().diskWaitEvents?.[0]).toEqual(expect.objectContaining({
|
||||
state: "waiting",
|
||||
at: expect.any(Number)
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps disk-wait downloads out of the scheduler until their capacity retry is due", async () => {
|
||||
@@ -320,6 +324,33 @@ describe("disk write recovery", () => {
|
||||
expect(session.items[itemId].status).toBe("completed");
|
||||
expect(fs.statSync(path.join(outputDir, "reserve-resume.bin")).size).toBe(1_024);
|
||||
expect((manager as any).diskReservations.getReservedBytesByVolume().get("download-volume")).toBe(0);
|
||||
expect(manager.getSnapshot().diskWaitEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ ownerId: itemId, state: "waiting" }),
|
||||
expect.objectContaining({ ownerId: itemId, state: "resolved", resolvedAt: expect.any(Number) })
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps multiple disk-wait events instead of replacing the previous cause", () => {
|
||||
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(os.tmpdir(), `rd-disk-ring-${Date.now()}`)));
|
||||
const first = {
|
||||
phase: "download" as const,
|
||||
ownerId: "item-one",
|
||||
volumeKey: "volume-one",
|
||||
requiredBytes: 200,
|
||||
availableBytes: 100,
|
||||
deficitBytes: 100,
|
||||
safetyBytes: 0,
|
||||
retryAt: Date.now() + 1000
|
||||
};
|
||||
const second = { ...first, ownerId: "item-two", volumeKey: "volume-two" };
|
||||
|
||||
(manager as any).recordDiskWait(first, { itemId: "item-one", packageId: "package-one" });
|
||||
(manager as any).recordDiskWait(second, { itemId: "item-two", packageId: "package-two" });
|
||||
|
||||
expect(manager.getSnapshot().diskWaitEvents).toEqual([
|
||||
expect.objectContaining({ ownerId: "item-one", state: "waiting" }),
|
||||
expect.objectContaining({ ownerId: "item-two", state: "waiting" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks a fully downloaded package as failed when post-processing failed", () => {
|
||||
@@ -631,6 +662,28 @@ describe("download start account gate", () => {
|
||||
expect(disabledMegaManager.getSnapshot().canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("treats configured Mega-Debrid pools with both modes disabled as unavailable", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-disabled-mega-modes-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
megaLogin: "legacy@example.test",
|
||||
megaPassword: "legacy-secret",
|
||||
megaCredentials: "legacy@example.test:legacy-secret",
|
||||
megaDebridApiCredentials: "api@example.test:api-secret",
|
||||
megaDebridWebCredentials: "web@example.test:web-secret",
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false,
|
||||
providerOrder: ["megadebrid-api", "megadebrid-web"]
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("allows start when an active account is available", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
||||
tempDirs.push(root);
|
||||
@@ -859,14 +912,16 @@ describe("download manager", () => {
|
||||
expect(itemLogPath).not.toBeNull();
|
||||
shutdownItemLogs();
|
||||
const content = fs.readFileSync(itemLogPath!, "utf8");
|
||||
const firstTest = content.indexOf("Account 1/3 (al***ha)");
|
||||
const firstTest = content.indexOf("accountLabel=Account 1/3");
|
||||
const firstFailure = content.indexOf("event=FAILED");
|
||||
const secondTest = content.indexOf("Account 2/3 (be***ta)");
|
||||
const secondTest = content.indexOf("accountLabel=Account 2/3");
|
||||
const secondSuccess = content.indexOf("event=OK");
|
||||
expect(firstTest).toBeGreaterThanOrEqual(0);
|
||||
expect(firstFailure).toBeGreaterThan(firstTest);
|
||||
expect(secondTest).toBeGreaterThan(firstFailure);
|
||||
expect(secondSuccess).toBeGreaterThan(secondTest);
|
||||
expect(content).not.toContain("al***ha");
|
||||
expect(content).not.toContain("be***ta");
|
||||
});
|
||||
|
||||
it("stores RapidGator metadata before a download starts", () => {
|
||||
@@ -956,6 +1011,26 @@ describe("download manager", () => {
|
||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes the Mega-Debrid runtime pool when a mode is toggled with unchanged credentials", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-mode-refresh-"));
|
||||
tempDirs.push(root);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaDebridWebCredentials: "web-user:web-pass",
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false
|
||||
};
|
||||
const invalidateMegaSession = vi.fn();
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession });
|
||||
const accountId = getMegaDebridAccountId("web-user");
|
||||
primeMegaDebridRuntimeCooldownForTests(`${accountId}:web`, 120_000);
|
||||
|
||||
manager.setSettings({ ...settings, megaDebridWebEnabled: false });
|
||||
|
||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
||||
expect(getMegaDebridAccountCooldownState(`${accountId}:web`)).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1000,6 +1075,112 @@ describe("download manager", () => {
|
||||
expect(failures.has("realdebrid:rapidgator.net")).toBe(true);
|
||||
});
|
||||
|
||||
it("refreshes an active Debrid-Link key pool without restarting the application", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debrid-link-live-pool-refresh-"));
|
||||
tempDirs.push(root);
|
||||
const keys = parseDebridLinkApiKeys("first-key\nsecond-key");
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
debridLinkApiKeys: "first-key\nsecond-key",
|
||||
debridLinkDisabledKeyIds: [keys[1].id],
|
||||
providerOrder: ["debridlink" as const]
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "debrid-link-pool-refresh", links: ["https://rapidgator.net/file/debrid-link-pool-refresh"] }]);
|
||||
const session = (manager as any).session;
|
||||
const item = Object.values(session.items)[0] as any;
|
||||
item.provider = "debridlink";
|
||||
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);
|
||||
|
||||
manager.setSettings({ ...settings, debridLinkDisabledKeyIds: [keys[0].id] });
|
||||
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("settings_refresh");
|
||||
});
|
||||
|
||||
it("pauses a running queue when the last account is disabled and resumes after reactivation", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-last-account-live-toggle-"));
|
||||
tempDirs.push(root);
|
||||
const login = "only-user";
|
||||
const accountId = getMegaDebridAccountId(login);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaDebridWebCredentials: `${login}:only-pass`,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: ["megadebrid-web" as const]
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "last-account-toggle", links: ["https://rapidgator.net/file/last-account-toggle"] }]);
|
||||
const item = Object.values((manager as any).session.items)[0] as any;
|
||||
item.provider = "megadebrid-web";
|
||||
item.status = "validating";
|
||||
(manager as any).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 schedulerSpy = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [accountId] });
|
||||
|
||||
expect(manager.getSnapshot().session.paused).toBe(true);
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
expect(active.abortController.signal.aborted).toBe(true);
|
||||
expect(active.abortReason).toBe("settings_refresh");
|
||||
|
||||
(manager as any).activeTasks.clear();
|
||||
item.status = "queued";
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [] });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(manager.getSnapshot().session.paused).toBe(false);
|
||||
expect(manager.getSnapshot().canStart).toBe(false);
|
||||
expect(schedulerSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a manual pause when the last account is disabled and reactivated", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-manual-pause-account-toggle-"));
|
||||
tempDirs.push(root);
|
||||
const login = "manual-pause-user";
|
||||
const accountId = getMegaDebridAccountId(login);
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
megaDebridWebCredentials: `${login}:only-pass`,
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridPreferApi: false,
|
||||
providerOrder: ["megadebrid-web" as const]
|
||||
};
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
(manager as any).session.running = true;
|
||||
(manager as any).session.paused = true;
|
||||
const schedulerSpy = vi.spyOn(manager as any, "ensureScheduler").mockResolvedValue(undefined);
|
||||
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [accountId] });
|
||||
manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [] });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(manager.getSnapshot().session.paused).toBe(true);
|
||||
expect(schedulerSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -3447,6 +3628,132 @@ describe("download manager", () => {
|
||||
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
||||
});
|
||||
|
||||
it("keeps a locked in-session HTTP 416 partial until the clean reset can remove it", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-locked-session-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "locked-session-416", links: ["https://dummy/locked-session-416"] }]);
|
||||
const item = Object.values((manager as any).session.items)[0] as any;
|
||||
const targetPath = path.join(root, "downloads", "locked-session-416.part01.rar");
|
||||
const partialBytes = 96 * 1024;
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 7));
|
||||
item.targetPath = targetPath;
|
||||
item.downloadedBytes = partialBytes;
|
||||
item.totalBytes = partialBytes * 2;
|
||||
item.progressPercent = 50;
|
||||
item.http416FreshRestarts = 2;
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
genericErrorRetries: 0
|
||||
};
|
||||
const originalRmSync = fs.rmSync;
|
||||
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
||||
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
||||
const error = new Error("locked") as NodeJS.ErrnoException;
|
||||
error.code = "EBUSY";
|
||||
throw error;
|
||||
}
|
||||
return originalRmSync(candidate as fs.PathLike, options as fs.RmDirOptions);
|
||||
}) as typeof fs.rmSync);
|
||||
|
||||
try {
|
||||
await (manager as any).scheduleHttp416Retry(item, active, "3", "HTTP 416", targetPath);
|
||||
|
||||
expect(item).toMatchObject({
|
||||
status: "queued",
|
||||
downloadedBytes: partialBytes,
|
||||
totalBytes: partialBytes * 2,
|
||||
progressPercent: 50,
|
||||
resumeResetPending: true,
|
||||
http416FreshRestarts: 2,
|
||||
fullStatus: "Warte auf Teildatei-Freigabe"
|
||||
});
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a locked HTTP 416 fresh-restart partial instead of reporting a false zero", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-locked-fresh-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
manager.addPackages([{ name: "locked-fresh-416", links: ["https://dummy/locked-fresh-416"] }]);
|
||||
const item = Object.values((manager as any).session.items)[0] as any;
|
||||
const targetPath = path.join(root, "downloads", "locked-fresh-416.part01.rar");
|
||||
const partialBytes = 128 * 1024;
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 11));
|
||||
item.targetPath = targetPath;
|
||||
item.downloadedBytes = partialBytes;
|
||||
item.totalBytes = partialBytes * 2;
|
||||
item.progressPercent = 50;
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
genericErrorRetries: 3
|
||||
};
|
||||
const originalRmSync = fs.rmSync;
|
||||
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
||||
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
||||
const error = new Error("locked") as NodeJS.ErrnoException;
|
||||
error.code = "EBUSY";
|
||||
throw error;
|
||||
}
|
||||
return originalRmSync(candidate as fs.PathLike, options as fs.RmDirOptions);
|
||||
}) as typeof fs.rmSync);
|
||||
|
||||
try {
|
||||
await (manager as any).escalateHttp416OrFail(item, active, targetPath, "HTTP 416");
|
||||
|
||||
expect(item).toMatchObject({
|
||||
status: "queued",
|
||||
downloadedBytes: partialBytes,
|
||||
totalBytes: partialBytes * 2,
|
||||
progressPercent: 50,
|
||||
resumeResetPending: true,
|
||||
fullStatus: "Warte auf Teildatei-Freigabe"
|
||||
});
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
||||
tempDirs.push(root);
|
||||
@@ -3533,12 +3840,76 @@ describe("download manager", () => {
|
||||
expect(item?.status).toBe("failed");
|
||||
expect(downloadCalls).toBeGreaterThan(4);
|
||||
expect(downloadCalls).toBeLessThan(30);
|
||||
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
|
||||
expect(item.http416FreshRestarts).toBeUndefined();
|
||||
} finally {
|
||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
it("honors a persisted HTTP 416 fresh-restart budget after manager recreation", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-persisted-cap-"));
|
||||
tempDirs.push(root);
|
||||
const paths = createStoragePaths(path.join(root, "state"));
|
||||
const session = emptySession();
|
||||
const createdAt = Date.now();
|
||||
session.packageOrder = ["persisted-416-package"];
|
||||
session.packages["persisted-416-package"] = {
|
||||
id: "persisted-416-package",
|
||||
name: "persisted-416",
|
||||
outputDir: path.join(root, "downloads", "persisted-416"),
|
||||
extractDir: path.join(root, "extract", "persisted-416"),
|
||||
status: "queued",
|
||||
itemIds: ["persisted-416-item"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items["persisted-416-item"] = {
|
||||
id: "persisted-416-item",
|
||||
packageId: "persisted-416-package",
|
||||
url: "https://rapidgator.net/file/persisted-416",
|
||||
provider: "realdebrid",
|
||||
status: "queued",
|
||||
retries: 5,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "persisted-416.bin",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "HTTP 416",
|
||||
fullStatus: "Wartet",
|
||||
http416FreshRestarts: 2,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
saveSession(paths, session);
|
||||
const restored = loadSession(paths);
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), autoExtract: false },
|
||||
restored,
|
||||
paths
|
||||
);
|
||||
const item = (manager as any).session.items["persisted-416-item"];
|
||||
const active = {
|
||||
itemId: item.id,
|
||||
packageId: item.packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
nonResumableCounted: false,
|
||||
genericErrorRetries: 3
|
||||
};
|
||||
|
||||
await (manager as any).escalateHttp416OrFail(item, active, "", "HTTP 416");
|
||||
|
||||
expect(item.status).toBe("failed");
|
||||
expect(item.http416FreshRestarts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -4913,6 +5284,120 @@ describe("download manager", () => {
|
||||
}
|
||||
}, 45000);
|
||||
|
||||
it.each(["write", "end"] as const)("recovers when a stream reports an asynchronous disk-full error during %s", async (failurePhase) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-async-write-error-"));
|
||||
tempDirs.push(root);
|
||||
const binary = Buffer.alloc(192 * 1024, 23);
|
||||
let directCalls = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/async-write-error") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
directCalls += 1;
|
||||
res.writeHead(200, {
|
||||
"Accept-Ranges": "bytes",
|
||||
"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}/async-write-error`;
|
||||
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("/unrestrict/link")) {
|
||||
return new Response(JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "async-write-error.bin",
|
||||
filesize: binary.length
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
const originalCreateWriteStream = fs.createWriteStream;
|
||||
const fsMutable = fs as unknown as { createWriteStream: typeof fs.createWriteStream };
|
||||
let injectedFailure = false;
|
||||
fsMutable.createWriteStream = ((...args: Parameters<typeof fs.createWriteStream>) => {
|
||||
const targetPath = String(args[0]);
|
||||
if (!injectedFailure && targetPath.endsWith("async-write-error.bin")) {
|
||||
injectedFailure = true;
|
||||
class AsyncFailingWriteStream extends EventEmitter {
|
||||
public closed = false;
|
||||
public destroyed = false;
|
||||
public writableLength = 0;
|
||||
public bytesWritten = 0;
|
||||
|
||||
public write(): boolean {
|
||||
if (failurePhase === "write") {
|
||||
queueMicrotask(() => {
|
||||
const error = Object.assign(new Error("disk full"), { code: "ENOSPC" });
|
||||
this.emit("error", error);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public end(): void {
|
||||
if (failurePhase === "end") {
|
||||
queueMicrotask(() => {
|
||||
const error = Object.assign(new Error("disk full on close"), { code: "ENOSPC" });
|
||||
this.emit("error", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
return new AsyncFailingWriteStream() as unknown as ReturnType<typeof fs.createWriteStream>;
|
||||
}
|
||||
return originalCreateWriteStream(...args);
|
||||
}) as typeof fs.createWriteStream;
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
autoReconnect: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "async-write-error", links: ["https://dummy/async-write-error"] }]);
|
||||
|
||||
await manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 30_000);
|
||||
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(injectedFailure).toBe(true);
|
||||
expect(directCalls).toBeGreaterThan(1);
|
||||
expect(fs.readFileSync(item.targetPath)).toEqual(binary);
|
||||
} finally {
|
||||
fsMutable.createWriteStream = originalCreateWriteStream;
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}, 35_000);
|
||||
|
||||
it("uses content-disposition filename when provider filename is opaque", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -5129,6 +5614,7 @@ describe("download manager", () => {
|
||||
const existingTargetPath = path.join(pkgDir, "complete.mkv");
|
||||
fs.writeFileSync(existingTargetPath, binary);
|
||||
let saw416 = false;
|
||||
let directCalls = 0;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/complete") {
|
||||
@@ -5136,6 +5622,7 @@ describe("download manager", () => {
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
directCalls += 1;
|
||||
const range = String(req.headers.range || "");
|
||||
const match = range.match(/bytes=(\d+)-/i);
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
@@ -5175,7 +5662,7 @@ describe("download manager", () => {
|
||||
JSON.stringify({
|
||||
download: directUrl,
|
||||
filename: "complete.mkv",
|
||||
filesize: binary.length
|
||||
filesize: null
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
@@ -5214,8 +5701,8 @@ describe("download manager", () => {
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: binary.length,
|
||||
totalBytes: binary.length,
|
||||
progressPercent: 100,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "complete.mkv",
|
||||
targetPath: existingTargetPath,
|
||||
resumable: true,
|
||||
@@ -5247,6 +5734,8 @@ describe("download manager", () => {
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.targetPath).toBe(existingTargetPath);
|
||||
expect(item?.downloadedBytes).toBe(binary.length);
|
||||
expect(item?.totalBytes).toBe(binary.length);
|
||||
expect(directCalls).toBe(1);
|
||||
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
||||
} finally {
|
||||
server.close();
|
||||
@@ -5858,6 +6347,7 @@ describe("download manager", () => {
|
||||
attempts: 3,
|
||||
lastError: "Error: HTTP 416",
|
||||
fullStatus: "Fehler: Error: HTTP 416",
|
||||
http416FreshRestarts: 2,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
@@ -5874,7 +6364,7 @@ describe("download manager", () => {
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 12000);
|
||||
await manager.waitForStartupRecovery();
|
||||
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[itemId];
|
||||
@@ -5883,10 +6373,101 @@ describe("download manager", () => {
|
||||
expect(item?.downloadedBytes).toBe(0);
|
||||
expect(item?.progressPercent).toBe(0);
|
||||
expect(item?.fullStatus).toContain("Auto-Retry");
|
||||
expect(item?.http416FreshRestarts).toBe(2);
|
||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a locked HTTP 416 partial intact and persists a pending clean reset", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "retry-416-locked-pkg";
|
||||
const itemId = "retry-416-locked-item";
|
||||
const createdAt = Date.now() - 20_000;
|
||||
const outputDir = path.join(root, "downloads", "retry-416-locked");
|
||||
const targetPath = path.join(outputDir, "locked.part03.rar");
|
||||
const partialBytes = 12 * 1024;
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(partialBytes, 1));
|
||||
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "retry-416-locked",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract", "retry-416-locked"),
|
||||
status: "failed",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/retry-416-locked",
|
||||
provider: "megadebrid-web",
|
||||
status: "failed",
|
||||
retries: 4,
|
||||
speedBps: 0,
|
||||
downloadedBytes: partialBytes,
|
||||
totalBytes: partialBytes * 2,
|
||||
progressPercent: 50,
|
||||
fileName: "locked.part03.rar",
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 3,
|
||||
lastError: "Error: HTTP 416",
|
||||
fullStatus: "Fehler: Error: HTTP 416",
|
||||
http416FreshRestarts: 2,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
const originalRmSync = fs.rmSync;
|
||||
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
||||
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
||||
throw Object.assign(new Error("target busy"), { code: "EBUSY" });
|
||||
}
|
||||
return originalRmSync(candidate, options);
|
||||
}) as typeof fs.rmSync);
|
||||
|
||||
try {
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
megaDebridWebCredentials: "mega-user:mega-pass",
|
||||
megaDebridWebEnabled: true,
|
||||
megaDebridApiEnabled: false,
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 2000);
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(item).toMatchObject({
|
||||
status: "queued",
|
||||
downloadedBytes: partialBytes,
|
||||
totalBytes: partialBytes * 2,
|
||||
progressPercent: 50,
|
||||
resumeResetPending: true,
|
||||
fullStatus: "Warte auf Teildatei-Freigabe"
|
||||
});
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.statSync(targetPath).size).toBe(partialBytes);
|
||||
} finally {
|
||||
rmSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("requeues completed zero-byte archive items automatically on startup", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -8320,6 +8901,76 @@ describe("download manager", () => {
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it.each(["items", "package"] as const)("restarts a reset item after a %s reset when its previous link conversion ignores abort", async (resetMode) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `rd-reset-stuck-conversion-${resetMode}-`));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
maxParallel: 1
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
manager.addPackages([{ name: "reset-stuck-conversion", links: ["https://rapidgator.net/file/reset-stuck-conversion"] }]);
|
||||
const itemId = Object.keys((manager as any).session.items)[0];
|
||||
let calls = 0;
|
||||
let releaseFirst: (value: UnrestrictedLink) => void = () => undefined;
|
||||
(manager as any).debridService.unrestrictLink = vi.fn(async (_link: string, signal?: AbortSignal) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return await new Promise<UnrestrictedLink>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
}
|
||||
return await new Promise<UnrestrictedLink>((_resolve, reject) => {
|
||||
const onAbort = (): void => reject(new Error("aborted"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await manager.start();
|
||||
await waitFor(() => calls === 1, 2_000);
|
||||
const firstActive = (manager as any).activeTasks.get(itemId);
|
||||
|
||||
if (resetMode === "items") {
|
||||
await manager.resetItems([itemId]);
|
||||
} else {
|
||||
await manager.resetPackage((manager as any).session.items[itemId].packageId);
|
||||
}
|
||||
await waitFor(() => calls === 2, 2_000);
|
||||
const replacementActive = (manager as any).activeTasks.get(itemId);
|
||||
|
||||
expect(replacementActive).toBeDefined();
|
||||
expect(replacementActive).not.toBe(firstActive);
|
||||
releaseFirst({
|
||||
fileName: "ignored-first.bin",
|
||||
directUrl: "https://dummy/ignored-first.bin",
|
||||
fileSize: 1,
|
||||
retriesUsed: 0
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
expect((manager as any).activeTasks.get(itemId)).toBe(replacementActive);
|
||||
} finally {
|
||||
manager.stop();
|
||||
releaseFirst({
|
||||
fileName: "ignored-first.bin",
|
||||
directUrl: "https://dummy/ignored-first.bin",
|
||||
fileSize: 1,
|
||||
retriesUsed: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a transient Mega-Debrid resolve failure fast (no long cooldown) with a German reason", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
@@ -8774,6 +9425,80 @@ describe("download manager", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps a locked target attached and pending instead of reporting a successful item reset", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-locked-target-"));
|
||||
tempDirs.push(root);
|
||||
const packageId = "reset-locked-target";
|
||||
const itemId = "reset-locked-target-item";
|
||||
const targetPath = path.join(root, "downloads", "reset-locked-target", "locked.part1.rar");
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(4_096, 7));
|
||||
const session = emptySession();
|
||||
const createdAt = Date.now();
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "reset-locked-target",
|
||||
outputDir: path.dirname(targetPath),
|
||||
extractDir: path.join(root, "extract", "reset-locked-target"),
|
||||
status: "completed",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/reset-locked-target",
|
||||
provider: "megadebrid-web",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 4_096,
|
||||
totalBytes: 8_192,
|
||||
progressPercent: 50,
|
||||
fileName: "locked.part1.rar",
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Fertig",
|
||||
onlineStatus: "online",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
const manager = new DownloadManager(
|
||||
{ ...defaultSettings(), outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
await manager.waitForStartupRecovery();
|
||||
const originalRmSync = fs.rmSync;
|
||||
const fsMutable = fs as unknown as { rmSync: typeof fs.rmSync };
|
||||
fsMutable.rmSync = ((candidate: fs.PathLike, options?: fs.RmDirOptions) => {
|
||||
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
||||
throw Object.assign(new Error("locked"), { code: "EBUSY" });
|
||||
}
|
||||
return originalRmSync(candidate, options as fs.RmDirOptions);
|
||||
}) as typeof fs.rmSync;
|
||||
|
||||
try {
|
||||
await expect(manager.resetItems([itemId])).rejects.toThrow("Teildatei");
|
||||
} finally {
|
||||
fsMutable.rmSync = originalRmSync;
|
||||
}
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(item.targetPath).toBe(targetPath);
|
||||
expect(item.downloadedBytes).toBe(4_096);
|
||||
expect(item.resumeResetPending).toBe(true);
|
||||
expect(item.fullStatus).toBe("Warte auf Teildatei-Freigabe");
|
||||
expect(item.onlineStatus).toBe("online");
|
||||
});
|
||||
|
||||
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-"));
|
||||
tempDirs.push(root);
|
||||
@@ -13784,6 +14509,126 @@ describe("download manager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("limits running small-queue telemetry to one update per 500 ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-state-small-cadence-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const internal = manager as unknown as {
|
||||
session: { running: boolean };
|
||||
itemCount: number;
|
||||
emitState: () => void;
|
||||
};
|
||||
internal.session.running = true;
|
||||
internal.itemCount = 12;
|
||||
let emitted = 0;
|
||||
manager.on("state", () => {
|
||||
emitted += 1;
|
||||
});
|
||||
|
||||
internal.emitState();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
expect(emitted).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(emitted).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces account-rotation state events into the running 500 ms cadence", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-state-cadence-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
(manager as any).session.running = true;
|
||||
let emitted = 0;
|
||||
manager.on("state", () => {
|
||||
emitted += 1;
|
||||
});
|
||||
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3", "TEST");
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3", "FAILED");
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3", "TEST");
|
||||
await vi.advanceTimersByTimeAsync(259);
|
||||
expect(emitted).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(emitted).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("coalesces forced running state events into the same 500 ms cadence", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-forced-state-cadence-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const internal = manager as unknown as {
|
||||
session: { running: boolean };
|
||||
emitState: (force?: boolean) => void;
|
||||
};
|
||||
internal.session.running = true;
|
||||
let emitted = 0;
|
||||
manager.on("state", () => {
|
||||
emitted += 1;
|
||||
});
|
||||
|
||||
internal.emitState(true);
|
||||
expect(emitted).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
internal.emitState(true);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
internal.emitState(true);
|
||||
await vi.advanceTimersByTimeAsync(259);
|
||||
expect(emitted).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(emitted).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes running large-queue statistics after 500 ms", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-stats-cadence-"));
|
||||
tempDirs.push(root);
|
||||
const manager = new DownloadManager(
|
||||
defaultSettings(),
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
const internal = manager as unknown as {
|
||||
session: { running: boolean };
|
||||
itemCount: number;
|
||||
sessionDownloadedBytes: number;
|
||||
};
|
||||
internal.session.running = true;
|
||||
internal.itemCount = 500;
|
||||
internal.sessionDownloadedBytes = 100;
|
||||
|
||||
expect(manager.getStats(1_000).totalDownloaded).toBe(100);
|
||||
internal.sessionDownloadedBytes = 200;
|
||||
expect(manager.getStats(1_499).totalDownloaded).toBe(100);
|
||||
expect(manager.getStats(1_500).totalDownloaded).toBe(200);
|
||||
});
|
||||
|
||||
it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@@ -79,35 +79,42 @@ function createSnapshot(running: boolean, paused: boolean): UiSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function findStartAction(node: ReactNode): (() => void) | null {
|
||||
interface DownloadActions {
|
||||
onStartDownloads?: () => void;
|
||||
onPauseDownloads?: () => void;
|
||||
onStopDownloads?: () => void;
|
||||
}
|
||||
|
||||
function findDownloadActions(node: ReactNode): DownloadActions | null {
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const action = findStartAction(child);
|
||||
if (action) return action;
|
||||
const actions = findDownloadActions(child);
|
||||
if (actions) return actions;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement(node)) return null;
|
||||
const props = node.props as {
|
||||
actions?: { onStartDownloads?: () => void };
|
||||
actions?: DownloadActions;
|
||||
children?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
};
|
||||
if (typeof props.actions?.onStartDownloads === "function") {
|
||||
return props.actions.onStartDownloads;
|
||||
if (props.actions && typeof props.actions.onStartDownloads === "function") {
|
||||
return props.actions;
|
||||
}
|
||||
return findStartAction(props.toolbar) ?? findStartAction(props.children);
|
||||
return findDownloadActions(props.toolbar) ?? findDownloadActions(props.children);
|
||||
}
|
||||
|
||||
async function flushAsyncAction(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function renderPausedStartAction(
|
||||
function renderDownloadActions(
|
||||
initialSnapshot: UiSnapshot,
|
||||
togglePause: () => Promise<boolean>,
|
||||
getSnapshot: () => Promise<UiSnapshot>
|
||||
): () => void {
|
||||
getSnapshot: () => Promise<UiSnapshot>,
|
||||
stop: () => Promise<void> = async () => undefined
|
||||
): DownloadActions {
|
||||
hookState.capturedSnapshot = false;
|
||||
hookState.initialSnapshot = initialSnapshot;
|
||||
hookState.currentSnapshot = initialSnapshot;
|
||||
@@ -125,14 +132,14 @@ function renderPausedStartAction(
|
||||
devicePixelRatio: 1,
|
||||
matchMedia: () => ({ matches: false }),
|
||||
prompt: () => null,
|
||||
rd: { getSnapshot, togglePause },
|
||||
rd: { getSnapshot, togglePause, stop },
|
||||
removeEventListener: () => {},
|
||||
setInterval,
|
||||
setTimeout
|
||||
});
|
||||
const action = findStartAction(App() as ReactElement);
|
||||
if (!action) throw new Error("Download-Startaktion nicht gefunden");
|
||||
return action;
|
||||
const actions = findDownloadActions(App() as ReactElement);
|
||||
if (!actions) throw new Error("Download-Aktionen nicht gefunden");
|
||||
return actions;
|
||||
}
|
||||
|
||||
describe("paused download resume reconciliation", () => {
|
||||
@@ -149,13 +156,13 @@ describe("paused download resume reconciliation", () => {
|
||||
it("restores the authoritative paused state when togglePause rejects without a state event", async () => {
|
||||
const initial = createSnapshot(true, true);
|
||||
const authoritative = createSnapshot(true, true);
|
||||
const action = renderPausedStartAction(
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
action();
|
||||
actions.onStartDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
@@ -164,13 +171,51 @@ describe("paused download resume reconciliation", () => {
|
||||
it("replaces stale running state when togglePause returns false without a state event", async () => {
|
||||
const initial = createSnapshot(true, true);
|
||||
const authoritative = createSnapshot(false, false);
|
||||
const action = renderPausedStartAction(
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
action();
|
||||
actions.onStartDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
});
|
||||
|
||||
it("replaces stale running state when pausing returns false without a state event", async () => {
|
||||
const initial = createSnapshot(true, false);
|
||||
const authoritative = createSnapshot(false, false);
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative
|
||||
);
|
||||
|
||||
actions.onPauseDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
});
|
||||
|
||||
it("loads the complete authoritative snapshot after stop succeeds without a state event", async () => {
|
||||
const initial = createSnapshot(true, false);
|
||||
const authoritative = {
|
||||
...createSnapshot(false, false),
|
||||
canStart: true,
|
||||
stats: {
|
||||
...createSnapshot(false, false).stats,
|
||||
totalDownloaded: 4096
|
||||
}
|
||||
};
|
||||
const actions = renderDownloadActions(
|
||||
initial,
|
||||
async () => false,
|
||||
async () => authoritative,
|
||||
async () => undefined
|
||||
);
|
||||
|
||||
actions.onStopDownloads?.();
|
||||
await flushAsyncAction();
|
||||
|
||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
formatHosterLabel,
|
||||
normalizeDownloadServiceLabel
|
||||
} from "../src/renderer/download-format";
|
||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||
import { getRollingMetricDirection, shouldAnimateRollingMetric } from "../src/renderer/ui/RollingMetricValue";
|
||||
|
||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||
|
||||
@@ -98,6 +98,13 @@ describe("rollende Downloadkennzahlen", () => {
|
||||
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
||||
});
|
||||
|
||||
it("skips rolling animations when reduced motion is requested", () => {
|
||||
expect(shouldAnimateRollingMetric("up", true)).toBe(false);
|
||||
expect(shouldAnimateRollingMetric("down", true)).toBe(false);
|
||||
expect(shouldAnimateRollingMetric("up", false)).toBe(true);
|
||||
expect(shouldAnimateRollingMetric("none", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("animates exactly the five stable sidebar metrics", () => {
|
||||
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
||||
|
||||
@@ -64,6 +64,53 @@ describe("item-log", () => {
|
||||
expect(content).toContain("code=missing_parts");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from item logs", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initItemLogs(baseDir);
|
||||
ensureItemLog({
|
||||
itemId: "item-sensitive",
|
||||
packageId: "pkg-sensitive",
|
||||
packageName: "Sensitive Paket",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket\\episode.part2.rar"
|
||||
});
|
||||
|
||||
logItemEvent(
|
||||
"item-sensitive",
|
||||
"ERROR",
|
||||
"Download https://ddownload.com/private/file.rar?auth=url-secret für user@example.net nach /mnt/downloads/file.rar fehlgeschlagen",
|
||||
{
|
||||
downloadUrl: "https://ddownload.com/private/file.rar?auth=url-secret",
|
||||
password: "password-secret-value",
|
||||
metadata: {
|
||||
cookies: "sid=cookie-secret-value",
|
||||
username: "user@example.net",
|
||||
localPath: "\\\\server\\share\\file.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getItemLogPath("item-sensitive");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/ddownload\.com#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("/private/file.rar");
|
||||
expect(content).not.toContain("url-secret");
|
||||
expect(content).not.toContain("password-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("user@example.net");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/mnt/downloads");
|
||||
expect(content).not.toContain("\\\\server\\share");
|
||||
});
|
||||
|
||||
it("keeps traversal-like item ids inside the item log directory", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configureLogger, flushLogger, getLogFilePath, logger } from "../src/main/logger";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await flushLogger();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("logger", () => {
|
||||
it("redacts secrets, accounts, URLs and local paths before every log sink", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-logger-redaction-"));
|
||||
tempDirs.push(baseDir);
|
||||
configureLogger(baseDir);
|
||||
|
||||
logger.warn("URL=https://rapidgator.net/file/private?token=secret | target=C:\\Users\\Admin\\Desktop\\private.rar | email=user@example.com | password=hunter2 | Authorization: Bearer abc.def");
|
||||
await flushLogger();
|
||||
|
||||
const content = fs.readFileSync(getLogFilePath(), "utf8");
|
||||
expect(content).toContain("rapidgator.net#");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("password=<redacted>");
|
||||
expect(content).not.toContain("/file/private");
|
||||
expect(content).not.toContain("C:\\Users\\Admin");
|
||||
expect(content).not.toContain("user@example.com");
|
||||
expect(content).not.toContain("hunter2");
|
||||
expect(content).not.toContain("abc.def");
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,54 @@ describe("package-log", () => {
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Passwort-Versuch");
|
||||
expect(content).toContain("archive=episode.part1.rar");
|
||||
expect(content).toContain("password=\"secret\"");
|
||||
expect(content).toContain("password=<redacted>");
|
||||
expect(content).not.toContain("\"secret\"");
|
||||
});
|
||||
|
||||
it("redacts secrets, identities, direct links and local paths from package logs", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
ensurePackageLog({
|
||||
packageId: "pkg-sensitive",
|
||||
name: "Sensitive Paket",
|
||||
outputDir: "C:\\Users\\Administrator\\Downloads\\Sensitive Paket",
|
||||
extractDir: "/srv/downloads/Sensitive Paket"
|
||||
});
|
||||
|
||||
logPackageEvent(
|
||||
"pkg-sensitive",
|
||||
"ERROR",
|
||||
"Abruf https://rapidgator.net/file/private-id/archive.rar?token=query-secret für owner@example.org unter C:\\Users\\Administrator\\Downloads\\archive.rar fehlgeschlagen",
|
||||
{
|
||||
directUrl: "https://rapidgator.net/file/private-id/archive.rar?token=query-secret",
|
||||
apiToken: "token-secret-value",
|
||||
nested: {
|
||||
cookie: "session=cookie-secret-value",
|
||||
email: "owner@example.org",
|
||||
outputPath: "/home/downloader/archive.rar"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getPackageLogPath("pkg-sensitive");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toMatch(/rapidgator\.net#[a-f0-9]{10}/);
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).not.toContain("private-id");
|
||||
expect(content).not.toContain("query-secret");
|
||||
expect(content).not.toContain("token-secret-value");
|
||||
expect(content).not.toContain("cookie-secret-value");
|
||||
expect(content).not.toContain("owner@example.org");
|
||||
expect(content).not.toContain("C:\\Users\\Administrator");
|
||||
expect(content).not.toContain("/home/downloader");
|
||||
expect(content).not.toContain("/srv/downloads");
|
||||
});
|
||||
|
||||
it("keeps traversal-like package ids inside the package log directory", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PackageEntry } from "../src/shared/types";
|
||||
import { preservePackageOrderForDisplay } from "../src/renderer/package-order";
|
||||
import {
|
||||
preservePackageOrderForDisplay,
|
||||
reconcileCollapsedPackageState,
|
||||
reconcileOptimisticPackageOrder
|
||||
} from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
|
||||
const now = Date.now();
|
||||
@@ -39,3 +43,81 @@ describe("preservePackageOrderForDisplay", () => {
|
||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileCollapsedPackageState", () => {
|
||||
it("keeps user collapse choices stable while package metadata and order change", () => {
|
||||
const previous = { "pkg-first": true, "pkg-second": false };
|
||||
const packages = {
|
||||
"pkg-first": { ...createPackage("pkg-first", ["first-item"], 300), status: "downloading" as const },
|
||||
"pkg-second": { ...createPackage("pkg-second", ["second-item"], 100), status: "completed" as const }
|
||||
};
|
||||
|
||||
const next = reconcileCollapsedPackageState(previous, ["pkg-second", "pkg-first"], packages, true);
|
||||
|
||||
expect(next).toBe(previous);
|
||||
expect(next).toEqual({ "pkg-first": true, "pkg-second": false });
|
||||
});
|
||||
|
||||
it("defaults only new packages and removes packages that disappeared", () => {
|
||||
const previous = { "pkg-old": false, "pkg-stale": true };
|
||||
const packages = {
|
||||
"pkg-old": createPackage("pkg-old", ["old-item"]),
|
||||
"pkg-new": createPackage("pkg-new", ["new-item"])
|
||||
};
|
||||
|
||||
expect(reconcileCollapsedPackageState(previous, ["pkg-old", "pkg-new"], packages, true)).toEqual({
|
||||
"pkg-old": false,
|
||||
"pkg-new": true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileOptimisticPackageOrder", () => {
|
||||
it("keeps the optimistic order visible while an older state event arrives", () => {
|
||||
const pending = ["pkg-a", "pkg-c", "pkg-b"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
["pkg-a", "pkg-b", "pkg-c"],
|
||||
pending,
|
||||
1_000,
|
||||
1_500
|
||||
)).toEqual({
|
||||
displayOrder: pending,
|
||||
pendingOrder: pending,
|
||||
pendingAt: 1_000,
|
||||
status: "pending"
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the authoritative order once it acknowledges the optimistic change", () => {
|
||||
const pending = ["pkg-a", "pkg-c", "pkg-b"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
pending,
|
||||
pending,
|
||||
1_000,
|
||||
1_500
|
||||
)).toEqual({
|
||||
displayOrder: pending,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "acknowledged"
|
||||
});
|
||||
});
|
||||
|
||||
it("returns to the authoritative order after the optimistic hold times out", () => {
|
||||
const authoritative = ["pkg-a", "pkg-b", "pkg-c"];
|
||||
|
||||
expect(reconcileOptimisticPackageOrder(
|
||||
authoritative,
|
||||
["pkg-a", "pkg-c", "pkg-b"],
|
||||
1_000,
|
||||
2_500
|
||||
)).toEqual({
|
||||
displayOrder: authoritative,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "timed-out"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AppController } from "../src/main/app-controller";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: { getPath: () => "C:\\MDD\\Test" },
|
||||
BrowserWindow: class {},
|
||||
clipboard: {},
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn() },
|
||||
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
|
||||
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
|
||||
shell: {},
|
||||
Tray: class {}
|
||||
}));
|
||||
|
||||
describe("reset controller boundary", () => {
|
||||
it("audits reset completion only after the manager operation succeeds", async () => {
|
||||
const packagePromise = Promise.resolve();
|
||||
const itemPromise = Promise.resolve();
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
resetPackage: ReturnType<typeof vi.fn>;
|
||||
resetItems: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
};
|
||||
controller.manager = {
|
||||
resetPackage: vi.fn(() => packagePromise),
|
||||
resetItems: vi.fn(() => itemPromise)
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await controller.resetPackage("package-1");
|
||||
await controller.resetItems(["item-1"]);
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Paket-Reset angefordert",
|
||||
"Paket-Reset abgeschlossen",
|
||||
"Item-Reset angefordert",
|
||||
"Item-Reset abgeschlossen"
|
||||
]);
|
||||
});
|
||||
|
||||
it("audits reset failures instead of reporting a false success", async () => {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
resetPackage: ReturnType<typeof vi.fn>;
|
||||
resetItems: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
resetItems: (itemIds: string[]) => Promise<void>;
|
||||
};
|
||||
controller.manager = {
|
||||
resetPackage: vi.fn(async () => { throw new Error("C:\\private\\locked.part"); }),
|
||||
resetItems: vi.fn(async () => { throw new Error("item locked"); })
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
await expect(controller.resetPackage("package-1")).rejects.toThrow();
|
||||
await expect(controller.resetItems(["item-1"])).rejects.toThrow();
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Paket-Reset angefordert",
|
||||
"Paket-Reset fehlgeschlagen",
|
||||
"Item-Reset angefordert",
|
||||
"Item-Reset fehlgeschlagen"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session control diagnostics", () => {
|
||||
it("records requested and applied phases for stop and pause", () => {
|
||||
const snapshot = {
|
||||
session: {
|
||||
running: true,
|
||||
paused: false,
|
||||
packages: { "package-1": {} },
|
||||
items: {
|
||||
"item-1": { status: "downloading" },
|
||||
"item-2": { status: "queued" }
|
||||
}
|
||||
}
|
||||
};
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
togglePause: ReturnType<typeof vi.fn>;
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
stop: () => void;
|
||||
togglePause: () => boolean;
|
||||
};
|
||||
controller.manager = {
|
||||
stop: vi.fn(() => { snapshot.session.running = false; }),
|
||||
togglePause: vi.fn(() => {
|
||||
snapshot.session.running = true;
|
||||
snapshot.session.paused = true;
|
||||
return true;
|
||||
}),
|
||||
getSnapshot: vi.fn(() => snapshot)
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
controller.stop();
|
||||
controller.togglePause();
|
||||
|
||||
expect(controller.audit.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Session-Stopp angefordert",
|
||||
"Session-Stopp angewendet",
|
||||
"Pause angefordert",
|
||||
"Pause angewendet"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("support bundle diagnostics", () => {
|
||||
it("records selection and export phases without target paths", () => {
|
||||
const controller = Object.create(AppController.prototype) as {
|
||||
manager: {
|
||||
getSnapshot: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
audit: ReturnType<typeof vi.fn>;
|
||||
recordSupportBundleExportSelected: () => void;
|
||||
recordSupportBundleExportLifecycle: (event: {
|
||||
phase: "write" | "failure";
|
||||
durationMs: number;
|
||||
totalDurationMs: number;
|
||||
bytes?: number;
|
||||
failedPhase?: "write";
|
||||
code?: string;
|
||||
}) => void;
|
||||
};
|
||||
controller.manager = {
|
||||
getSnapshot: vi.fn(() => ({
|
||||
session: {
|
||||
running: true,
|
||||
paused: false,
|
||||
packages: { "package-1": {} },
|
||||
items: {
|
||||
"item-1": { status: "downloading" },
|
||||
"item-2": { status: "queued" }
|
||||
}
|
||||
}
|
||||
}))
|
||||
};
|
||||
controller.audit = vi.fn();
|
||||
|
||||
controller.recordSupportBundleExportSelected();
|
||||
controller.recordSupportBundleExportLifecycle({
|
||||
phase: "write",
|
||||
durationMs: 250,
|
||||
totalDurationMs: 700,
|
||||
bytes: 4096
|
||||
});
|
||||
controller.recordSupportBundleExportLifecycle({
|
||||
phase: "failure",
|
||||
durationMs: 300,
|
||||
totalDurationMs: 1000,
|
||||
failedPhase: "write",
|
||||
code: "ENOSPC"
|
||||
});
|
||||
|
||||
expect(controller.audit.mock.calls).toEqual([
|
||||
["INFO", "Support-Bundle-Ziel ausgewählt", {
|
||||
phase: "selected",
|
||||
running: true,
|
||||
paused: false,
|
||||
packageCount: 1,
|
||||
itemCount: 2,
|
||||
activeItemCount: 1
|
||||
}],
|
||||
["INFO", "Support-Bundle geschrieben", {
|
||||
phase: "write",
|
||||
durationMs: 250,
|
||||
totalDurationMs: 700,
|
||||
bytes: 4096
|
||||
}],
|
||||
["ERROR", "Support-Bundle-Export fehlgeschlagen", {
|
||||
phase: "failure",
|
||||
durationMs: 300,
|
||||
totalDurationMs: 1000,
|
||||
failedPhase: "write",
|
||||
code: "ENOSPC"
|
||||
}]
|
||||
]);
|
||||
expect(JSON.stringify(controller.audit.mock.calls)).not.toContain("C:\\");
|
||||
});
|
||||
});
|
||||
@@ -1008,6 +1008,65 @@ describe("settings storage", () => {
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves resume recovery state across a session save and reload", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const session = emptySession();
|
||||
const now = Date.now();
|
||||
const outputDir = path.join(dir, "out");
|
||||
const itemId = "item-resume";
|
||||
session.packageOrder = ["pkg-resume"];
|
||||
session.packages["pkg-resume"] = {
|
||||
id: "pkg-resume",
|
||||
name: "Resume Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract"),
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId: "pkg-resume",
|
||||
url: "https://example.com/resume-file",
|
||||
provider: "megadebrid-web",
|
||||
status: "queued",
|
||||
retries: 2,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 8192,
|
||||
totalBytes: 16384,
|
||||
progressPercent: 50,
|
||||
fileName: "resume-file.bin",
|
||||
targetPath: path.join(outputDir, "resume-file.bin"),
|
||||
resumable: true,
|
||||
attempts: 3,
|
||||
lastError: "",
|
||||
fullStatus: "Resume-Link erneuern",
|
||||
resumeLinkRenewalFailures: 4,
|
||||
resumeHardResetUsed: true,
|
||||
resumeResetPending: true,
|
||||
http416FreshRestarts: 2,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
const loaded = loadSession(paths);
|
||||
|
||||
expect(loaded.items[itemId]).toEqual(expect.objectContaining({
|
||||
downloadedBytes: 8192,
|
||||
totalBytes: 16384,
|
||||
resumeLinkRenewalFailures: 4,
|
||||
resumeHardResetUsed: true,
|
||||
resumeResetPending: true,
|
||||
http416FreshRestarts: 2
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
@@ -6,18 +6,39 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSupportBundle,
|
||||
createSupportBundleExportRunner,
|
||||
type SupportBundleExportLifecycleEvent,
|
||||
writeSupportBundleAtomically
|
||||
} from "../src/main/support-bundle";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
|
||||
import { configureLogger, flushLoggerSync, logger } from "../src/main/logger";
|
||||
import { ensurePackageLog, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { ensureItemLog, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||
import {
|
||||
primeDebridLinkRuntimeCooldownForTests,
|
||||
primeMegaDebridInFlightForTests,
|
||||
primeMegaDebridRuntimeCooldownForTests,
|
||||
resetDebridLinkRuntimeStateForTests,
|
||||
resetMegaDebridRuntimeStateForTests
|
||||
} from "../src/main/debrid";
|
||||
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
|
||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
|
||||
afterEach(() => {
|
||||
shutdownTraceLog();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownSessionLog();
|
||||
shutdownAccountRotationLog();
|
||||
resetDebridLinkRuntimeStateForTests();
|
||||
resetMegaDebridRuntimeStateForTests();
|
||||
flushLoggerSync();
|
||||
configureLogger(process.cwd());
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||
}
|
||||
@@ -151,6 +172,8 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
|
||||
expect(entries).toContain("overview/meta.json");
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("overview/debug-setup.json");
|
||||
expect(entries).not.toContain("overview/self-check.json");
|
||||
expect(entries).toContain("runtime/debug_host.txt");
|
||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||
expect(entries).toContain("overview/support-manifest.json");
|
||||
@@ -159,6 +182,323 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
|
||||
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
|
||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
||||
const meta = JSON.parse(new AdmZip(buffer).getEntry("overview/meta.json")!.getData().toString("utf8"));
|
||||
expect(meta.limits).toMatchObject({
|
||||
directoryLogDiscoveryWindowHours: 8,
|
||||
currentAndRelevantLogsIgnoreAgeFilter: true
|
||||
});
|
||||
expect(meta.limits).not.toHaveProperty("logWindowHours");
|
||||
});
|
||||
|
||||
it("replaces overview clear names with stable bundle-local aliases while retaining extension, size, status and correlation", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-aliases-"));
|
||||
tempDirs.push(root);
|
||||
fs.writeFileSync(path.join(root, "rd_history.json"), JSON.stringify([{
|
||||
id: "history-private-id",
|
||||
name: "Private Linux Collection.iso",
|
||||
totalBytes: 8_000,
|
||||
downloadedBytes: 8_000,
|
||||
fileCount: 1,
|
||||
provider: "megadebrid-web",
|
||||
completedAt: 4,
|
||||
durationSeconds: 5,
|
||||
status: "completed",
|
||||
outputDir: "C:\\Private\\History",
|
||||
urls: ["https://example.invalid/private"]
|
||||
}]), "utf8");
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["package-private-id"],
|
||||
packages: {
|
||||
"package-private-id": {
|
||||
id: "package-private-id",
|
||||
name: "Private Series Collection.zip",
|
||||
outputDir: "C:\\Private\\Output",
|
||||
extractDir: "C:\\Private\\Extract",
|
||||
status: "downloading",
|
||||
itemIds: ["item-private-id"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
cleanedDownloadedBytes: 500,
|
||||
cleanedTotalBytes: 500,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-private-id": {
|
||||
id: "item-private-id",
|
||||
packageId: "package-private-id",
|
||||
url: "https://rapidgator.net/file/example",
|
||||
provider: "megadebrid-web",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 100,
|
||||
downloadedBytes: 250,
|
||||
totalBytes: 1_000,
|
||||
progressPercent: 25,
|
||||
fileName: "Private.Show.S01E01.part1.rar",
|
||||
targetPath: "C:\\Private\\Output\\Private.Show.S01E01.part1.rar",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
onlineStatus: "online"
|
||||
}
|
||||
},
|
||||
runStartedAt: 1,
|
||||
totalDownloadedBytes: 750,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "Geschwindigkeit: 100 B/s",
|
||||
etaText: "ETA: 1m",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
};
|
||||
const manager = {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
|
||||
const zip = new AdmZip(buffer);
|
||||
const packages = JSON.parse(zip.getEntry("overview/packages.json")!.getData().toString("utf8"));
|
||||
const items = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
|
||||
const history = JSON.parse(zip.getEntry("overview/history.json")!.getData().toString("utf8"));
|
||||
const overviewText = [packages, items, history].map((value) => JSON.stringify(value)).join("\n");
|
||||
|
||||
expect(packages.packages[0]).toMatchObject({
|
||||
id: "package-private-id",
|
||||
name: "package-001.zip",
|
||||
status: "downloading",
|
||||
downloadedBytes: 750,
|
||||
totalBytes: 1_500
|
||||
});
|
||||
expect(items.items[0]).toMatchObject({
|
||||
id: "item-private-id",
|
||||
packageId: "package-private-id",
|
||||
fileName: "item-001.rar",
|
||||
status: "downloading",
|
||||
downloadedBytes: 250,
|
||||
totalBytes: 1_000
|
||||
});
|
||||
expect(history.entries[0]).toMatchObject({
|
||||
id: "history-private-id",
|
||||
name: "history-001.iso",
|
||||
status: "completed",
|
||||
downloadedBytes: 8_000,
|
||||
totalBytes: 8_000
|
||||
});
|
||||
expect(overviewText).not.toContain("Private Series Collection.zip");
|
||||
expect(overviewText).not.toContain("Private.Show.S01E01.part1.rar");
|
||||
expect(overviewText).not.toContain("Private Linux Collection.iso");
|
||||
});
|
||||
|
||||
it("adds provider runtime diagnostics with pool-local aliases and no internal account or key identifiers", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-provider-runtime-"));
|
||||
tempDirs.push(root);
|
||||
const disabledApiAccountId = getMegaDebridAccountId("beta-login");
|
||||
const disabledDebridKeyId = getDebridLinkApiKeyId("debrid-token-two");
|
||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||
megaDebridApiCredentials: "alpha-login:alpha-password\nbeta-login:beta-password",
|
||||
megaDebridWebCredentials: "gamma-login:gamma-password",
|
||||
megaDebridApiEnabled: true,
|
||||
megaDebridWebEnabled: false,
|
||||
megaDebridApiDisabledAccountIds: [disabledApiAccountId],
|
||||
debridLinkApiKeys: "debrid-token-one,debrid-token-two",
|
||||
debridLinkDisabledKeyIds: [disabledDebridKeyId]
|
||||
}), "utf8");
|
||||
const apiAccountKey = `${getMegaDebridAccountId("alpha-login")}:api`;
|
||||
const debridKeyId = getDebridLinkApiKeyId("debrid-token-one");
|
||||
primeMegaDebridRuntimeCooldownForTests(apiAccountKey, 60_000, "private account cooldown detail");
|
||||
primeMegaDebridInFlightForTests(apiAccountKey, 2);
|
||||
primeDebridLinkRuntimeCooldownForTests(debridKeyId, 45_000, "private key cooldown detail");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
|
||||
const runtime = JSON.parse(new AdmZip(buffer).getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
|
||||
const providerText = JSON.stringify(runtime.providerRuntime);
|
||||
|
||||
expect(runtime.providerRuntime).toMatchObject({
|
||||
megaDebrid: {
|
||||
rotationCursor: 0,
|
||||
pools: {
|
||||
api: {
|
||||
configuredCount: 2,
|
||||
activeCount: 1,
|
||||
disabledCount: 1,
|
||||
inFlight: 2,
|
||||
accounts: [{
|
||||
account: "Account 1/2",
|
||||
inFlight: 2,
|
||||
cooldown: {
|
||||
category: "temporary"
|
||||
}
|
||||
}]
|
||||
},
|
||||
web: {
|
||||
configuredCount: 1,
|
||||
activeCount: 0,
|
||||
enabled: false,
|
||||
inFlight: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
debridLink: {
|
||||
configuredCount: 2,
|
||||
activeCount: 1,
|
||||
disabledCount: 1,
|
||||
keys: [{
|
||||
account: "Key 1/2",
|
||||
cooldown: {
|
||||
category: "temporary"
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
expect(runtime.providerRuntime.megaDebrid.pools.api.accounts[0].cooldown.remainingMs).toBeGreaterThan(0);
|
||||
expect(runtime.providerRuntime.debridLink.keys[0].cooldown.remainingMs).toBeGreaterThan(0);
|
||||
for (const forbidden of [
|
||||
"alpha-login",
|
||||
"beta-login",
|
||||
"gamma-login",
|
||||
"debrid-token-one",
|
||||
"debrid-token-two",
|
||||
getMegaDebridAccountId("alpha-login"),
|
||||
debridKeyId
|
||||
]) {
|
||||
expect(providerText).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes runtime rotation, disk-wait, export-phase and resume-recovery diagnostics", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
rotationEvents: [{
|
||||
id: "rotation-1",
|
||||
at: 1_234,
|
||||
level: "WARN",
|
||||
provider: "Mega-Debrid Web",
|
||||
accountLabel: "Account 2/3 (be***ta)",
|
||||
event: "FAILED",
|
||||
reason: "timeout",
|
||||
next: "Account 3/3 (ga***ma)"
|
||||
}],
|
||||
diskWaitEvents: [{
|
||||
phase: "download",
|
||||
ownerId: "item-resume",
|
||||
itemId: "item-resume",
|
||||
packageId: "package-resume",
|
||||
volumeKey: "C:",
|
||||
requiredBytes: 2_048,
|
||||
availableBytes: 1_024,
|
||||
deficitBytes: 1_024,
|
||||
retryAt: 2_000
|
||||
}],
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["package-resume"],
|
||||
packages: {
|
||||
"package-resume": {
|
||||
id: "package-resume",
|
||||
name: "Resume",
|
||||
status: "queued",
|
||||
itemIds: ["item-resume"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-resume": {
|
||||
id: "item-resume",
|
||||
packageId: "package-resume",
|
||||
url: "https://rapidgator.net/file/example",
|
||||
provider: "megadebrid-web",
|
||||
status: "queued",
|
||||
retries: 2,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 1_024,
|
||||
totalBytes: 2_048,
|
||||
progressPercent: 50,
|
||||
fileName: "resume.bin",
|
||||
targetPath: "C:\\Downloads\\resume.bin",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "range_ignored_on_resume:1024/2048",
|
||||
fullStatus: "Warte auf Teildatei-Freigabe",
|
||||
resumeLinkRenewalFailures: 2,
|
||||
resumeHardResetUsed: false,
|
||||
resumeResetPending: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
onlineStatus: "online"
|
||||
}
|
||||
},
|
||||
runStartedAt: 1,
|
||||
totalDownloadedBytes: 1_024,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "Geschwindigkeit: 0 B/s",
|
||||
etaText: "ETA: --",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
};
|
||||
const manager = {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, { hostDiagnosticsMode: "none", debugSetupMode: "deferred" });
|
||||
const zip = new AdmZip(buffer);
|
||||
const runtimeDiagnostics = JSON.parse(zip.getEntry("overview/runtime-diagnostics.json")!.getData().toString("utf8"));
|
||||
const itemDiagnostics = JSON.parse(zip.getEntry("overview/items.json")!.getData().toString("utf8"));
|
||||
|
||||
expect(runtimeDiagnostics).toMatchObject({
|
||||
bundleBuild: {
|
||||
state: "building",
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
},
|
||||
rotationEvents: [{
|
||||
provider: "Mega-Debrid Web",
|
||||
accountLabel: "Account 2/3 (<redacted-account>)",
|
||||
event: "FAILED",
|
||||
reason: "timeout"
|
||||
}],
|
||||
diskWaitEvents: [{
|
||||
phase: "download",
|
||||
itemId: "item-resume",
|
||||
deficitBytes: 1_024
|
||||
}]
|
||||
});
|
||||
expect(runtimeDiagnostics.bundleBuild.startedAt).toEqual(expect.any(String));
|
||||
expect(itemDiagnostics.items[0]).toMatchObject({
|
||||
id: "item-resume",
|
||||
resumeLinkRenewalFailures: 2,
|
||||
resumeHardResetUsed: false,
|
||||
resumeResetPending: true
|
||||
});
|
||||
});
|
||||
|
||||
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
|
||||
@@ -215,6 +555,53 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(sessionEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("flushes every pending logger before reading bundle files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-flush-"));
|
||||
tempDirs.push(root);
|
||||
flushLoggerSync();
|
||||
configureLogger(root);
|
||||
initSessionLog(root);
|
||||
initPackageLogs(root);
|
||||
initItemLogs(root);
|
||||
initTraceLog(root);
|
||||
setTraceEnabled(true, "bundle-flush-test", 0);
|
||||
ensurePackageLog({
|
||||
packageId: "package-flush",
|
||||
name: "Flush Package",
|
||||
outputDir: path.join(root, "output"),
|
||||
extractDir: path.join(root, "extract")
|
||||
});
|
||||
ensureItemLog({
|
||||
itemId: "item-flush",
|
||||
packageId: "package-flush",
|
||||
packageName: "Flush Package",
|
||||
fileName: "flush.bin",
|
||||
targetPath: path.join(root, "output", "flush.bin")
|
||||
});
|
||||
|
||||
logger.info("main-buffer-marker");
|
||||
logPackageEvent("package-flush", "INFO", "package-buffer-marker");
|
||||
logItemEvent("item-flush", "INFO", "item-buffer-marker");
|
||||
logTraceEvent("INFO", "support", "trace-buffer-marker");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const zip = new AdmZip(buffer);
|
||||
const packageEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/package-logs/"));
|
||||
const itemEntry = zip.getEntries().find((entry) => entry.entryName.startsWith("logs/item-logs/"));
|
||||
const entryNames = zip.getEntries().map((entry) => entry.entryName);
|
||||
|
||||
expect(zip.getEntry("logs/rd_downloader.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
|
||||
expect(zip.getEntry("logs/session.log")?.getData().toString("utf8") || "").toContain("main-buffer-marker");
|
||||
expect(zip.getEntry("logs/trace.log")?.getData().toString("utf8") || "").toContain("trace-buffer-marker");
|
||||
expect(packageEntry, entryNames.join("\n")).toBeDefined();
|
||||
expect(itemEntry, entryNames.join("\n")).toBeDefined();
|
||||
expect(packageEntry?.getData().toString("utf8") || "").toContain("package-buffer-marker");
|
||||
expect(itemEntry?.getData().toString("utf8") || "").toContain("item-buffer-marker");
|
||||
});
|
||||
|
||||
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
@@ -240,6 +627,113 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
|
||||
});
|
||||
|
||||
it("prioritizes active package and item logs beyond the bounded directory scan", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-priority-"));
|
||||
tempDirs.push(root);
|
||||
const packageLogs = path.join(root, "package-logs");
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(packageLogs, { recursive: true });
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
for (let index = 0; index < 2_048; index += 1) {
|
||||
const name = `${String(index).padStart(4, "0")}-filler.log`;
|
||||
fs.writeFileSync(path.join(packageLogs, name), "package filler", "utf8");
|
||||
fs.writeFileSync(path.join(itemLogs, name), "item filler", "utf8");
|
||||
}
|
||||
|
||||
initPackageLogs(root);
|
||||
initItemLogs(root);
|
||||
ensurePackageLog({
|
||||
packageId: "zzzz-active-package",
|
||||
name: "Active Package",
|
||||
outputDir: path.join(root, "output"),
|
||||
extractDir: path.join(root, "extract")
|
||||
});
|
||||
ensureItemLog({
|
||||
itemId: "zzzz-active-item",
|
||||
packageId: "zzzz-active-package",
|
||||
packageName: "Active Package",
|
||||
fileName: "active.bin",
|
||||
targetPath: path.join(root, "output", "active.bin")
|
||||
});
|
||||
logPackageEvent("zzzz-active-package", "INFO", "active-package-marker");
|
||||
logItemEvent("zzzz-active-item", "INFO", "active-item-marker");
|
||||
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["zzzz-active-package"],
|
||||
packages: {
|
||||
"zzzz-active-package": {
|
||||
id: "zzzz-active-package",
|
||||
name: "Active Package",
|
||||
status: "downloading",
|
||||
itemIds: ["zzzz-active-item"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"zzzz-active-item": {
|
||||
id: "zzzz-active-item",
|
||||
packageId: "zzzz-active-package",
|
||||
url: "https://files.example.test/active",
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 2,
|
||||
progressPercent: 50,
|
||||
fileName: "active.bin",
|
||||
targetPath: path.join(root, "output", "active.bin"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Lädt",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
onlineStatus: "online"
|
||||
}
|
||||
},
|
||||
runStartedAt: 1,
|
||||
totalDownloadedBytes: 1,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: 2
|
||||
},
|
||||
speedText: "Geschwindigkeit: 1 B/s",
|
||||
etaText: "ETA: 1s",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true
|
||||
};
|
||||
const manager = {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => { throw new Error("bundle export must not create package logs"); },
|
||||
getItemLogPath: () => { throw new Error("bundle export must not create item logs"); }
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
const buffer = await buildSupportBundle(manager, root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const zip = new AdmZip(buffer);
|
||||
const packageEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/package-logs/"));
|
||||
const itemEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
|
||||
const packageText = packageEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
|
||||
const itemText = itemEntries.map((entry) => entry.getData().toString("utf8")).join("\n");
|
||||
|
||||
expect(packageText).toContain("active-package-marker");
|
||||
expect(itemText).toContain("active-item-marker");
|
||||
expect(packageEntries.length).toBeLessThanOrEqual(8);
|
||||
expect(itemEntries.length).toBeLessThanOrEqual(16);
|
||||
}, 15_000);
|
||||
|
||||
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);
|
||||
@@ -344,6 +838,91 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
|
||||
});
|
||||
|
||||
it("redacts slash-escaped URLs at the ZIP boundary after credentials change", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-escaped-url-"));
|
||||
tempDirs.push(root);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(itemLogs, "escaped-url.log"),
|
||||
String.raw`{"url":"https:\/\/legacy-user:p!7@escaped-private-host.invalid\/secret?value=private"}`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const text = new AdmZip(buffer).getEntry("logs/item-logs/escaped-url.log")?.getData().toString("utf8") || "";
|
||||
|
||||
expect(text).toContain("<redacted-url>");
|
||||
expect(text).not.toContain("legacy-user");
|
||||
expect(text).not.toContain("p!7");
|
||||
expect(text).not.toContain("escaped-private-host.invalid");
|
||||
});
|
||||
|
||||
it("redacts historical comma-style account labels at the ZIP boundary", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-account-label-"));
|
||||
tempDirs.push(root);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
fs.writeFileSync(path.join(itemLogs, "historical-labels.log"), [
|
||||
"Mega-Debrid (Account 1/3, Hi*******cal): uebersprungen",
|
||||
"Debrid-Link (Key 2/4, old********value): fehlgeschlagen"
|
||||
].join("\n"), "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const text = new AdmZip(buffer).getEntry("logs/item-logs/historical-labels.log")?.getData().toString("utf8") || "";
|
||||
|
||||
expect(text).toContain("Account 1/3, <redacted-account>");
|
||||
expect(text).toContain("Key 2/4, <redacted-account>");
|
||||
expect(text).not.toContain("Hi*******cal");
|
||||
expect(text).not.toContain("old********value");
|
||||
});
|
||||
|
||||
it("keeps static archive directories stable when a short credential matches their name", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-static-path-"));
|
||||
tempDirs.push(root);
|
||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||
megaDebridWebCredentials: "archive-user:logs"
|
||||
}), "utf8");
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
fs.writeFileSync(path.join(itemLogs, "recent.log"), "diagnostic", "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName);
|
||||
|
||||
expect(entries).toContain("logs/item-logs/recent.log");
|
||||
});
|
||||
|
||||
it("keeps separately redacted log filenames distinct", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-archive-names-"));
|
||||
tempDirs.push(root);
|
||||
const itemLogs = path.join(root, "item-logs");
|
||||
fs.mkdirSync(itemLogs, { recursive: true });
|
||||
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-one.log"), "first-log-marker", "utf8");
|
||||
fs.writeFileSync(path.join(itemLogs, "abcdefghijklmnopqrstuvwxyz1234567890-two.log"), "second-log-marker", "utf8");
|
||||
|
||||
const buffer = await buildSupportBundle(fakeManager(), root, {
|
||||
hostDiagnosticsMode: "none",
|
||||
debugSetupMode: "deferred"
|
||||
});
|
||||
const entries = new AdmZip(buffer).getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/"));
|
||||
const text = entries.map((entry) => entry.getData().toString("utf8")).join("\n");
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(new Set(entries.map((entry) => entry.entryName)).size).toBe(2);
|
||||
expect(text).toContain("first-log-marker");
|
||||
expect(text).toContain("second-log-marker");
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -394,6 +973,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
});
|
||||
|
||||
describe("support bundle export runner", () => {
|
||||
it("emits busy, cancel, build, write and success lifecycle phases with deterministic durations", async () => {
|
||||
let now = 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 lifecycle: SupportBundleExportLifecycleEvent[] = [];
|
||||
let chooseCount = 0;
|
||||
const run = createSupportBundleExportRunner({
|
||||
now: () => now,
|
||||
chooseFile: async () => {
|
||||
chooseCount += 1;
|
||||
now += 5;
|
||||
return chooseCount === 1 ? "C:\\Private\\support.zip" : null;
|
||||
},
|
||||
build: async () => {
|
||||
signalBuildStarted();
|
||||
const buffer = await buildPending;
|
||||
now += 20;
|
||||
return buffer;
|
||||
},
|
||||
write: async () => {
|
||||
now += 30;
|
||||
},
|
||||
onLifecycle: (event) => {
|
||||
lifecycle.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
const first = run();
|
||||
await buildStarted;
|
||||
await expect(run()).resolves.toMatchObject({ saved: false, busy: true });
|
||||
releaseBuild(Buffer.from("zip"));
|
||||
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Private\\support.zip" });
|
||||
await expect(run()).resolves.toEqual({ saved: false, busy: false });
|
||||
|
||||
expect(lifecycle).toEqual([
|
||||
{ phase: "busy", durationMs: 0, totalDurationMs: 0 },
|
||||
{ phase: "build", durationMs: 20, totalDurationMs: 25, bytes: 3 },
|
||||
{ phase: "write", durationMs: 30, totalDurationMs: 55, bytes: 3 },
|
||||
{ phase: "success", durationMs: 55, totalDurationMs: 55, bytes: 3 },
|
||||
{ phase: "cancel", durationMs: 5, totalDurationMs: 5 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a path-free failure phase and duration when writing fails", async () => {
|
||||
let now = 100;
|
||||
const lifecycle: SupportBundleExportLifecycleEvent[] = [];
|
||||
const failures: unknown[] = [];
|
||||
const target = "C:\\Users\\Alice\\Desktop\\private-support.zip";
|
||||
const run = createSupportBundleExportRunner({
|
||||
now: () => now,
|
||||
chooseFile: async () => {
|
||||
now += 4;
|
||||
return target;
|
||||
},
|
||||
build: async () => {
|
||||
now += 6;
|
||||
return Buffer.from("zip");
|
||||
},
|
||||
write: async () => {
|
||||
now += 9;
|
||||
throw Object.assign(new Error(`ENOSPC while writing ${target}`), { code: "ENOSPC" });
|
||||
},
|
||||
onLifecycle: (event) => {
|
||||
lifecycle.push(event);
|
||||
},
|
||||
onFailure: (error) => {
|
||||
failures.push(error);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(run()).rejects.toMatchObject({
|
||||
name: "SupportBundleExportError",
|
||||
phase: "write",
|
||||
durationMs: 19,
|
||||
code: "ENOSPC"
|
||||
});
|
||||
expect(lifecycle).toEqual([
|
||||
{ phase: "build", durationMs: 6, totalDurationMs: 10, bytes: 3 },
|
||||
{
|
||||
phase: "failure",
|
||||
failedPhase: "write",
|
||||
durationMs: 9,
|
||||
totalDurationMs: 19,
|
||||
code: "ENOSPC"
|
||||
}
|
||||
]);
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(String((failures[0] as Error).message)).not.toContain(target);
|
||||
expect(String((failures[0] as Error).message)).not.toContain("Alice");
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -430,6 +1102,32 @@ describe("support bundle export runner", () => {
|
||||
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
it("records the selected export target before bundle construction starts", async () => {
|
||||
const phases: string[] = [];
|
||||
const run = createSupportBundleExportRunner({
|
||||
chooseFile: async () => {
|
||||
phases.push("choose");
|
||||
return "C:\\Temp\\support.zip";
|
||||
},
|
||||
onStart: ({ filePath }) => {
|
||||
phases.push(`start:${path.basename(filePath)}`);
|
||||
},
|
||||
build: async () => {
|
||||
phases.push("build");
|
||||
return Buffer.from("zip");
|
||||
},
|
||||
write: async () => {
|
||||
phases.push("write");
|
||||
},
|
||||
onSuccess: () => {
|
||||
phases.push("success");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: "C:\\Temp\\support.zip" });
|
||||
expect(phases).toEqual(["choose", "start:support.zip", "build", "write", "success"]);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -476,7 +1174,7 @@ describe("support bundle export runner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
await expect(run()).rejects.toThrow("write failed");
|
||||
await expect(run()).rejects.toMatchObject({ name: "SupportBundleExportError", phase: "write" });
|
||||
await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,34 @@ describe("trace-log", () => {
|
||||
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("redacts sensitive messages and fields before writing the trace log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-redaction-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
configureLogger(baseDir);
|
||||
initTraceLog(baseDir);
|
||||
setTraceEnabled(true, "redaction-test");
|
||||
logTraceEvent("WARN", "download", "Failed https://rapidgator.net/file/private at C:\\Users\\Admin\\Desktop\\private.rar", {
|
||||
directUrl: "https://cdn.example/private?token=secret",
|
||||
targetPath: "C:\\Users\\Admin\\Desktop\\private.rar",
|
||||
email: "user@example.com",
|
||||
password: "hunter2"
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const content = fs.readFileSync(getTraceLogPath()!, "utf8");
|
||||
expect(content).toContain("rapidgator.net#");
|
||||
expect(content).toContain("cdn.example#");
|
||||
expect(content).toContain("<redacted-path>");
|
||||
expect(content).toContain("<redacted-account>");
|
||||
expect(content).toContain("<redacted>");
|
||||
expect(content).not.toContain("/file/private");
|
||||
expect(content).not.toContain("C:\\Users\\Admin");
|
||||
expect(content).not.toContain("user@example.com");
|
||||
expect(content).not.toContain("hunter2");
|
||||
});
|
||||
|
||||
it("auto-disables support trace after the requested duration", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
Reference in New Issue
Block a user