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.
|
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
|
## [2.0.30] - 2026-08-13
|
||||||
|
|
||||||
### Rotation diagnostics
|
### Rotation diagnostics
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ The Downloads workspace is optimized for large queues:
|
|||||||
|
|
||||||
- Manage several accounts or API keys for the same provider.
|
- Manage several accounts or API keys for the same provider.
|
||||||
- Enable or disable individual accounts without deleting their saved data.
|
- 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.
|
- Check account status, remaining traffic, username, expiry, and access type.
|
||||||
- Configure primary, secondary, and tertiary provider fallback.
|
- Configure primary, secondary, and tertiary provider fallback.
|
||||||
- Route individual hosters through a specific provider.
|
- 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.
|
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
|
## Updates and changelog
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.30",
|
"version": "2.0.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.30",
|
"version": "2.0.31",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.30",
|
"version": "2.0.31",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -3,26 +3,43 @@ import { logTimestamp } from "./log-timestamp";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import type { RotationEvent } from "../shared/types";
|
import type { RotationEvent } from "../shared/types";
|
||||||
|
import { sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
export type RotationItemSink = (event: RotationEvent) => void;
|
export interface RotationCorrelationContext {
|
||||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
attemptId?: string;
|
||||||
|
itemId?: string;
|
||||||
|
packageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
export type CorrelatedRotationEvent = RotationEvent & RotationCorrelationContext;
|
||||||
return rotationItemContext.run(sink, fn);
|
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";
|
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
const ROTATION_EVENT_RING_MAX = 60;
|
const ROTATION_EVENT_RING_MAX = 60;
|
||||||
const rotationEventRing: RotationEvent[] = [];
|
const rotationEventRing: CorrelatedRotationEvent[] = [];
|
||||||
let rotationEventSeq = 0;
|
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;
|
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);
|
const slice = rotationEventRing.slice(-limit);
|
||||||
slice.reverse();
|
slice.reverse();
|
||||||
return slice;
|
return slice;
|
||||||
@@ -35,9 +52,10 @@ function pushRotationEvent(
|
|||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>,
|
fields?: Record<string, unknown>,
|
||||||
at = Date.now()
|
at = Date.now()
|
||||||
): void {
|
): CorrelatedRotationEvent {
|
||||||
rotationEventSeq += 1;
|
rotationEventSeq += 1;
|
||||||
const entry: RotationEvent = {
|
const context = rotationItemContext.getStore();
|
||||||
|
const entry: CorrelatedRotationEvent = {
|
||||||
id: `rot_${at}_${rotationEventSeq}`,
|
id: `rot_${at}_${rotationEventSeq}`,
|
||||||
at,
|
at,
|
||||||
level,
|
level,
|
||||||
@@ -47,13 +65,15 @@ function pushRotationEvent(
|
|||||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : 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 (context) {
|
||||||
if (itemSink) {
|
|
||||||
try {
|
try {
|
||||||
itemSink(entry);
|
context.sink(entry);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +91,7 @@ function pushRotationEvent(
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||||
@@ -83,13 +104,13 @@ function sanitizeFieldValue(value: unknown): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return sanitizeDiagnosticText(value);
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
@@ -162,7 +183,11 @@ export function logAccountRotation(
|
|||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): 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) {
|
if (!rotationLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,8 +196,14 @@ export function logAccountRotation(
|
|||||||
if (!fs.existsSync(rotationLogPath)) {
|
if (!fs.existsSync(rotationLogPath)) {
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||||
}
|
}
|
||||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
const head = `${logTimestamp()} [${level}] ${safeProvider} | ${safeAccountLabel} | ${safeEvent}`;
|
||||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
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 {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-43
@@ -63,6 +63,7 @@ import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log
|
|||||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||||
|
import type { SupportBundleExportLifecycleEvent } from "./support-bundle";
|
||||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||||
@@ -193,16 +194,9 @@ export class AppController {
|
|||||||
this.runtimeStatsTimer.unref?.();
|
this.runtimeStatsTimer.unref?.();
|
||||||
|
|
||||||
if (this.settings.autoResumeOnStart) {
|
if (this.settings.autoResumeOnStart) {
|
||||||
const snapshot = this.manager.getSnapshot();
|
void this.manager.waitForStartupRecovery().then(() => {
|
||||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
this.prepareAutoResume();
|
||||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
}).catch((err) => logger.warn(`Auto-Resume Startup-Recovery Fehler: ${String(err)}`));
|
||||||
if (this.onStateHandler) {
|
|
||||||
this.beginAutoResume();
|
|
||||||
} else {
|
|
||||||
this.autoResumePending = true;
|
|
||||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,18 +228,33 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
private prepareAutoResume(): void {
|
||||||
return Boolean(
|
const snapshot = this.manager.getSnapshot();
|
||||||
settings.token.trim()
|
const items = Object.values(snapshot.session.items);
|
||||||
|| settings.realDebridUseWebLogin
|
const pendingCount = items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length;
|
||||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
if (pendingCount === 0) {
|
||||||
|| settings.bestToken.trim()
|
this.audit("INFO", "Auto-Resume übersprungen", {
|
||||||
|| settings.bestDebridUseWebLogin
|
reason: "no_pending",
|
||||||
|| settings.allDebridUseWebLogin
|
itemCount: items.length
|
||||||
|| settings.allDebridToken.trim()
|
});
|
||||||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
return;
|
||||||
|| settings.oneFichierApiKey.trim()
|
}
|
||||||
);
|
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 {
|
public get onState(): ((snapshot: UiSnapshot) => void) | null {
|
||||||
@@ -709,16 +718,41 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public stop(): void {
|
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.manager.stop();
|
||||||
|
this.audit("INFO", "Session-Stopp angewendet", {
|
||||||
|
...this.sessionControlFields(this.manager.getSnapshot()),
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public togglePause(): boolean {
|
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();
|
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;
|
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 {
|
public retryExtraction(packageId: string): void {
|
||||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||||||
this.manager.retryExtraction(packageId);
|
this.manager.retryExtraction(packageId);
|
||||||
@@ -729,9 +763,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.manager.extractNow(packageId);
|
this.manager.extractNow(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetPackage(packageId: string): void {
|
public async resetPackage(packageId: string): Promise<void> {
|
||||||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
const startedAt = Date.now();
|
||||||
this.manager.resetPackage(packageId);
|
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 {
|
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 } {
|
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
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,
|
packageCount: selection.packageCount,
|
||||||
linkCount: selection.linkCount,
|
linkCount: selection.linkCount,
|
||||||
packageIds
|
packageIds
|
||||||
@@ -776,7 +821,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
|
|
||||||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
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,
|
packageCount: selection.packageCount,
|
||||||
linkCount: selection.linkCount,
|
linkCount: selection.linkCount,
|
||||||
itemIds
|
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 snapshot = this.manager.getSnapshot();
|
||||||
|
const items = Object.values(snapshot.session.items);
|
||||||
const fields = {
|
const fields = {
|
||||||
fileName: path.basename(filePath),
|
phase: "selected",
|
||||||
bytes,
|
running: snapshot.session.running,
|
||||||
|
paused: snapshot.session.paused,
|
||||||
packageCount: Object.keys(snapshot.session.packages).length,
|
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);
|
this.audit("INFO", "Support-Bundle-Ziel ausgewählt", fields);
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public recordSupportBundleExportFailed(error: unknown): void {
|
public recordSupportBundleExportLifecycle(event: SupportBundleExportLifecycleEvent): void {
|
||||||
const fields = {
|
const messages: Record<SupportBundleExportLifecycleEvent["phase"], string> = {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
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);
|
const level = event.phase === "failure" ? "ERROR" : event.phase === "busy" ? "WARN" : "INFO";
|
||||||
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields);
|
this.audit(level, messages[event.phase], { ...event });
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSupportBundleDefaultFileName(): string {
|
public getSupportBundleDefaultFileName(): string {
|
||||||
@@ -1113,9 +1165,25 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.manager.skipItems(itemIds);
|
this.manager.skipItems(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetItems(itemIds: string[]): void {
|
public async resetItems(itemIds: string[]): Promise<void> {
|
||||||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
const startedAt = Date.now();
|
||||||
this.manager.resetItems(itemIds);
|
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 {
|
public removeHistoryEntry(entryId: string): void {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
@@ -27,10 +28,11 @@ function sanitizeFieldValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
const safeFields = sanitizeDiagnosticFields(fields);
|
||||||
|
if (!safeFields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(safeFields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
@@ -93,7 +95,7 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
|||||||
}
|
}
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
auditLogPath,
|
auditLogPath,
|
||||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
`${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`,
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} 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 path from "node:path";
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
import { formatDiagnosticLink, sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
export interface ConversionPhase {
|
export interface ConversionPhase {
|
||||||
atMs: number;
|
atMs: number;
|
||||||
@@ -17,7 +18,9 @@ export interface ConversionPhase {
|
|||||||
|
|
||||||
export interface ConversionTrace {
|
export interface ConversionTrace {
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
|
attemptId?: string;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
packageId?: string;
|
||||||
itemName: string;
|
itemName: string;
|
||||||
link: string;
|
link: string;
|
||||||
providerOrder: string;
|
providerOrder: string;
|
||||||
@@ -27,9 +30,18 @@ export interface ConversionTrace {
|
|||||||
|
|
||||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||||
|
|
||||||
function shortLink(link: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
const raw = String(link || "").trim();
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
}
|
||||||
|
|
||||||
|
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 {
|
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||||
@@ -37,7 +49,16 @@ export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void
|
|||||||
if (!trace) {
|
if (!trace) {
|
||||||
return;
|
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 {
|
export function traceConversionNote(key: string, value: string | number): void {
|
||||||
@@ -45,7 +66,9 @@ export function traceConversionNote(key: string, value: string | number): void {
|
|||||||
if (!trace) {
|
if (!trace) {
|
||||||
return;
|
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 {
|
export function hasActiveConversionTrace(): boolean {
|
||||||
@@ -58,22 +81,30 @@ export function formatConversionBlock(
|
|||||||
detail: string,
|
detail: string,
|
||||||
totalMs: number
|
totalMs: number
|
||||||
): string {
|
): string {
|
||||||
const noteParts = Object.entries(trace.notes)
|
const safeNotes = sanitizeDiagnosticFields(trace.notes) || {};
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
const noteParts = Object.entries(safeNotes)
|
||||||
|
.map(([key, value]) => `${sanitizeConversionText(key, trace.itemName)}=${sanitizeConversionText(value, trace.itemName)}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
const safeOrder = sanitizeConversionText(trace.providerOrder || "?", trace.itemName);
|
||||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
const safeOutcome = sanitizeConversionText(outcome, trace.itemName);
|
||||||
+ ` | link=${shortLink(trace.link)}`;
|
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 lines = trace.phases.map((p) => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
if (p.provider) parts.push(`provider=${sanitizeConversionText(p.provider, trace.itemName)}`);
|
||||||
if (p.account) parts.push(`account=${p.account}`);
|
if (p.account) parts.push(`account=${sanitizeDiagnosticAccountLabel(p.account)}`);
|
||||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
if (p.tokenState) parts.push(`token=${sanitizeConversionText(p.tokenState, trace.itemName)}`);
|
||||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
if (p.outcome) parts.push(`outcome=${sanitizeConversionText(p.outcome, trace.itemName)}`);
|
||||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
if (p.detail) parts.push(`detail=${sanitizeConversionText(p.detail, trace.itemName)}`);
|
||||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
return ` +${p.atMs}ms ${sanitizeConversionText(p.phase, trace.itemName)}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||||
});
|
});
|
||||||
return [header, ...lines].join("\n");
|
return [header, ...lines].join("\n");
|
||||||
}
|
}
|
||||||
@@ -162,15 +193,17 @@ function writeConversionBlock(block: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runWithConversionTrace<T>(
|
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>
|
fn: () => Promise<T>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const trace: ConversionTrace = {
|
const trace: ConversionTrace = {
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
itemId: meta.itemId,
|
attemptId: meta.attemptId ? sanitizeDiagnosticText(meta.attemptId) : undefined,
|
||||||
itemName: meta.itemName,
|
itemId: sanitizeDiagnosticText(meta.itemId),
|
||||||
link: meta.link,
|
packageId: meta.packageId ? sanitizeDiagnosticText(meta.packageId) : undefined,
|
||||||
providerOrder: meta.providerOrder,
|
itemName: sanitizeDiagnosticText(meta.itemName),
|
||||||
|
link: formatDiagnosticLink(meta.link),
|
||||||
|
providerOrder: sanitizeDiagnosticText(meta.providerOrder),
|
||||||
notes: {},
|
notes: {},
|
||||||
phases: []
|
phases: []
|
||||||
};
|
};
|
||||||
@@ -181,7 +214,7 @@ export async function runWithConversionTrace<T>(
|
|||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outcome = "FAIL";
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
const totalMs = Date.now() - trace.startedAt;
|
const totalMs = Date.now() - trace.startedAt;
|
||||||
|
|||||||
+85
-31
@@ -8,6 +8,7 @@ import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { logAccountRotation } from "./account-rotation-log";
|
import { logAccountRotation } from "./account-rotation-log";
|
||||||
import { traceConversionPhase } from "./conversion-trace";
|
import { traceConversionPhase } from "./conversion-trace";
|
||||||
|
import { sanitizeDiagnosticText, type DiagnosticRedactions } from "./diagnostic-sanitizer";
|
||||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||||
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
||||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
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_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;
|
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_API_BASE = "https://debrid-link.com/api/v2";
|
||||||
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
||||||
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
||||||
@@ -333,6 +338,22 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
|||||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
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 {
|
export function getMegaDebridAccountAttemptTimeoutMs(): number {
|
||||||
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
|
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
|
||||||
return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000
|
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 {
|
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
|
||||||
|
const hasDedicatedPoolCredentials = Boolean(
|
||||||
|
String(settings.megaDebridApiCredentials || "").trim()
|
||||||
|
|| String(settings.megaDebridWebCredentials || "").trim()
|
||||||
|
);
|
||||||
if (mode === "api") {
|
if (mode === "api") {
|
||||||
return settings.megaDebridApiEnabled
|
return settings.megaDebridApiEnabled
|
||||||
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||||
}
|
}
|
||||||
return settings.megaDebridWebEnabled
|
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 {
|
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
|
||||||
@@ -768,6 +793,7 @@ function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal):
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
|
void promise.catch(() => {});
|
||||||
return Promise.reject(new Error("aborted:debrid"));
|
return Promise.reject(new Error("aborted:debrid"));
|
||||||
}
|
}
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@@ -1079,9 +1105,10 @@ async function requestDebridLinkPayloadWithKey(
|
|||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
const payload = parseJsonSafe(responseText);
|
const payload = parseJsonSafe(responseText);
|
||||||
if (!payload) {
|
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})`
|
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
|
||||||
: compactErrorText(responseText) || `Debrid-Link lieferte kein 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(
|
const error = new DebridLinkApiError(
|
||||||
response.status,
|
response.status,
|
||||||
"requestError",
|
"requestError",
|
||||||
@@ -1097,10 +1124,14 @@ async function requestDebridLinkPayloadWithKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok || !parseDebridLinkSuccess(payload)) {
|
if (!response.ok || !parseDebridLinkSuccess(payload)) {
|
||||||
|
const description = sanitizeDiagnosticText(
|
||||||
|
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
||||||
|
{ secretValues: [apiKey.token] }
|
||||||
|
);
|
||||||
const error = new DebridLinkApiError(
|
const error = new DebridLinkApiError(
|
||||||
response.status,
|
response.status,
|
||||||
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
|
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
|
||||||
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
description,
|
||||||
parseRetryAfterMs(response.headers.get("retry-after")),
|
parseRetryAfterMs(response.headers.get("retry-after")),
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
@@ -1116,9 +1147,9 @@ async function requestDebridLinkPayloadWithKey(
|
|||||||
if (error instanceof DebridLinkApiError) {
|
if (error instanceof DebridLinkApiError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
lastTransportError = compactErrorText(error);
|
lastTransportError = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
|
||||||
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
|
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
|
||||||
throw error;
|
throw new Error(lastTransportError);
|
||||||
}
|
}
|
||||||
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
|
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
|
||||||
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
|
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
|
||||||
@@ -1955,7 +1986,11 @@ class MegaDebridClient {
|
|||||||
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
||||||
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
const token = String(payload.token || "").trim();
|
const token = String(payload.token || "").trim();
|
||||||
@@ -2002,7 +2037,10 @@ class MegaDebridClient {
|
|||||||
if (tokenInvalidated) {
|
if (tokenInvalidated) {
|
||||||
this.clearTokenCache();
|
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() });
|
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) {
|
if (errorText) {
|
||||||
throw new Error(`Mega-Debrid API: ${errorText}`);
|
throw new Error(`Mega-Debrid API: ${errorText}`);
|
||||||
@@ -2144,8 +2182,8 @@ class MegaDebridClient {
|
|||||||
const entry = orderedEntries[orderPos];
|
const entry = orderedEntries[orderPos];
|
||||||
const account = entry.account;
|
const account = entry.account;
|
||||||
const idx = entry.idx;
|
const idx = entry.idx;
|
||||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
const accountLabel = ` (${account.label}/${totalAccounts})`;
|
||||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
const rotationLabel = `${account.label}/${totalAccounts}`;
|
||||||
|
|
||||||
if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
|
if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
|
||||||
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
|
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
|
||||||
@@ -2195,7 +2233,7 @@ class MegaDebridClient {
|
|||||||
: accountAttemptTimeoutSignal;
|
: accountAttemptTimeoutSignal;
|
||||||
try {
|
try {
|
||||||
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
||||||
const result = await client.unrestrictLink(link, accountAttemptSignal);
|
const result = await waitForPromiseWithSignal(client.unrestrictLink(link, accountAttemptSignal), accountAttemptSignal);
|
||||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
@@ -2221,9 +2259,10 @@ class MegaDebridClient {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const redactions = { accountValues: [account.login], secretValues: [account.password] };
|
||||||
|
const abortText = sanitizeProviderErrorText(error, redactions);
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw error;
|
throw new Error(abortText);
|
||||||
}
|
}
|
||||||
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||||
@@ -2278,7 +2317,7 @@ class MegaDebridClient {
|
|||||||
}
|
}
|
||||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||||
}
|
}
|
||||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
const failure = MegaDebridClient.classifyAccountFailure(error, redactions);
|
||||||
traceConversionPhase({
|
traceConversionPhase({
|
||||||
phase: "mega-account",
|
phase: "mega-account",
|
||||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
||||||
@@ -2326,7 +2365,7 @@ class MegaDebridClient {
|
|||||||
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
|
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
|
||||||
const nextAcc = orderedEntries[nextPos].account;
|
const nextAcc = orderedEntries[nextPos].account;
|
||||||
if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2364,9 +2403,10 @@ class MegaDebridClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static classifyAccountFailure(
|
static classifyAccountFailure(
|
||||||
error: unknown
|
error: unknown,
|
||||||
|
redactions: DiagnosticRedactions = {}
|
||||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
): { 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)) {
|
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
||||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
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) {
|
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
||||||
const apiKey = this.apiKeys[keyIdx];
|
const apiKey = this.apiKeys[keyIdx];
|
||||||
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
const keyLabel = ` (${apiKey.label}/${totalKeys})`;
|
||||||
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
const rotationLabel = `${apiKey.label}/${totalKeys}`;
|
||||||
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
||||||
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
|
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
|
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
|
||||||
@@ -2978,7 +3018,7 @@ class DebridLinkClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||||
const elapsedMs = Date.now() - testStartedAt;
|
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)) {
|
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||||
if (ranLongEnough) {
|
if (ranLongEnough) {
|
||||||
@@ -3059,7 +3099,7 @@ class DebridLinkClient {
|
|||||||
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
||||||
const nextKey = this.apiKeys[nextIdx];
|
const nextKey = this.apiKeys[nextIdx];
|
||||||
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
|
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
|
||||||
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
|
nextLabel = `${nextKey.label}/${totalKeys}`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3232,16 +3272,18 @@ class DebridLinkClient {
|
|||||||
link: string,
|
link: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
|
): 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) {
|
if (error instanceof DebridLinkApiError) {
|
||||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
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)) {
|
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
|
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"
|
category: "invalid"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3249,7 +3291,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
|
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"
|
category: "rate_limit"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3260,7 +3302,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs,
|
cooldownMs,
|
||||||
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
|
message: `Quota erreicht fuer ${hosterLabel} (${safeCode}: ${description})`,
|
||||||
category: "quota",
|
category: "quota",
|
||||||
hostOnly: true,
|
hostOnly: true,
|
||||||
hoster: hosterRaw
|
hoster: hosterRaw
|
||||||
@@ -3271,7 +3313,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs,
|
cooldownMs,
|
||||||
message: `Quota erreicht (${code}: ${description})`,
|
message: `Quota erreicht (${safeCode}: ${description})`,
|
||||||
category: "quota"
|
category: "quota"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3279,7 +3321,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
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",
|
category: "temporary",
|
||||||
providerWide: true
|
providerWide: true
|
||||||
};
|
};
|
||||||
@@ -3288,7 +3330,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: 0,
|
cooldownMs: 0,
|
||||||
message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`,
|
message: `Key kann Link aktuell nicht verarbeiten (${safeCode}: ${description})`,
|
||||||
category: "skip"
|
category: "skip"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3304,7 +3346,7 @@ class DebridLinkClient {
|
|||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||||
message: `temporärer API-Fehler (${code}: ${description})`
|
message: `temporärer API-Fehler (${safeCode}: ${description})`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -3748,7 +3790,19 @@ export class DebridService {
|
|||||||
const prev = this.settings;
|
const prev = this.settings;
|
||||||
this.settings = cloneSettings(next);
|
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.cachedDebridLinkClient = null;
|
||||||
this.cachedDebridLinkKey = "";
|
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 {
|
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;
|
return parseContentRange(contentRange)?.total ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,10 +876,8 @@ function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvid
|
|||||||
if (provider !== "megadebrid") {
|
if (provider !== "megadebrid") {
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
const apiEnabled = settings.megaDebridApiEnabled
|
const apiEnabled = settings.megaDebridApiEnabled;
|
||||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
const webEnabled = settings.megaDebridWebEnabled;
|
||||||
const webEnabled = settings.megaDebridWebEnabled
|
|
||||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim() && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
|
||||||
if (apiEnabled && !webEnabled) {
|
if (apiEnabled && !webEnabled) {
|
||||||
return "megadebrid-api";
|
return "megadebrid-api";
|
||||||
}
|
}
|
||||||
@@ -1735,10 +1738,13 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
|||||||
return Math.floor(jitter);
|
return Math.floor(jitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearResumeRecoveryState(item: DownloadItem): void {
|
function clearResumeRecoveryState(item: DownloadItem, includeHttp416Budget = true): void {
|
||||||
delete item.resumeLinkRenewalFailures;
|
delete item.resumeLinkRenewalFailures;
|
||||||
delete item.resumeHardResetUsed;
|
delete item.resumeHardResetUsed;
|
||||||
delete item.resumeResetPending;
|
delete item.resumeResetPending;
|
||||||
|
if (includeHttp416Budget) {
|
||||||
|
delete item.http416FreshRestarts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeResumePartialForReset(targetPath: string): Promise<boolean> {
|
async function removeResumePartialForReset(targetPath: string): Promise<boolean> {
|
||||||
@@ -1934,6 +1940,40 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private diskWaitEvents: NonNullable<UiSnapshot["diskWaitEvents"]> = [];
|
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 diskReservations = new DiskReservationCoordinator();
|
||||||
|
|
||||||
private diskLeasesByOwner = new Map<string, DiskReservationLease>();
|
private diskLeasesByOwner = new Map<string, DiskReservationLease>();
|
||||||
@@ -1946,8 +1986,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
unrestrictRetries: number;
|
unrestrictRetries: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
private http416FreshRestartByItem = new Map<string, number>();
|
|
||||||
|
|
||||||
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
|
||||||
|
|
||||||
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
|
||||||
@@ -1959,6 +1997,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private onHistoryEntryCallback?: HistoryEntryCallback;
|
private onHistoryEntryCallback?: HistoryEntryCallback;
|
||||||
|
|
||||||
|
private startupRecoveryPromise: Promise<number>;
|
||||||
|
|
||||||
|
private pausedForMissingAccount = false;
|
||||||
|
|
||||||
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
||||||
super();
|
super();
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -1996,7 +2038,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.restoreTargetPathReservations();
|
this.restoreTargetPathReservations();
|
||||||
this.resolveExistingQueuedOpaqueFilenames();
|
this.resolveExistingQueuedOpaqueFilenames();
|
||||||
this.revalidateCompletedItems();
|
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.recoverPostProcessingOnStartup();
|
||||||
this.checkExistingRapidgatorLinks();
|
this.checkExistingRapidgatorLinks();
|
||||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`));
|
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`));
|
||||||
@@ -2005,7 +2050,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.emitState(true);
|
this.emitState();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2013,6 +2058,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private rotationListenerActive = true;
|
private rotationListenerActive = true;
|
||||||
|
|
||||||
|
public waitForStartupRecovery(): Promise<number> {
|
||||||
|
return this.startupRecoveryPromise;
|
||||||
|
}
|
||||||
|
|
||||||
public getPackageLogPath(packageId: string): string | null {
|
public getPackageLogPath(packageId: string): string | null {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
@@ -2132,12 +2181,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
private logRotationEventForItem(item: DownloadItem, event: RotationEvent): void {
|
private logRotationEventForItem(item: DownloadItem, event: RotationEvent): void {
|
||||||
this.logItemOnly(item, event.level, "Account-Rotation", {
|
this.logItemOnly(item, event.level, "Account-Rotation", {
|
||||||
provider: event.provider,
|
provider: event.provider,
|
||||||
account: event.accountLabel,
|
accountLabel: event.accountLabel,
|
||||||
event: event.event,
|
event: event.event,
|
||||||
reason: event.reason,
|
reason: event.reason,
|
||||||
category: event.category,
|
category: event.category,
|
||||||
cooldownSec: event.cooldownSec,
|
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)
|
const previousMegaAccounts = (["api", "web"] as const)
|
||||||
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
|
.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 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]
|
const previousMegaPool = [...previousMegaPoolEntries]
|
||||||
.map(([key, password]) => `${key}:${password}`)
|
.map(([key, password]) => `${key}:${password}`)
|
||||||
|
.concat(`apiEnabled:${previousMegaApiEnabled}`, `webEnabled:${previousMegaWebEnabled}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const nextMegaAccounts = (["api", "web"] as const)
|
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)));
|
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
|
||||||
const nextMegaPool = [...nextMegaPoolEntries]
|
const nextMegaPool = [...nextMegaPoolEntries]
|
||||||
.map(([key, password]) => `${key}:${password}`)
|
.map(([key, password]) => `${key}:${password}`)
|
||||||
|
.concat(`apiEnabled:${nextMegaApiEnabled}`, `webEnabled:${nextMegaWebEnabled}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
|
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
|
||||||
.map((account) => `${account.id}:${account.password}`)
|
.map((account) => `${account.id}:${account.password}`)
|
||||||
|
.concat(`webEnabled:${previousMegaWebEnabled}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
|
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
|
||||||
.map((account) => `${account.id}:${account.password}`)
|
.map((account) => `${account.id}:${account.password}`)
|
||||||
|
.concat(`webEnabled:${nextMegaWebEnabled}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
const megaPoolChanged = previousMegaPool !== nextMegaPool;
|
const megaPoolChanged = previousMegaPool !== nextMegaPool;
|
||||||
const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool;
|
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.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
@@ -2343,7 +2406,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
||||||
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
||||||
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
{ 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.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
|
||||||
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
||||||
{
|
{
|
||||||
@@ -2375,6 +2438,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
changedAccountKeys.add(key);
|
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);
|
clearMegaDebridAccountRuntimeStates(changedAccountKeys);
|
||||||
for (const key of [...this.providerFailures.keys()]) {
|
for (const key of [...this.providerFailures.keys()]) {
|
||||||
if (isMegaDebridProviderKey(key)) {
|
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) {
|
if (!opts?.settingsOnlyImport && megaPoolChanged && nextMegaAccounts.length > 0) {
|
||||||
this.releaseMegaDebridResetParks();
|
this.releaseMegaDebridResetParks();
|
||||||
}
|
}
|
||||||
@@ -2643,7 +2758,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public getStats(now = nowMs()): DownloadStats {
|
public getStats(now = nowMs()): DownloadStats {
|
||||||
const itemCount = this.itemCount;
|
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;
|
return this.statsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4323,21 +4438,20 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const sourceName = path.basename(sourcePath);
|
const sourceName = path.basename(sourcePath);
|
||||||
let result: VideoProcessResult | null = null;
|
let result: VideoProcessResult | null = null;
|
||||||
let remuxLease: DiskReservationLease | null = null;
|
let remuxLease: DiskReservationLease | null = null;
|
||||||
|
const remuxOwnerId = pkg?.id || sourcePath;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
const sourceBytes = (await fs.promises.stat(sourcePath)).size;
|
const sourceBytes = (await fs.promises.stat(sourcePath)).size;
|
||||||
remuxLease = await this.diskReservations.reserve({
|
remuxLease = await this.diskReservations.reserve({
|
||||||
phase: "remux",
|
phase: "remux",
|
||||||
ownerId: pkg?.id || sourcePath,
|
ownerId: remuxOwnerId,
|
||||||
targetPath: sourcePath,
|
targetPath: sourcePath,
|
||||||
requiredBytes: sourceBytes
|
requiredBytes: sourceBytes
|
||||||
});
|
});
|
||||||
|
this.resolveDiskWait(remuxOwnerId, "remux");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DiskCapacityError) {
|
if (error instanceof DiskCapacityError) {
|
||||||
this.diskWaitEvents = [{
|
this.recordDiskWait(error.event, pkg ? { packageId: pkg.id } : {});
|
||||||
...error.event,
|
|
||||||
...(pkg ? { packageId: pkg.id } : {})
|
|
||||||
}];
|
|
||||||
result = { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux" };
|
result = { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux" };
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -5452,20 +5566,44 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!pkg) return;
|
if (!pkg) return;
|
||||||
|
|
||||||
const itemIds = [...pkg.itemIds];
|
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) {
|
for (const itemId of itemIds) {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
|
||||||
const active = this.activeTasks.get(itemId);
|
|
||||||
if (active) {
|
|
||||||
active.abortReason = "reset";
|
|
||||||
active.abortController.abort("reset");
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = String(item.targetPath || "").trim();
|
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) {
|
if (targetPath) {
|
||||||
try { fs.rmSync(targetPath, { force: true }); } catch { }
|
|
||||||
this.releaseTargetPath(itemId);
|
this.releaseTargetPath(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5491,7 +5629,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
|
|
||||||
this.runCompletedPackages.delete(packageId);
|
this.runCompletedPackages.delete(packageId);
|
||||||
|
|
||||||
pkg.status = "queued";
|
pkg.status = "queued";
|
||||||
@@ -5518,7 +5655,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runPackageIds.add(packageId);
|
this.runPackageIds.add(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.allSettled(postProcessTasks);
|
|
||||||
if (pkg.outputDir) {
|
if (pkg.outputDir) {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
clearExtractResumeState(pkg.outputDir, packageId),
|
clearExtractResumeState(pkg.outputDir, packageId),
|
||||||
@@ -5531,26 +5667,60 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetPackage): ${compactErrorText(err)}`));
|
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> {
|
public async resetItems(itemIds: string[]): Promise<void> {
|
||||||
const affectedPackageIds = new Set<string>();
|
const affectedPackageIds = new Set<string>();
|
||||||
const postProcessTasks = new Set<Promise<void>>();
|
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) {
|
for (const itemId of itemIds) {
|
||||||
const item = this.session.items[itemId];
|
const item = this.session.items[itemId];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
|
||||||
affectedPackageIds.add(item.packageId);
|
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();
|
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) {
|
if (targetPath) {
|
||||||
try { fs.rmSync(targetPath, { force: true }); } catch { }
|
|
||||||
this.releaseTargetPath(itemId);
|
this.releaseTargetPath(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5582,7 +5752,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const pkgId of affectedPackageIds) {
|
for (const pkgId of affectedPackageIds) {
|
||||||
for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task);
|
|
||||||
this.runCompletedPackages.delete(pkgId);
|
this.runCompletedPackages.delete(pkgId);
|
||||||
this.historyRecordedPackages.delete(pkgId);
|
this.historyRecordedPackages.delete(pkgId);
|
||||||
this.notifiedPackages.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) => {
|
await Promise.allSettled([...affectedPackageIds].flatMap((pkgId) => {
|
||||||
const pkg = this.session.packages[pkgId];
|
const pkg = this.session.packages[pkgId];
|
||||||
return pkg?.outputDir ? [clearExtractResumeState(pkg.outputDir, pkgId)] : [];
|
return pkg?.outputDir ? [clearExtractResumeState(pkg.outputDir, pkgId)] : [];
|
||||||
@@ -5613,6 +5781,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (resetItems): ${compactErrorText(err)}`));
|
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 {
|
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||||||
@@ -5689,6 +5860,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public async startPackages(packageIds: string[]): Promise<void> {
|
public async startPackages(packageIds: string[]): Promise<void> {
|
||||||
this.ensureUsableDownloadAccount();
|
this.ensureUsableDownloadAccount();
|
||||||
|
this.pausedForMissingAccount = false;
|
||||||
const targetSet = new Set(packageIds);
|
const targetSet = new Set(packageIds);
|
||||||
|
|
||||||
for (const pkgId of targetSet) {
|
for (const pkgId of targetSet) {
|
||||||
@@ -5784,6 +5956,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public async startItems(itemIds: string[]): Promise<void> {
|
public async startItems(itemIds: string[]): Promise<void> {
|
||||||
this.ensureUsableDownloadAccount();
|
this.ensureUsableDownloadAccount();
|
||||||
|
this.pausedForMissingAccount = false;
|
||||||
const targetSet = new Set(itemIds);
|
const targetSet = new Set(itemIds);
|
||||||
|
|
||||||
const affectedPackageIds = new Set<string>();
|
const affectedPackageIds = new Set<string>();
|
||||||
@@ -5893,6 +6066,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.ensureUsableDownloadAccount();
|
this.ensureUsableDownloadAccount();
|
||||||
|
this.pausedForMissingAccount = false;
|
||||||
this.schedulerGeneration += 1;
|
this.schedulerGeneration += 1;
|
||||||
|
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
@@ -5997,7 +6171,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.providerStartReservations.clear();
|
this.providerStartReservations.clear();
|
||||||
this.pacedStartReservationByItem.clear();
|
this.pacedStartReservationByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
this.http416FreshRestartByItem.clear();
|
|
||||||
this.itemContributedBytes.clear();
|
this.itemContributedBytes.clear();
|
||||||
this.reservedTargetPaths.clear();
|
this.reservedTargetPaths.clear();
|
||||||
this.claimedTargetPathByItem.clear();
|
this.claimedTargetPathByItem.clear();
|
||||||
@@ -6046,6 +6219,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.schedulerGeneration += 1;
|
this.schedulerGeneration += 1;
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
|
this.pausedForMissingAccount = false;
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
@@ -6121,6 +6295,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
|
this.pausedForMissingAccount = false;
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||||
@@ -6544,10 +6719,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private emitState(force = false): void {
|
private emitState(force = false): void {
|
||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
const MIN_FORCE_GAP_MS = 120;
|
const minGapMs = this.session.running ? 500 : 120;
|
||||||
if (force) {
|
if (force) {
|
||||||
const sinceLastEmit = now - this.lastStateEmitAt;
|
const sinceLastEmit = now - this.lastStateEmitAt;
|
||||||
if (sinceLastEmit >= MIN_FORCE_GAP_MS) {
|
if (this.lastStateEmitAt === 0 || sinceLastEmit >= minGapMs) {
|
||||||
if (this.stateEmitTimer) {
|
if (this.stateEmitTimer) {
|
||||||
clearTimeout(this.stateEmitTimer);
|
clearTimeout(this.stateEmitTimer);
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
@@ -6557,29 +6732,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.stateEmitTimer) {
|
if (this.stateEmitTimer) {
|
||||||
clearTimeout(this.stateEmitTimer);
|
return;
|
||||||
this.stateEmitTimer = null;
|
|
||||||
}
|
}
|
||||||
this.stateEmitTimer = setTimeout(() => {
|
this.stateEmitTimer = setTimeout(() => {
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
this.lastStateEmitAt = nowMs();
|
this.lastStateEmitAt = nowMs();
|
||||||
this.emit("state", this.getSnapshotForEmit());
|
this.emit("state", this.getSnapshotForEmit());
|
||||||
}, MIN_FORCE_GAP_MS - sinceLastEmit);
|
}, minGapMs - sinceLastEmit);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.stateEmitTimer) {
|
if (this.stateEmitTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const itemCount = this.itemCount;
|
const emitDelay = this.session.running ? 500 : 200;
|
||||||
const emitDelay = this.session.running
|
|
||||||
? itemCount >= 1500
|
|
||||||
? 500
|
|
||||||
: itemCount >= 700
|
|
||||||
? 500
|
|
||||||
: itemCount >= 250
|
|
||||||
? 300
|
|
||||||
: 150
|
|
||||||
: 200;
|
|
||||||
this.stateEmitTimer = setTimeout(() => {
|
this.stateEmitTimer = setTimeout(() => {
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
this.lastStateEmitAt = nowMs();
|
this.lastStateEmitAt = nowMs();
|
||||||
@@ -8267,11 +8432,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (effectiveProvider === "megadebrid-api") {
|
if (effectiveProvider === "megadebrid-api") {
|
||||||
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "api").length > 0;
|
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") {
|
if (effectiveProvider === "megadebrid-web") {
|
||||||
const hasMegaCreds = getAvailableMegaDebridAccounts(this.settings, "web").length > 0;
|
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") {
|
if (effectiveProvider === "bestdebrid") {
|
||||||
return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim());
|
return Boolean(this.settings.bestDebridUseWebLogin || this.settings.bestToken.trim());
|
||||||
@@ -9103,28 +9268,23 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleHttp416Retry(
|
private async scheduleHttp416Retry(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
active: ActiveTask,
|
active: ActiveTask,
|
||||||
retryDisplayLimit: string,
|
retryDisplayLimit: string,
|
||||||
errorText: string,
|
errorText: string,
|
||||||
claimedTargetPath: string
|
claimedTargetPath: string
|
||||||
): void {
|
): Promise<void> {
|
||||||
active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1;
|
active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1;
|
||||||
item.retries += 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.lastError = errorText;
|
||||||
item.downloadedBytes = 0;
|
item.resumeResetPending = true;
|
||||||
item.totalBytes = null;
|
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||||
item.progressPercent = 0;
|
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||||
item.speedBps = 0;
|
if (!resetApplied) {
|
||||||
|
this.queueRetry(item, active, 1000, "Warte auf Teildatei-Freigabe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const delayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
const delayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`HTTP 416 erkannt: item=${item.fileName || item.id}, ` +
|
`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}`);
|
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
|
private async escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): Promise<void> {
|
||||||
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
|
const freshRestarts = Math.max(0, Number(item.http416FreshRestarts || 0));
|
||||||
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
|
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
|
||||||
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
|
|
||||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||||
if (resetTargetPath) {
|
item.resumeResetPending = true;
|
||||||
try {
|
item.lastError = errorText;
|
||||||
fs.rmSync(resetTargetPath, { force: true });
|
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||||
} catch {
|
if (!resetApplied) {
|
||||||
}
|
this.queueRetry(item, active, 1000, "Warte auf Teildatei-Freigabe");
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.releaseTargetPath(item.id);
|
item.http416FreshRestarts = freshRestarts + 1;
|
||||||
this.dropItemContribution(item.id);
|
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
item.downloadedBytes = 0;
|
|
||||||
item.totalBytes = null;
|
|
||||||
item.progressPercent = 0;
|
|
||||||
item.speedBps = 0;
|
|
||||||
item.lastError = "";
|
item.lastError = "";
|
||||||
active.genericErrorRetries = 0;
|
active.genericErrorRetries = 0;
|
||||||
active.freshRetryUsed = false;
|
active.freshRetryUsed = false;
|
||||||
@@ -9164,7 +9321,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.http416FreshRestartByItem.delete(item.id);
|
delete item.http416FreshRestarts;
|
||||||
item.status = "failed";
|
item.status = "failed";
|
||||||
this.recordRunOutcome(item.id, "failed");
|
this.recordRunOutcome(item.id, "failed");
|
||||||
item.lastError = errorText;
|
item.lastError = errorText;
|
||||||
@@ -9241,6 +9398,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
void this.processItem(active).catch((err) => {
|
void this.processItem(active).catch((err) => {
|
||||||
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
|
if (this.activeTasks.get(itemId) !== active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.diskLeasesByOwner.get(itemId)?.release();
|
this.diskLeasesByOwner.get(itemId)?.release();
|
||||||
this.diskLeasesByOwner.delete(itemId);
|
this.diskLeasesByOwner.delete(itemId);
|
||||||
if (!this.retryAfterByItem.has(item.id)) {
|
if (!this.retryAfterByItem.has(item.id)) {
|
||||||
@@ -9366,12 +9526,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
|
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
|
||||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||||
let unrestricted;
|
let unrestricted;
|
||||||
|
const conversionAttemptId = uuidv4();
|
||||||
try {
|
try {
|
||||||
unrestricted = await runWithRotationItemSink(
|
unrestricted = await runWithRotationItemSink(
|
||||||
(event) => this.logRotationEventForItem(item, event),
|
(event) => this.logRotationEventForItem(item, event),
|
||||||
() => runWithConversionTrace(
|
() => runWithConversionTrace(
|
||||||
{
|
{
|
||||||
itemId: item.id,
|
itemId: item.id,
|
||||||
|
packageId: item.packageId,
|
||||||
|
attemptId: conversionAttemptId,
|
||||||
itemName: item.fileName || item.id,
|
itemName: item.fileName || item.id,
|
||||||
link: item.url,
|
link: item.url,
|
||||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
||||||
@@ -9392,7 +9555,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
throw innerError;
|
throw innerError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
),
|
||||||
|
{
|
||||||
|
attemptId: conversionAttemptId,
|
||||||
|
itemId: item.id,
|
||||||
|
packageId: item.packageId
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
@@ -9451,13 +9619,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
this.diskLeasesByOwner.get(item.id)?.release();
|
this.diskLeasesByOwner.get(item.id)?.release();
|
||||||
this.diskLeasesByOwner.set(item.id, diskLease);
|
this.diskLeasesByOwner.set(item.id, diskLease);
|
||||||
|
this.resolveDiskWait(item.id, "download");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DiskCapacityError) {
|
if (error instanceof DiskCapacityError) {
|
||||||
this.diskWaitEvents = [{
|
this.recordDiskWait(error.event, { itemId: item.id, packageId: pkg.id });
|
||||||
...error.event,
|
|
||||||
itemId: item.id,
|
|
||||||
packageId: pkg.id
|
|
||||||
}];
|
|
||||||
this.releaseTargetPath(item.id);
|
this.releaseTargetPath(item.id);
|
||||||
this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte");
|
this.queueRetry(item, active, Math.max(1000, error.event.retryAt - nowMs()), "Warte auf Festplatte");
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
@@ -9473,14 +9638,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`);
|
logger.info(`Download Start: ${item.fileName} (${humanSize(unrestricted.fileSize || 0)}) via ${pLabel}, pkg=${pkg.name}`);
|
||||||
this.logPackageForItem(item, "INFO", "Link umgewandelt", {
|
this.logPackageForItem(item, "INFO", "Link umgewandelt", {
|
||||||
|
conversionAttemptId,
|
||||||
provider: unrestricted.provider,
|
provider: unrestricted.provider,
|
||||||
providerLabel: unrestricted.providerLabel || "",
|
providerLabel: unrestricted.providerLabel || "",
|
||||||
accountId: unrestricted.sourceAccountId || "",
|
|
||||||
accountLabel: unrestricted.sourceAccountLabel || "",
|
accountLabel: unrestricted.sourceAccountLabel || "",
|
||||||
sizeBytes: unrestricted.fileSize,
|
sizeBytes: unrestricted.fileSize,
|
||||||
targetPath: item.targetPath,
|
targetPath: item.targetPath,
|
||||||
directHost,
|
directHost,
|
||||||
directUrl: unrestricted.directUrl,
|
directLink: unrestricted.directUrl,
|
||||||
resumableHint: unrestricted.retriesUsed >= 0
|
resumableHint: unrestricted.retriesUsed >= 0
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -9644,6 +9809,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reason = active.abortReason;
|
const reason = active.abortReason;
|
||||||
|
if (reason === "reset" && this.activeTasks.get(item.id) !== active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
|
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
|
||||||
if (reason === "cancel") {
|
if (reason === "cancel") {
|
||||||
this.logPackageForItem(item, "WARN", "Download abgebrochen durch Entfernen", {
|
this.logPackageForItem(item, "WARN", "Download abgebrochen durch Entfernen", {
|
||||||
@@ -9680,6 +9848,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
} else if (reason === "pause") {
|
} else if (reason === "pause") {
|
||||||
|
this.logPackageForItem(item, "WARN", "Download pausiert", {
|
||||||
|
reason,
|
||||||
|
downloadedBytes: item.downloadedBytes,
|
||||||
|
totalBytes: item.totalBytes,
|
||||||
|
progressPercent: item.progressPercent
|
||||||
|
});
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.fullStatus = "Pausiert";
|
item.fullStatus = "Pausiert";
|
||||||
@@ -9715,6 +9889,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||||
});
|
});
|
||||||
} else if (reason === "reset") {
|
} 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);
|
this.retryStateByItem.delete(item.id);
|
||||||
} else if (reason === "settings_refresh") {
|
} else if (reason === "settings_refresh") {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
@@ -9850,12 +10030,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (isHttp416Text(exhaustedReason)) {
|
if (isHttp416Text(exhaustedReason)) {
|
||||||
if (active.genericErrorRetries < maxHttp416Retries) {
|
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
await this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
await this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
||||||
@@ -9905,12 +10085,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const isHttp416 = isHttp416Text(errorText);
|
const isHttp416 = isHttp416Text(errorText);
|
||||||
if (isHttp416) {
|
if (isHttp416) {
|
||||||
if (active.genericErrorRetries < maxHttp416Retries) {
|
if (active.genericErrorRetries < maxHttp416Retries) {
|
||||||
this.scheduleHttp416Retry(item, active, retryDisplayLimit, errorText, claimedTargetPath);
|
await this.scheduleHttp416Retry(item, active, retryDisplayLimit, errorText, claimedTargetPath);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
await this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shouldFreshRetry) {
|
if (shouldFreshRetry) {
|
||||||
@@ -10381,9 +10561,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const partialRemoved = await removeResumePartialForReset(effectiveTargetPath);
|
||||||
await fs.promises.rm(effectiveTargetPath, { force: true });
|
if (!partialRemoved) {
|
||||||
} catch {
|
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);
|
this.dropItemContribution(active.itemId);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
@@ -10626,6 +10816,31 @@ export class DownloadManager extends EventEmitter {
|
|||||||
start: preAllocated ? 0 : undefined,
|
start: preAllocated ? 0 : undefined,
|
||||||
highWaterMark: STREAM_HIGH_WATER_MARK
|
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;
|
written = writeMode === "a" ? existingBytes : 0;
|
||||||
let windowBytes = 0;
|
let windowBytes = 0;
|
||||||
let windowStarted = nowMs();
|
let windowStarted = nowMs();
|
||||||
@@ -10734,6 +10949,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
const alignedFlush = async (final = false): Promise<void> => {
|
const alignedFlush = async (final = false): Promise<void> => {
|
||||||
if (writeBufPos === 0) return;
|
if (writeBufPos === 0) return;
|
||||||
|
throwStreamFailure();
|
||||||
let toWrite = writeBufPos;
|
let toWrite = writeBufPos;
|
||||||
if (!final && toWrite > ALLOCATION_UNIT_SIZE) {
|
if (!final && toWrite > ALLOCATION_UNIT_SIZE) {
|
||||||
toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE);
|
toWrite = toWrite - (toWrite % ALLOCATION_UNIT_SIZE);
|
||||||
@@ -10742,6 +10958,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!stream.write(slice)) {
|
if (!stream.write(slice)) {
|
||||||
await waitDrain();
|
await waitDrain();
|
||||||
}
|
}
|
||||||
|
throwStreamFailure();
|
||||||
if (toWrite < writeBufPos) {
|
if (toWrite < writeBufPos) {
|
||||||
writeBuf.copy(writeBuf, 0, toWrite, writeBufPos);
|
writeBuf.copy(writeBuf, 0, toWrite, writeBufPos);
|
||||||
}
|
}
|
||||||
@@ -10838,7 +11055,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await readWithTimeout();
|
const { done, value } = await raceStreamFailure(readWithTimeout());
|
||||||
if (done) {
|
if (done) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -10996,6 +11213,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await alignedFlush(true);
|
await alignedFlush(true);
|
||||||
|
throwStreamFailure();
|
||||||
} catch (flushError) {
|
} catch (flushError) {
|
||||||
if (!bodyError) {
|
if (!bodyError) {
|
||||||
bodyError = flushError;
|
bodyError = flushError;
|
||||||
@@ -11003,6 +11221,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (streamFailure) {
|
||||||
|
reject(streamFailure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (stream.closed || stream.destroyed) {
|
if (stream.closed || stream.destroyed) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
@@ -11023,18 +11245,47 @@ export class DownloadManager extends EventEmitter {
|
|||||||
stream.once("error", onError);
|
stream.once("error", onError);
|
||||||
stream.end();
|
stream.end();
|
||||||
});
|
});
|
||||||
|
throwStreamFailure();
|
||||||
} catch (streamCloseError) {
|
} catch (streamCloseError) {
|
||||||
if (!stream.destroyed) {
|
if (!stream.destroyed) {
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
}
|
}
|
||||||
if (!bodyError) {
|
if (!bodyError) {
|
||||||
throw streamCloseError;
|
bodyError = streamCloseError;
|
||||||
|
} else {
|
||||||
|
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
|
||||||
}
|
}
|
||||||
logger.warn(`Stream-Abschlussfehler unterdrückt: ${compactErrorText(streamCloseError)}`);
|
|
||||||
}
|
}
|
||||||
if (!stream.destroyed) {
|
if (!stream.destroyed) {
|
||||||
stream.destroy();
|
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) {
|
if (!bodyError && preAllocated) {
|
||||||
try {
|
try {
|
||||||
const syncFd = await fs.promises.open(effectiveTargetPath, "r");
|
const syncFd = await fs.promises.open(effectiveTargetPath, "r");
|
||||||
@@ -11046,8 +11297,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
if (bodyError) {
|
if (bodyError) {
|
||||||
|
stream.off("error", onStreamFailure);
|
||||||
throw bodyError;
|
throw bodyError;
|
||||||
}
|
}
|
||||||
|
stream.off("error", onStreamFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -11345,12 +11598,27 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
clearResumeRecoveryState(item);
|
|
||||||
const targetPath = String(item.targetPath || "").trim();
|
const targetPath = String(item.targetPath || "").trim();
|
||||||
if (options.hardReset && targetPath) {
|
if (options.hardReset && targetPath) {
|
||||||
|
let removalErrorCode = "";
|
||||||
try {
|
try {
|
||||||
fs.rmSync(targetPath, { force: true });
|
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);
|
this.releaseTargetPath(item.id);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
@@ -11359,6 +11627,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.dropItemContribution(item.id);
|
this.dropItemContribution(item.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearResumeRecoveryState(item, false);
|
||||||
|
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.attempts = 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)
|
requiredBytes: archiveSizes.every((size) => size === null) ? null : archiveSizes.reduce<number>((total, size) => total + Math.max(0, size || 0), 0)
|
||||||
});
|
});
|
||||||
diskLease.release();
|
diskLease.release();
|
||||||
|
this.resolveDiskWait(packageId, "extract");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DiskCapacityError) {
|
if (error instanceof DiskCapacityError) {
|
||||||
this.diskWaitEvents = [{ ...error.event, packageId }];
|
this.recordDiskWait(error.event, { packageId });
|
||||||
const retryAt = error.event.retryAt;
|
const retryAt = error.event.retryAt;
|
||||||
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
|
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
|
|||||||
+16
-5
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||||
@@ -54,10 +55,11 @@ function sanitizeFieldValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
const safeFields = sanitizeDiagnosticFields(fields);
|
||||||
|
if (!safeFields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(safeFields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
@@ -165,9 +167,14 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
|||||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||||
initializedThisProcess.add(normalizedItemId);
|
initializedThisProcess.add(normalizedItemId);
|
||||||
const startedAt = logTimestamp();
|
const startedAt = logTimestamp();
|
||||||
|
const headerFields = sanitizeDiagnosticFields({
|
||||||
|
itemId: String(meta.itemId || ""),
|
||||||
|
logKey: normalizedItemId,
|
||||||
|
fileName: meta.fileName
|
||||||
|
}) || {};
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
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"
|
"utf8"
|
||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
@@ -197,7 +204,7 @@ export function logItemEvent(
|
|||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||||
appendLine(itemId, line);
|
appendLine(itemId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,12 +216,16 @@ export function getItemLogPath(itemId: string): string | null {
|
|||||||
return fs.existsSync(logPath) ? logPath : null;
|
return fs.existsSync(logPath) ? logPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownItemLogs(): void {
|
export function flushItemLogs(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shutdownItemLogs(): void {
|
||||||
|
flushItemLogs();
|
||||||
for (const itemId of knownLogPaths.keys()) {
|
for (const itemId of knownLogPaths.keys()) {
|
||||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
|
|||||||
+30
-18
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import { recordRecentError } from "./error-ring";
|
import { recordRecentError } from "./error-ring";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -33,7 +34,7 @@ let legacyLogListener: LogListener | null = null;
|
|||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let pendingChars = 0;
|
let pendingChars = 0;
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
let flushInFlight = false;
|
let flushInFlight: Promise<void> | null = null;
|
||||||
let exitHookAttached = false;
|
let exitHookAttached = false;
|
||||||
|
|
||||||
export function setLogListener(listener: LogListener | null): void {
|
export function setLogListener(listener: LogListener | null): void {
|
||||||
@@ -71,6 +72,17 @@ export function flushLoggerSync(): void {
|
|||||||
flushSyncPending();
|
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 } {
|
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
@@ -185,17 +197,7 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushAsync(): Promise<void> {
|
async function performAsyncFlush(): 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.
|
|
||||||
const linesSnapshot = pendingLines;
|
const linesSnapshot = pendingLines;
|
||||||
pendingLines = [];
|
pendingLines = [];
|
||||||
pendingChars = 0;
|
pendingChars = 0;
|
||||||
@@ -216,9 +218,6 @@ async function flushAsync(): Promise<void> {
|
|||||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||||
}
|
}
|
||||||
if (!wroteAny) {
|
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);
|
pendingLines = linesSnapshot.concat(pendingLines);
|
||||||
pendingChars += chunk.length;
|
pendingChars += chunk.length;
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
@@ -230,13 +229,25 @@ async function flushAsync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
flushInFlight = false;
|
flushInFlight = null;
|
||||||
if (pendingLines.length > 0) {
|
if (pendingLines.length > 0) {
|
||||||
scheduleFlush();
|
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 {
|
function ensureExitHook(): void {
|
||||||
if (exitHookAttached) {
|
if (exitHookAttached) {
|
||||||
return;
|
return;
|
||||||
@@ -249,14 +260,15 @@ function ensureExitHook(): void {
|
|||||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
ensureExitHook();
|
ensureExitHook();
|
||||||
const ts = logTimestamp();
|
const ts = logTimestamp();
|
||||||
const line = `${ts} [${level}] ${message}\n`;
|
const safeMessage = sanitizeDiagnosticText(message);
|
||||||
|
const line = `${ts} [${level}] ${safeMessage}\n`;
|
||||||
pendingLines.push(line);
|
pendingLines.push(line);
|
||||||
pendingChars += line.length;
|
pendingChars += line.length;
|
||||||
|
|
||||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||||
// "what failed recently" is answerable even after the file rotates.
|
// "what failed recently" is answerable even after the file rotates.
|
||||||
if (level === "ERROR" || level === "WARN") {
|
if (level === "ERROR" || level === "WARN") {
|
||||||
recordRecentError(level, message, ts);
|
recordRecentError(level, safeMessage, ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
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 { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||||
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
|
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
|
||||||
|
import { writeClipboardTextFromIpc } from "./clipboard-ipc";
|
||||||
|
|
||||||
function validateString(value: unknown, name: string): string {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -612,13 +613,9 @@ function registerIpcHandlers(): void {
|
|||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => {
|
ipcMain.handle(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (event, text: unknown) => (
|
||||||
if (typeof text !== "string" || text.length > 16 * 1024 * 1024) {
|
writeClipboardTextFromIpc(event, text, getTrustedIpcOptions())
|
||||||
throw new Error("Ungültiger Zwischenablageinhalt");
|
));
|
||||||
}
|
|
||||||
clipboard.writeText(text);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||||
@@ -684,10 +681,10 @@ function registerIpcHandlers(): void {
|
|||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||||
return result.canceled || !result.filePath ? null : result.filePath;
|
return result.canceled || !result.filePath ? null : result.filePath;
|
||||||
},
|
},
|
||||||
|
onStart: () => controller.recordSupportBundleExportSelected(),
|
||||||
build: async () => (await controller.exportSupportBundle()).buffer,
|
build: async () => (await controller.exportSupportBundle()).buffer,
|
||||||
write: writeSupportBundleAtomically,
|
write: writeSupportBundleAtomically,
|
||||||
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
|
onLifecycle: (event) => controller.recordSupportBundleExportLifecycle(event)
|
||||||
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
|
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
|
||||||
|
|||||||
+16
-5
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||||
@@ -53,10 +54,11 @@ function sanitizeFieldValue(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
const safeFields = sanitizeDiagnosticFields(fields);
|
||||||
|
if (!safeFields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(safeFields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
@@ -164,9 +166,14 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
|||||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||||
initializedThisProcess.add(normalizedPackageId);
|
initializedThisProcess.add(normalizedPackageId);
|
||||||
const startedAt = logTimestamp();
|
const startedAt = logTimestamp();
|
||||||
|
const headerFields = sanitizeDiagnosticFields({
|
||||||
|
packageId: String(meta.packageId || ""),
|
||||||
|
logKey: normalizedPackageId,
|
||||||
|
name: meta.name
|
||||||
|
}) || {};
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
logPath,
|
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"
|
"utf8"
|
||||||
);
|
);
|
||||||
fs.appendFileSync(
|
fs.appendFileSync(
|
||||||
@@ -195,7 +202,7 @@ export function logPackageEvent(
|
|||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||||
appendLine(packageId, line);
|
appendLine(packageId, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +214,16 @@ export function getPackageLogPath(packageId: string): string | null {
|
|||||||
return fs.existsSync(logPath) ? logPath : null;
|
return fs.existsSync(logPath) ? logPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownPackageLogs(): void {
|
export function flushPackageLogs(): void {
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shutdownPackageLogs(): void {
|
||||||
|
flushPackageLogs();
|
||||||
for (const packageId of knownLogPaths.keys()) {
|
for (const packageId of knownLogPaths.keys()) {
|
||||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
|
|||||||
@@ -101,16 +101,20 @@ export function getSessionLogPath(): string | null {
|
|||||||
return sessionLogPath;
|
return sessionLogPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shutdownSessionLog(): void {
|
export function flushSessionLog(): void {
|
||||||
if (!sessionLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shutdownSessionLog(): void {
|
||||||
|
if (!sessionLogPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushSessionLog();
|
||||||
|
|
||||||
const isoTimestamp = logTimestamp();
|
const isoTimestamp = logTimestamp();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
|
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
|
||||||
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
|
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
|
||||||
resumeResetPending: Boolean(item.resumeResetPending) || 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,
|
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
||||||
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||||
updatedAt: clampNumber(item.updatedAt, 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 { getConversionLogPath } from "./conversion-trace";
|
||||||
import { getAuditLogPath } from "./audit-log";
|
import { getAuditLogPath } from "./audit-log";
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
import { getDebugSetupCheck } from "./debug-setup";
|
||||||
import { getLogFilePath } from "./logger";
|
import { flushLogger, getLogFilePath } from "./logger";
|
||||||
import { getRecentErrors } from "./error-ring";
|
import { getRecentErrors } from "./error-ring";
|
||||||
import { getRenameLogPath } from "./rename-log";
|
import { getRenameLogPath } from "./rename-log";
|
||||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||||
import { getSessionLogPath } from "./session-log";
|
import { flushSessionLog, getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadSettings } from "./storage";
|
import { createStoragePaths, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
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 { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
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 { DownloadManager } from "./download-manager";
|
||||||
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
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);
|
return String.fromCharCode(0xf8ff - offset);
|
||||||
};
|
};
|
||||||
const urlMarker = findMarker(raw, 0);
|
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);
|
const pathMarker = findMarker(output, 1);
|
||||||
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
|
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
|
||||||
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
|
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
|
||||||
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\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(?: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(?: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(/\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(/(["']?(?: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>");
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string {
|
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>, redactFileName: boolean): string {
|
||||||
return redactSupportText(zipPath, sensitiveValues)
|
const parts = zipPath.split("/");
|
||||||
.split("/")
|
return parts
|
||||||
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry")
|
.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("/");
|
.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +230,8 @@ async function addTextFileIfExists(
|
|||||||
sensitiveValues: ReadonlySet<string>,
|
sensitiveValues: ReadonlySet<string>,
|
||||||
budget: TextBudget,
|
budget: TextBudget,
|
||||||
maxFileBytes: number,
|
maxFileBytes: number,
|
||||||
maxAgeMs?: number
|
maxAgeMs?: number,
|
||||||
|
redactArchiveFileName = false
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!sourcePath || budget.remainingBytes <= 0) {
|
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -231,7 +251,7 @@ async function addTextFileIfExists(
|
|||||||
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
||||||
}
|
}
|
||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
|
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
||||||
includedSourcePaths.add(sourcePathKey);
|
includedSourcePaths.add(sourcePathKey);
|
||||||
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
|
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
|
||||||
return true;
|
return true;
|
||||||
@@ -250,6 +270,9 @@ async function addRecentDirectoryFiles(
|
|||||||
sensitiveValues: ReadonlySet<string>,
|
sensitiveValues: ReadonlySet<string>,
|
||||||
budget: TextBudget
|
budget: TextBudget
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
if (maxFiles <= 0 || budget.remainingBytes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
|
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
|
||||||
let directory;
|
let directory;
|
||||||
try {
|
try {
|
||||||
@@ -292,7 +315,45 @@ async function addRecentDirectoryFiles(
|
|||||||
includedSourcePaths,
|
includedSourcePaths,
|
||||||
sensitiveValues,
|
sensitiveValues,
|
||||||
budget,
|
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;
|
added += 1;
|
||||||
}
|
}
|
||||||
@@ -304,12 +365,38 @@ function isActiveStatus(status: unknown): boolean {
|
|||||||
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
|
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 {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name,
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
itemCount: entry.itemIds.length,
|
itemCount: entry.itemIds.length,
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
cancelled: entry.cancelled,
|
cancelled: entry.cancelled,
|
||||||
enabled: entry.enabled,
|
enabled: entry.enabled,
|
||||||
priority: entry.priority,
|
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 {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
packageId: entry.packageId,
|
packageId: entry.packageId,
|
||||||
@@ -345,12 +432,16 @@ function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
|||||||
downloadedBytes: entry.downloadedBytes,
|
downloadedBytes: entry.downloadedBytes,
|
||||||
totalBytes: entry.totalBytes,
|
totalBytes: entry.totalBytes,
|
||||||
progressPercent: entry.progressPercent,
|
progressPercent: entry.progressPercent,
|
||||||
fileName: entry.fileName,
|
fileName,
|
||||||
targetPath: entry.targetPath ? "<local-path>" : "",
|
targetPath: entry.targetPath ? "<local-path>" : "",
|
||||||
resumable: entry.resumable,
|
resumable: entry.resumable,
|
||||||
attempts: entry.attempts,
|
attempts: entry.attempts,
|
||||||
lastError: entry.lastError,
|
lastError: entry.lastError,
|
||||||
fullStatus: entry.fullStatus,
|
fullStatus: entry.fullStatus,
|
||||||
|
resumeLinkRenewalFailures: entry.resumeLinkRenewalFailures,
|
||||||
|
resumeHardResetUsed: entry.resumeHardResetUsed,
|
||||||
|
resumeResetPending: entry.resumeResetPending,
|
||||||
|
http416FreshRestarts: entry.http416FreshRestarts,
|
||||||
createdAt: entry.createdAt,
|
createdAt: entry.createdAt,
|
||||||
updatedAt: entry.updatedAt,
|
updatedAt: entry.updatedAt,
|
||||||
onlineStatus: entry.onlineStatus
|
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 {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name,
|
name,
|
||||||
status: entry.status,
|
status: entry.status,
|
||||||
provider: entry.provider,
|
provider: entry.provider,
|
||||||
fileCount: entry.fileCount,
|
fileCount: entry.fileCount,
|
||||||
@@ -403,7 +494,11 @@ async function loadBoundedHistory(filePath: string): Promise<{ total: number | n
|
|||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
return { total: 0, entries: [], omitted: 0 };
|
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) };
|
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
|
||||||
} catch {
|
} catch {
|
||||||
return { total: 0, entries: [], omitted: 0 };
|
return { total: 0, entries: [], omitted: 0 };
|
||||||
@@ -436,20 +531,64 @@ interface SupportBundleExportSuccess {
|
|||||||
bytes: number;
|
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 {
|
interface SupportBundleExportRunnerOptions {
|
||||||
chooseFile: () => Promise<string | null>;
|
chooseFile: () => Promise<string | null>;
|
||||||
build: () => Promise<Buffer>;
|
build: () => Promise<Buffer>;
|
||||||
write: (filePath: string, buffer: Buffer) => Promise<void>;
|
write: (filePath: string, buffer: Buffer) => Promise<void>;
|
||||||
|
now?: () => number;
|
||||||
|
onStart?: (result: { filePath: string }) => Promise<void> | void;
|
||||||
onSuccess?: (result: SupportBundleExportSuccess) => 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(
|
export function createSupportBundleExportRunner(
|
||||||
options: SupportBundleExportRunnerOptions
|
options: SupportBundleExportRunnerOptions
|
||||||
): () => Promise<SupportBundleExportResult> {
|
): () => Promise<SupportBundleExportResult> {
|
||||||
let active = false;
|
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 () => {
|
return async () => {
|
||||||
if (active) {
|
if (active) {
|
||||||
|
await emitLifecycle({ phase: "busy", durationMs: 0, totalDurationMs: 0 });
|
||||||
return {
|
return {
|
||||||
saved: false,
|
saved: false,
|
||||||
busy: true,
|
busy: true,
|
||||||
@@ -457,28 +596,77 @@ export function createSupportBundleExportRunner(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
active = true;
|
active = true;
|
||||||
|
const startedAt = now();
|
||||||
|
let phase: "choose" | "build" | "write" = "choose";
|
||||||
|
let phaseStartedAt = startedAt;
|
||||||
try {
|
try {
|
||||||
const filePath = await options.chooseFile();
|
const filePath = await options.chooseFile();
|
||||||
if (!filePath) {
|
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 };
|
return { saved: false, busy: false };
|
||||||
}
|
}
|
||||||
|
if (options.onStart) {
|
||||||
|
try {
|
||||||
|
await options.onStart({ filePath });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase = "build";
|
||||||
|
phaseStartedAt = now();
|
||||||
const buffer = await options.build();
|
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);
|
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) {
|
if (options.onSuccess) {
|
||||||
try {
|
try {
|
||||||
await options.onSuccess({ filePath, bytes: buffer.length });
|
await options.onSuccess({ filePath, bytes: buffer.length });
|
||||||
} catch {
|
} 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 };
|
return { saved: true, busy: false, filePath };
|
||||||
} catch (error) {
|
} 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) {
|
if (options.onFailure) {
|
||||||
try {
|
try {
|
||||||
await options.onFailure(error);
|
await options.onFailure(safeError);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw safeError;
|
||||||
} finally {
|
} finally {
|
||||||
active = false;
|
active = false;
|
||||||
}
|
}
|
||||||
@@ -545,27 +733,136 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
|||||||
return getWindowsHostDiagnostics();
|
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> {
|
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
const includedSourcePaths = new Set<string>();
|
const includedSourcePaths = new Set<string>();
|
||||||
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
|
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
|
||||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||||
|
const debugSetupMode = options.debugSetupMode || "full";
|
||||||
|
const generatedAt = new Date().toISOString();
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
const storagePaths = createStoragePaths(baseDir);
|
||||||
const settings = loadSettings(storagePaths);
|
const settings = loadSettings(storagePaths);
|
||||||
const sensitiveValues = collectSensitiveValues(settings);
|
const sensitiveValues = collectSensitiveValues(settings);
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const packageEntries = Object.values(snapshot.session.packages);
|
const packageEntries = Object.values(snapshot.session.packages);
|
||||||
const itemEntries = Object.values(snapshot.session.items);
|
const itemEntries = Object.values(snapshot.session.items);
|
||||||
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
|
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
||||||
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
|
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 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." }
|
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
|
||||||
: getDebugSetupCheck(baseDir);
|
: getDebugSetupCheck(baseDir);
|
||||||
|
|
||||||
await addJson(zip, "overview/meta.json", {
|
await addJson(zip, "overview/meta.json", {
|
||||||
appVersion: APP_VERSION,
|
appVersion: APP_VERSION,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt,
|
||||||
runtimeBaseDir: "<local-path>",
|
runtimeBaseDir: "<local-path>",
|
||||||
packageCount: packageEntries.length,
|
packageCount: packageEntries.length,
|
||||||
itemCount: itemEntries.length,
|
itemCount: itemEntries.length,
|
||||||
@@ -574,7 +871,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
itemDtos: MAX_ITEM_DTOS,
|
itemDtos: MAX_ITEM_DTOS,
|
||||||
textBytes: MAX_TOTAL_TEXT_BYTES,
|
textBytes: MAX_TOTAL_TEXT_BYTES,
|
||||||
textFileBytes: MAX_TEXT_FILE_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);
|
}, sensitiveValues);
|
||||||
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
|
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
|
||||||
@@ -589,7 +887,6 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
}
|
}
|
||||||
}, sensitiveValues);
|
}, sensitiveValues);
|
||||||
await addJson(zip, "overview/debug-setup.json", debugSetup, 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/history.json", history, sensitiveValues);
|
||||||
await addJson(zip, "overview/packages.json", {
|
await addJson(zip, "overview/packages.json", {
|
||||||
count: packageEntries.length,
|
count: packageEntries.length,
|
||||||
@@ -603,6 +900,17 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
|||||||
omitted: Math.max(0, itemEntries.length - selectedItems.length),
|
omitted: Math.max(0, itemEntries.length - selectedItems.length),
|
||||||
items: selectedItems
|
items: selectedItems
|
||||||
}, sensitiveValues);
|
}, 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/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
|
||||||
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
|
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
|
||||||
const recentErrors = getRecentErrors().slice(-100);
|
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(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||||
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
|
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 mainLogPath = getLogFilePath();
|
||||||
const auditLogPath = getAuditLogPath();
|
const auditLogPath = getAuditLogPath();
|
||||||
const renameLogPath = getRenameLogPath();
|
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 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, "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, "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, MAX_ITEM_LOG_FILES, 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);
|
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
|
||||||
if (supportManifest) {
|
if (supportManifest) {
|
||||||
|
|||||||
+17
-11
@@ -3,6 +3,7 @@ import { logTimestamp } from "./log-timestamp";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { addLogListener, removeLogListener } from "./logger";
|
import { addLogListener, removeLogListener } from "./logger";
|
||||||
import type { SupportTraceConfig } from "../shared/types";
|
import type { SupportTraceConfig } from "../shared/types";
|
||||||
|
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||||
|
|
||||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
@@ -33,23 +34,24 @@ function sanitizeFieldValue(value: unknown): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
return sanitizeDiagnosticText(value);
|
||||||
}
|
}
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return sanitizeDiagnosticText(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
function formatFields(fields?: Record<string, unknown>): string {
|
||||||
if (!fields) {
|
const safeFields = sanitizeDiagnosticFields(fields);
|
||||||
|
if (!safeFields) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parts = Object.entries(fields)
|
const parts = Object.entries(safeFields)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
@@ -288,7 +290,15 @@ export function logTraceEvent(
|
|||||||
if (category === "audit" && !traceConfig.includeAudit) {
|
if (category === "audit" && !traceConfig.includeAudit) {
|
||||||
return;
|
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 {
|
export function shutdownTraceLog(): void {
|
||||||
@@ -297,11 +307,7 @@ export function shutdownTraceLog(): void {
|
|||||||
if (!traceLogPath) {
|
if (!traceLogPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (flushTimer) {
|
flushTraceLog();
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
flushPending();
|
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+151
-92
@@ -38,7 +38,12 @@ import {
|
|||||||
getProviderDailyUsageBytes,
|
getProviderDailyUsageBytes,
|
||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} 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 { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||||
import type { AccountModeFilter } 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;
|
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||||
|
|
||||||
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _activeTab: MainView): number {
|
||||||
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
return 0;
|
||||||
if (!running) delay = Math.min(delay, 200);
|
}
|
||||||
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
|
|
||||||
return delay;
|
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 {
|
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 ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
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 {
|
function formatMbpsInputFromKbps(kbps: number): string {
|
||||||
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
||||||
return String(Number(mbps.toFixed(2)));
|
return String(Number(mbps.toFixed(2)));
|
||||||
@@ -1571,9 +1609,49 @@ export function App(): ReactElement {
|
|||||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
snapshotRef.current = 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);
|
const tabRef = useRef(tab);
|
||||||
tabRef.current = tab;
|
tabRef.current = tab;
|
||||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
@@ -1592,10 +1670,6 @@ export function App(): ReactElement {
|
|||||||
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
|
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
|
||||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||||
const activeTabRef = useRef<Tab>(tab);
|
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 [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||||
@@ -1605,6 +1679,7 @@ export function App(): ReactElement {
|
|||||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
|
const [resetBusy, setResetBusy] = useState(false);
|
||||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||||
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
|
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
|
||||||
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
|
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
|
||||||
@@ -1612,6 +1687,7 @@ export function App(): ReactElement {
|
|||||||
const accountToggleRevisionRef = useRef(0);
|
const accountToggleRevisionRef = useRef(0);
|
||||||
const accountTogglePendingRef = useRef(0);
|
const accountTogglePendingRef = useRef(0);
|
||||||
const actionBusyRef = useRef(false);
|
const actionBusyRef = useRef(false);
|
||||||
|
const resetUiActionGateRef = useRef<ResetUiActionGate>({ busy: false });
|
||||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
||||||
@@ -1734,34 +1810,6 @@ export function App(): ReactElement {
|
|||||||
activeTabRef.current = tab;
|
activeTabRef.current = tab;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||||
}, [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 applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
||||||
const availableIds = entries.map((entry) => entry.id);
|
const availableIds = entries.map((entry) => entry.id);
|
||||||
const availableSet = new Set(availableIds);
|
const availableSet = new Set(availableIds);
|
||||||
@@ -1943,8 +2008,7 @@ export function App(): ReactElement {
|
|||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
masterSnapshotRef.current = state;
|
applyAuthoritativeSnapshot(state);
|
||||||
setSnapshot(state);
|
|
||||||
if (state.settings.columnOrder?.length > 0) {
|
if (state.settings.columnOrder?.length > 0) {
|
||||||
setColumnOrder(state.settings.columnOrder);
|
setColumnOrder(state.settings.columnOrder);
|
||||||
}
|
}
|
||||||
@@ -1989,8 +2053,7 @@ export function App(): ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
merged = wireState;
|
merged = wireState;
|
||||||
}
|
}
|
||||||
masterSnapshotRef.current = merged;
|
latestStateRef.current = stageAuthoritativeSnapshot(merged);
|
||||||
latestStateRef.current = merged;
|
|
||||||
if (stateFlushTimerRef.current) { return; }
|
if (stateFlushTimerRef.current) { return; }
|
||||||
|
|
||||||
const itemCount = Object.keys(merged.session.items).length;
|
const itemCount = Object.keys(merged.session.items).length;
|
||||||
@@ -2000,6 +2063,7 @@ export function App(): ReactElement {
|
|||||||
stateFlushTimerRef.current = null;
|
stateFlushTimerRef.current = null;
|
||||||
if (latestStateRef.current) {
|
if (latestStateRef.current) {
|
||||||
const next = latestStateRef.current;
|
const next = latestStateRef.current;
|
||||||
|
snapshotRef.current = next;
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
if (next.settings.columnOrder?.length > 0) {
|
if (next.settings.columnOrder?.length > 0) {
|
||||||
setColumnOrder(next.settings.columnOrder);
|
setColumnOrder(next.settings.columnOrder);
|
||||||
@@ -2055,7 +2119,7 @@ export function App(): ReactElement {
|
|||||||
if (unsubClipboard) { unsubClipboard(); }
|
if (unsubClipboard) { unsubClipboard(); }
|
||||||
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
||||||
};
|
};
|
||||||
}, [clearImportQueueFocusListener]);
|
}, [applyAuthoritativeSnapshot, clearImportQueueFocusListener, stageAuthoritativeSnapshot]);
|
||||||
|
|
||||||
const downloadsTabActive = tab === "downloads";
|
const downloadsTabActive = tab === "downloads";
|
||||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||||
@@ -2093,24 +2157,12 @@ export function App(): ReactElement {
|
|||||||
if (!downloadsTabActive) {
|
if (!downloadsTabActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCollapsedPackages((prev) => {
|
setCollapsedPackages((prev) => reconcileCollapsedPackageState(
|
||||||
let changed = false;
|
prev,
|
||||||
const next: Record<string, boolean> = { ...prev };
|
snapshot.session.packageOrder,
|
||||||
const defaultCollapsed = totalPackageCount >= 24;
|
snapshot.session.packages,
|
||||||
for (const packageId of snapshot.session.packageOrder) {
|
totalPackageCount >= 24
|
||||||
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;
|
|
||||||
});
|
|
||||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||||
|
|
||||||
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
// 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);
|
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
|
||||||
}
|
}
|
||||||
|
|
||||||
await window.rd.start();
|
try {
|
||||||
|
await window.rd.start();
|
||||||
|
} finally {
|
||||||
|
await reconcileAuthoritativeSnapshot().catch(() => undefined);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4686,7 +4742,7 @@ export function App(): ReactElement {
|
|||||||
canStart: snapshot.canStart,
|
canStart: snapshot.canStart,
|
||||||
canPause: snapshot.canPause,
|
canPause: snapshot.canPause,
|
||||||
canStop: snapshot.canStop,
|
canStop: snapshot.canStop,
|
||||||
actionBusy,
|
actionBusy: actionBusy || resetBusy,
|
||||||
reconnectSeconds: snapshot.reconnectSeconds,
|
reconnectSeconds: snapshot.reconnectSeconds,
|
||||||
reconnectReason: snapshot.session.reconnectReason,
|
reconnectReason: snapshot.session.reconnectReason,
|
||||||
clipboardWatcher: snapshot.clipboardActive,
|
clipboardWatcher: snapshot.clipboardActive,
|
||||||
@@ -4712,7 +4768,7 @@ export function App(): ReactElement {
|
|||||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||||
eta: snapshot.etaText
|
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 = {
|
const downloadsActions: DownloadsViewActions = {
|
||||||
onDisplayModeChange: setDownloadDisplayMode,
|
onDisplayModeChange: setDownloadDisplayMode,
|
||||||
@@ -4732,10 +4788,7 @@ export function App(): ReactElement {
|
|||||||
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
|
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
const fresh = await window.rd.getSnapshot();
|
await reconcileAuthoritativeSnapshot();
|
||||||
masterSnapshotRef.current = fresh;
|
|
||||||
latestStateRef.current = null;
|
|
||||||
setSnapshot(fresh);
|
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4746,11 +4799,11 @@ export function App(): ReactElement {
|
|||||||
},
|
},
|
||||||
onPauseDownloads: () => {
|
onPauseDownloads: () => {
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
||||||
void window.rd.togglePause().then((paused) => {
|
void window.rd.togglePause().then(async () => {
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
|
await reconcileAuthoritativeSnapshot();
|
||||||
}).catch(async (error) => {
|
}).catch(async (error) => {
|
||||||
try {
|
try {
|
||||||
setSnapshot(await window.rd.getSnapshot());
|
await reconcileAuthoritativeSnapshot();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
|
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
@@ -4758,9 +4811,11 @@ export function App(): ReactElement {
|
|||||||
},
|
},
|
||||||
onStopDownloads: () => {
|
onStopDownloads: () => {
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
|
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 {
|
try {
|
||||||
setSnapshot(await window.rd.getSnapshot());
|
await reconcileAuthoritativeSnapshot();
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
|
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
|
||||||
@@ -4920,7 +4975,7 @@ export function App(): ReactElement {
|
|||||||
if (failedIds.length === 0) {
|
if (failedIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void window.rd.resetItems(failedIds).catch(() => {});
|
void performReset(() => window.rd.resetItems(failedIds));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const collectorActions: CollectorViewActions = {
|
const collectorActions: CollectorViewActions = {
|
||||||
@@ -6097,17 +6152,21 @@ export function App(): ReactElement {
|
|||||||
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
||||||
)}
|
)}
|
||||||
{hasPackages && !contextMenu.itemId && (
|
{hasPackages && !contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||||
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
|
void performReset(async () => {
|
||||||
|
for (const id of selectedPackageIds) {
|
||||||
|
await window.rd.resetPackage(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedPackageIds.length})` : ""}`}</button>
|
||||||
)}
|
)}
|
||||||
{contextMenu.itemId && (
|
{contextMenu.itemId && (
|
||||||
<button className="ctx-menu-item" onClick={() => {
|
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||||
void window.rd.resetItems(itemIds).catch(() => {});
|
void performReset(() => window.rd.resetItems(itemIds));
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedItemIds.length})` : ""}`}</button>
|
||||||
)}
|
)}
|
||||||
{hasPackages && !multi && (() => {
|
{hasPackages && !multi && (() => {
|
||||||
const pkg = snapshot.session.packages[contextMenu.packageId];
|
const pkg = snapshot.session.packages[contextMenu.packageId];
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type AccountToggleTarget =
|
|||||||
export interface AccountToggleSettings {
|
export interface AccountToggleSettings {
|
||||||
disabledProviders: DebridProvider[];
|
disabledProviders: DebridProvider[];
|
||||||
debridLinkDisabledKeyIds: string[];
|
debridLinkDisabledKeyIds: string[];
|
||||||
|
megaDebridApiEnabled: boolean;
|
||||||
|
megaDebridWebEnabled: boolean;
|
||||||
megaDebridDisabledAccountIds: string[];
|
megaDebridDisabledAccountIds: string[];
|
||||||
megaDebridApiDisabledAccountIds: string[];
|
megaDebridApiDisabledAccountIds: string[];
|
||||||
megaDebridWebDisabledAccountIds: string[];
|
megaDebridWebDisabledAccountIds: string[];
|
||||||
@@ -45,6 +47,8 @@ export function setAccountTargetEnabled<T extends AccountToggleSettings>(
|
|||||||
: settings.megaDebridWebDisabledAccountIds;
|
: settings.megaDebridWebDisabledAccountIds;
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
|
megaDebridApiEnabled: target.kind === "mega-api" && enabled ? true : settings.megaDebridApiEnabled,
|
||||||
|
megaDebridWebEnabled: target.kind === "mega-web" && enabled ? true : settings.megaDebridWebEnabled,
|
||||||
megaDebridApiDisabledAccountIds: apiDisabled,
|
megaDebridApiDisabledAccountIds: apiDisabled,
|
||||||
megaDebridWebDisabledAccountIds: webDisabled,
|
megaDebridWebDisabledAccountIds: webDisabled,
|
||||||
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
|
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
|
||||||
@@ -55,6 +59,8 @@ export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings
|
|||||||
return {
|
return {
|
||||||
disabledProviders: settings.disabledProviders,
|
disabledProviders: settings.disabledProviders,
|
||||||
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
|
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
|
||||||
|
megaDebridApiEnabled: settings.megaDebridApiEnabled,
|
||||||
|
megaDebridWebEnabled: settings.megaDebridWebEnabled,
|
||||||
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
|
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
|
||||||
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
|
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
|
||||||
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
|
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
|
||||||
|
|||||||
@@ -27,3 +27,78 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
|||||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||||
return packages;
|
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";
|
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 {
|
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
|
||||||
const previousRef = useRef({ numericValue, value });
|
const previousRef = useRef({ numericValue, value });
|
||||||
const sequenceRef = useRef(0);
|
const sequenceRef = useRef(0);
|
||||||
@@ -34,7 +41,10 @@ export function RollingMetricValue({ numericValue, value }: RollingMetricValuePr
|
|||||||
if (previous.value === value && previous.numericValue === numericValue) return;
|
if (previous.value === value && previous.numericValue === numericValue) return;
|
||||||
previousRef.current = { numericValue, value };
|
previousRef.current = { numericValue, value };
|
||||||
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
|
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);
|
setTransition(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export interface DownloadItem {
|
|||||||
resumeLinkRenewalFailures?: number;
|
resumeLinkRenewalFailures?: number;
|
||||||
resumeHardResetUsed?: boolean;
|
resumeHardResetUsed?: boolean;
|
||||||
resumeResetPending?: boolean;
|
resumeResetPending?: boolean;
|
||||||
|
http416FreshRestarts?: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
onlineStatus?: "online" | "offline" | "checking";
|
onlineStatus?: "online" | "offline" | "checking";
|
||||||
@@ -460,6 +461,9 @@ export interface RotationEvent {
|
|||||||
category?: string;
|
category?: string;
|
||||||
cooldownSec?: number;
|
cooldownSec?: number;
|
||||||
next?: string;
|
next?: string;
|
||||||
|
attemptId?: string;
|
||||||
|
itemId?: string;
|
||||||
|
packageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UiSnapshot {
|
export interface UiSnapshot {
|
||||||
@@ -485,7 +489,11 @@ export interface UiSnapshot {
|
|||||||
requiredBytes: number;
|
requiredBytes: number;
|
||||||
availableBytes: number;
|
availableBytes: number;
|
||||||
deficitBytes: number;
|
deficitBytes: number;
|
||||||
|
safetyBytes: number;
|
||||||
retryAt: number;
|
retryAt: number;
|
||||||
|
at: number;
|
||||||
|
state: "waiting" | "resolved";
|
||||||
|
resolvedAt?: number;
|
||||||
}>;
|
}>;
|
||||||
payloadKind?: "full" | "delta";
|
payloadKind?: "full" | "delta";
|
||||||
removedItemIds?: string[];
|
removedItemIds?: string[];
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import fs from "node:fs";
|
||||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
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";
|
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)", () => {
|
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||||
const captured: RotationEvent[] = [];
|
const captured: RotationEvent[] = [];
|
||||||
@@ -17,7 +37,107 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
|||||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||||
const failed = captured.find((e) => e.event === "FAILED");
|
const failed = captured.find((e) => e.event === "FAILED");
|
||||||
expect(failed?.reason).toBe("Timeout");
|
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", () => {
|
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"]);
|
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", () => {
|
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)", "TEST");
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||||
const ring = getRecentRotationEvents(10);
|
const ring = getRecentRotationEvents(10);
|
||||||
expect(ring.some((e) => e.event === "OK" && 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 (zz)")).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", () => {
|
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)"
|
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({
|
expect(event).toMatchObject({
|
||||||
event: "TIMEOUT_COOLDOWN",
|
event: "TIMEOUT_COOLDOWN",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildAccountToggleSettingsUpdate,
|
||||||
SerialTaskQueue,
|
SerialTaskQueue,
|
||||||
setAccountTargetEnabled,
|
setAccountTargetEnabled,
|
||||||
type AccountToggleTarget
|
type AccountToggleTarget
|
||||||
@@ -47,6 +48,8 @@ describe("account toggle queue", () => {
|
|||||||
let settings = {
|
let settings = {
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
debridLinkDisabledKeyIds: [],
|
debridLinkDisabledKeyIds: [],
|
||||||
|
megaDebridApiEnabled: false,
|
||||||
|
megaDebridWebEnabled: true,
|
||||||
megaDebridApiDisabledAccountIds: [],
|
megaDebridApiDisabledAccountIds: [],
|
||||||
megaDebridWebDisabledAccountIds: [...accountIds],
|
megaDebridWebDisabledAccountIds: [...accountIds],
|
||||||
megaDebridDisabledAccountIds: [...accountIds]
|
megaDebridDisabledAccountIds: [...accountIds]
|
||||||
@@ -60,4 +63,25 @@ describe("account toggle queue", () => {
|
|||||||
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
expect(settings.megaDebridWebDisabledAccountIds).toEqual([]);
|
||||||
expect(settings.megaDebridDisabledAccountIds).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 { AppHeader } from "../src/renderer/shell/AppHeader";
|
||||||
import { AppShell } from "../src/renderer/shell/AppShell";
|
import { AppShell } from "../src/renderer/shell/AppShell";
|
||||||
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
|
||||||
import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
import { getSnapshotRenderDelay, runResetUiAction, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App";
|
||||||
|
|
||||||
describe("desktop shell", () => {
|
describe("desktop shell", () => {
|
||||||
it("uses keyboard-focusable controls for every copy target", () => {
|
it("uses keyboard-focusable controls for every copy target", () => {
|
||||||
@@ -35,9 +35,78 @@ describe("desktop shell", () => {
|
|||||||
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
expect(removal).toContain('title: "Ausgewählte Links löschen"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders active download telemetry at a stable half-second cadence", () => {
|
it("does not add a second renderer debounce after main-process telemetry throttling", () => {
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0);
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500);
|
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 () => {
|
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||||
|
|||||||
@@ -45,4 +45,41 @@ describe("audit-log", () => {
|
|||||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||||
expect(content).toContain("Audit-Log Start");
|
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 {
|
import {
|
||||||
formatConversionBlock,
|
formatConversionBlock,
|
||||||
|
getConversionLogPath,
|
||||||
hasActiveConversionTrace,
|
hasActiveConversionTrace,
|
||||||
|
initConversionLog,
|
||||||
runWithConversionTrace,
|
runWithConversionTrace,
|
||||||
|
shutdownConversionLog,
|
||||||
traceConversionPhase,
|
traceConversionPhase,
|
||||||
type ConversionTrace
|
type ConversionTrace
|
||||||
} from "../src/main/conversion-trace";
|
} 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", () => {
|
describe("formatConversionBlock", () => {
|
||||||
it("renders a header with verdict + total and one indented line per phase", () => {
|
it("renders a header with verdict + total and one indented line per phase", () => {
|
||||||
const trace: ConversionTrace = {
|
const trace: ConversionTrace = {
|
||||||
@@ -26,12 +41,15 @@ describe("formatConversionBlock", () => {
|
|||||||
|
|
||||||
const lines = block.split("\n");
|
const lines = block.split("\n");
|
||||||
expect(lines[0]).toContain("[CONV]");
|
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("result=OK");
|
||||||
expect(lines[0]).toContain("total=1450ms");
|
expect(lines[0]).toContain("total=1450ms");
|
||||||
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
||||||
expect(lines).toHaveLength(4);
|
expect(lines).toHaveLength(4);
|
||||||
expect(lines[2]).toContain("+5ms token");
|
expect(lines[2]).toContain("+5ms token");
|
||||||
|
expect(lines[2]).toContain("account=Account 2/2");
|
||||||
expect(lines[2]).toContain("token=fresh");
|
expect(lines[2]).toContain("token=fresh");
|
||||||
expect(lines[2]).toContain("workMs=812");
|
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.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
|
||||||
expect(block).toContain("caller-timeout");
|
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", () => {
|
describe("conversion trace context", () => {
|
||||||
@@ -68,4 +146,36 @@ describe("conversion trace context", () => {
|
|||||||
expect(seen).toBe(true);
|
expect(seen).toBe(true);
|
||||||
expect(hasActiveConversionTrace()).toBe(false);
|
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 { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
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;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -371,6 +371,21 @@ describe("debrid service", () => {
|
|||||||
expect(result.directUrl).toBe("https://debrid-link.example/valid.bin");
|
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 () => {
|
it("looks up limits and rotates keys when Debrid-Link host quota is reached", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -1366,6 +1381,121 @@ describe("debrid service", () => {
|
|||||||
await expect(service.unrestrictLink("https://rapidgator.net/file/missing-mega-web")).rejects.toThrow(/nicht konfiguriert/i);
|
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 () => {
|
it("uses Mega web fallback when API fails", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...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 () => {
|
it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => {
|
||||||
const oldSettings = {
|
const oldSettings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -2351,6 +2676,88 @@ describe("debrid service", () => {
|
|||||||
expect(genuineEmpty.limitSignal).toBe(true);
|
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", () => {
|
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"));
|
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
|
||||||
expect(result.fatal).toBe(false);
|
expect(result.fatal).toBe(false);
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ describe("debug-server", () => {
|
|||||||
expect(entries).toContain("overview/settings.json");
|
expect(entries).toContain("overview/settings.json");
|
||||||
expect(entries).toContain("overview/accounts.json");
|
expect(entries).toContain("overview/accounts.json");
|
||||||
expect(entries).toContain("overview/debug-setup.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("overview/trace-config.json");
|
||||||
expect(entries).toContain("logs/audit.log");
|
expect(entries).toContain("logs/audit.log");
|
||||||
expect(entries).toContain("logs/rename.log");
|
expect(entries).toContain("logs/rename.log");
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ describe("disk write recovery", () => {
|
|||||||
availableBytes: 384,
|
availableBytes: 384,
|
||||||
deficitBytes: 640
|
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 () => {
|
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(session.items[itemId].status).toBe("completed");
|
||||||
expect(fs.statSync(path.join(outputDir, "reserve-resume.bin")).size).toBe(1_024);
|
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 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", () => {
|
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);
|
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", () => {
|
it("allows start when an active account is available", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-active-account-gate-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -859,14 +912,16 @@ describe("download manager", () => {
|
|||||||
expect(itemLogPath).not.toBeNull();
|
expect(itemLogPath).not.toBeNull();
|
||||||
shutdownItemLogs();
|
shutdownItemLogs();
|
||||||
const content = fs.readFileSync(itemLogPath!, "utf8");
|
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 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");
|
const secondSuccess = content.indexOf("event=OK");
|
||||||
expect(firstTest).toBeGreaterThanOrEqual(0);
|
expect(firstTest).toBeGreaterThanOrEqual(0);
|
||||||
expect(firstFailure).toBeGreaterThan(firstTest);
|
expect(firstFailure).toBeGreaterThan(firstTest);
|
||||||
expect(secondTest).toBeGreaterThan(firstFailure);
|
expect(secondTest).toBeGreaterThan(firstFailure);
|
||||||
expect(secondSuccess).toBeGreaterThan(secondTest);
|
expect(secondSuccess).toBeGreaterThan(secondTest);
|
||||||
|
expect(content).not.toContain("al***ha");
|
||||||
|
expect(content).not.toContain("be***ta");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores RapidGator metadata before a download starts", () => {
|
it("stores RapidGator metadata before a download starts", () => {
|
||||||
@@ -956,6 +1011,26 @@ describe("download manager", () => {
|
|||||||
expect(invalidateMegaSession).toHaveBeenCalledTimes(1);
|
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", () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-pool-refresh-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -1000,6 +1075,112 @@ describe("download manager", () => {
|
|||||||
expect(failures.has("realdebrid:rapidgator.net")).toBe(true);
|
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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-account-switch-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -3447,6 +3628,132 @@ describe("download manager", () => {
|
|||||||
expect(fs.statSync(item.targetPath).size).toBe(binary.length);
|
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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -3533,12 +3840,76 @@ describe("download manager", () => {
|
|||||||
expect(item?.status).toBe("failed");
|
expect(item?.status).toBe("failed");
|
||||||
expect(downloadCalls).toBeGreaterThan(4);
|
expect(downloadCalls).toBeGreaterThan(4);
|
||||||
expect(downloadCalls).toBeLessThan(30);
|
expect(downloadCalls).toBeLessThan(30);
|
||||||
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
|
expect(item.http416FreshRestarts).toBeUndefined();
|
||||||
} finally {
|
} finally {
|
||||||
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
|
||||||
}
|
}
|
||||||
}, 25000);
|
}, 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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -4913,6 +5284,120 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
}, 45000);
|
}, 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 () => {
|
it("uses content-disposition filename when provider filename is opaque", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -5129,6 +5614,7 @@ describe("download manager", () => {
|
|||||||
const existingTargetPath = path.join(pkgDir, "complete.mkv");
|
const existingTargetPath = path.join(pkgDir, "complete.mkv");
|
||||||
fs.writeFileSync(existingTargetPath, binary);
|
fs.writeFileSync(existingTargetPath, binary);
|
||||||
let saw416 = false;
|
let saw416 = false;
|
||||||
|
let directCalls = 0;
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if ((req.url || "") !== "/complete") {
|
if ((req.url || "") !== "/complete") {
|
||||||
@@ -5136,6 +5622,7 @@ describe("download manager", () => {
|
|||||||
res.end("not-found");
|
res.end("not-found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
directCalls += 1;
|
||||||
const range = String(req.headers.range || "");
|
const range = String(req.headers.range || "");
|
||||||
const match = range.match(/bytes=(\d+)-/i);
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
const start = match ? Number(match[1]) : 0;
|
const start = match ? Number(match[1]) : 0;
|
||||||
@@ -5175,7 +5662,7 @@ describe("download manager", () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
download: directUrl,
|
download: directUrl,
|
||||||
filename: "complete.mkv",
|
filename: "complete.mkv",
|
||||||
filesize: binary.length
|
filesize: null
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -5214,8 +5701,8 @@ describe("download manager", () => {
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
speedBps: 0,
|
speedBps: 0,
|
||||||
downloadedBytes: binary.length,
|
downloadedBytes: binary.length,
|
||||||
totalBytes: binary.length,
|
totalBytes: null,
|
||||||
progressPercent: 100,
|
progressPercent: 0,
|
||||||
fileName: "complete.mkv",
|
fileName: "complete.mkv",
|
||||||
targetPath: existingTargetPath,
|
targetPath: existingTargetPath,
|
||||||
resumable: true,
|
resumable: true,
|
||||||
@@ -5247,6 +5734,8 @@ describe("download manager", () => {
|
|||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.targetPath).toBe(existingTargetPath);
|
expect(item?.targetPath).toBe(existingTargetPath);
|
||||||
expect(item?.downloadedBytes).toBe(binary.length);
|
expect(item?.downloadedBytes).toBe(binary.length);
|
||||||
|
expect(item?.totalBytes).toBe(binary.length);
|
||||||
|
expect(directCalls).toBe(1);
|
||||||
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
expect(fs.statSync(existingTargetPath).size).toBe(binary.length);
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
@@ -5858,6 +6347,7 @@ describe("download manager", () => {
|
|||||||
attempts: 3,
|
attempts: 3,
|
||||||
lastError: "Error: HTTP 416",
|
lastError: "Error: HTTP 416",
|
||||||
fullStatus: "Fehler: Error: HTTP 416",
|
fullStatus: "Fehler: Error: HTTP 416",
|
||||||
|
http416FreshRestarts: 2,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt
|
updatedAt: createdAt
|
||||||
};
|
};
|
||||||
@@ -5874,7 +6364,7 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => manager.getSnapshot().session.items[itemId]?.status === "queued", 12000);
|
await manager.waitForStartupRecovery();
|
||||||
|
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const item = snapshot.session.items[itemId];
|
const item = snapshot.session.items[itemId];
|
||||||
@@ -5883,10 +6373,101 @@ describe("download manager", () => {
|
|||||||
expect(item?.downloadedBytes).toBe(0);
|
expect(item?.downloadedBytes).toBe(0);
|
||||||
expect(item?.progressPercent).toBe(0);
|
expect(item?.progressPercent).toBe(0);
|
||||||
expect(item?.fullStatus).toContain("Auto-Retry");
|
expect(item?.fullStatus).toContain("Auto-Retry");
|
||||||
|
expect(item?.http416FreshRestarts).toBe(2);
|
||||||
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||||
expect(fs.existsSync(targetPath)).toBe(false);
|
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", () => {
|
it("requeues completed zero-byte archive items automatically on startup", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -8320,6 +8901,76 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
}, 20000);
|
}, 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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
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 () => {
|
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
|
||||||
tempDirs.push(root);
|
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)) {
|
if (Array.isArray(node)) {
|
||||||
for (const child of node) {
|
for (const child of node) {
|
||||||
const action = findStartAction(child);
|
const actions = findDownloadActions(child);
|
||||||
if (action) return action;
|
if (actions) return actions;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isValidElement(node)) return null;
|
if (!isValidElement(node)) return null;
|
||||||
const props = node.props as {
|
const props = node.props as {
|
||||||
actions?: { onStartDownloads?: () => void };
|
actions?: DownloadActions;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
toolbar?: ReactNode;
|
toolbar?: ReactNode;
|
||||||
};
|
};
|
||||||
if (typeof props.actions?.onStartDownloads === "function") {
|
if (props.actions && typeof props.actions.onStartDownloads === "function") {
|
||||||
return props.actions.onStartDownloads;
|
return props.actions;
|
||||||
}
|
}
|
||||||
return findStartAction(props.toolbar) ?? findStartAction(props.children);
|
return findDownloadActions(props.toolbar) ?? findDownloadActions(props.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flushAsyncAction(): Promise<void> {
|
async function flushAsyncAction(): Promise<void> {
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPausedStartAction(
|
function renderDownloadActions(
|
||||||
initialSnapshot: UiSnapshot,
|
initialSnapshot: UiSnapshot,
|
||||||
togglePause: () => Promise<boolean>,
|
togglePause: () => Promise<boolean>,
|
||||||
getSnapshot: () => Promise<UiSnapshot>
|
getSnapshot: () => Promise<UiSnapshot>,
|
||||||
): () => void {
|
stop: () => Promise<void> = async () => undefined
|
||||||
|
): DownloadActions {
|
||||||
hookState.capturedSnapshot = false;
|
hookState.capturedSnapshot = false;
|
||||||
hookState.initialSnapshot = initialSnapshot;
|
hookState.initialSnapshot = initialSnapshot;
|
||||||
hookState.currentSnapshot = initialSnapshot;
|
hookState.currentSnapshot = initialSnapshot;
|
||||||
@@ -125,14 +132,14 @@ function renderPausedStartAction(
|
|||||||
devicePixelRatio: 1,
|
devicePixelRatio: 1,
|
||||||
matchMedia: () => ({ matches: false }),
|
matchMedia: () => ({ matches: false }),
|
||||||
prompt: () => null,
|
prompt: () => null,
|
||||||
rd: { getSnapshot, togglePause },
|
rd: { getSnapshot, togglePause, stop },
|
||||||
removeEventListener: () => {},
|
removeEventListener: () => {},
|
||||||
setInterval,
|
setInterval,
|
||||||
setTimeout
|
setTimeout
|
||||||
});
|
});
|
||||||
const action = findStartAction(App() as ReactElement);
|
const actions = findDownloadActions(App() as ReactElement);
|
||||||
if (!action) throw new Error("Download-Startaktion nicht gefunden");
|
if (!actions) throw new Error("Download-Aktionen nicht gefunden");
|
||||||
return action;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("paused download resume reconciliation", () => {
|
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 () => {
|
it("restores the authoritative paused state when togglePause rejects without a state event", async () => {
|
||||||
const initial = createSnapshot(true, true);
|
const initial = createSnapshot(true, true);
|
||||||
const authoritative = createSnapshot(true, true);
|
const authoritative = createSnapshot(true, true);
|
||||||
const action = renderPausedStartAction(
|
const actions = renderDownloadActions(
|
||||||
initial,
|
initial,
|
||||||
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
|
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
|
||||||
async () => authoritative
|
async () => authoritative
|
||||||
);
|
);
|
||||||
|
|
||||||
action();
|
actions.onStartDownloads?.();
|
||||||
await flushAsyncAction();
|
await flushAsyncAction();
|
||||||
|
|
||||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
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 () => {
|
it("replaces stale running state when togglePause returns false without a state event", async () => {
|
||||||
const initial = createSnapshot(true, true);
|
const initial = createSnapshot(true, true);
|
||||||
const authoritative = createSnapshot(false, false);
|
const authoritative = createSnapshot(false, false);
|
||||||
const action = renderPausedStartAction(
|
const actions = renderDownloadActions(
|
||||||
initial,
|
initial,
|
||||||
async () => false,
|
async () => false,
|
||||||
async () => authoritative
|
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();
|
await flushAsyncAction();
|
||||||
|
|
||||||
expect(hookState.currentSnapshot).toEqual(authoritative);
|
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
formatHosterLabel,
|
formatHosterLabel,
|
||||||
normalizeDownloadServiceLabel
|
normalizeDownloadServiceLabel
|
||||||
} from "../src/renderer/download-format";
|
} 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();
|
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");
|
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", () => {
|
it("animates exactly the five stable sidebar metrics", () => {
|
||||||
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||||
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
||||||
|
|||||||
@@ -64,6 +64,53 @@ describe("item-log", () => {
|
|||||||
expect(content).toContain("code=missing_parts");
|
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", () => {
|
it("keeps traversal-like item ids inside the item log directory", () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||||
tempDirs.push(baseDir);
|
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");
|
const content = fs.readFileSync(logPath!, "utf8");
|
||||||
expect(content).toContain("Passwort-Versuch");
|
expect(content).toContain("Passwort-Versuch");
|
||||||
expect(content).toContain("archive=episode.part1.rar");
|
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", () => {
|
it("keeps traversal-like package ids inside the package log directory", () => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PackageEntry } from "../src/shared/types";
|
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 {
|
function createPackage(id: string, itemIds: string[], downloadStartedAt = 0): PackageEntry {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -39,3 +43,81 @@ describe("preservePackageOrderForDisplay", () => {
|
|||||||
expect(preservePackageOrderForDisplay(packages).map((pkg) => pkg.id)).toEqual(["pkg-first", "pkg-second", "pkg-third"]);
|
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);
|
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", () => {
|
it("returns defaults when config file contains invalid JSON", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|||||||
@@ -6,18 +6,39 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
buildSupportBundle,
|
buildSupportBundle,
|
||||||
createSupportBundleExportRunner,
|
createSupportBundleExportRunner,
|
||||||
|
type SupportBundleExportLifecycleEvent,
|
||||||
writeSupportBundleAtomically
|
writeSupportBundleAtomically
|
||||||
} from "../src/main/support-bundle";
|
} from "../src/main/support-bundle";
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||||
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-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 tempDirs: string[] = [];
|
||||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
shutdownTraceLog();
|
||||||
|
shutdownItemLogs();
|
||||||
|
shutdownPackageLogs();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
shutdownAccountRotationLog();
|
shutdownAccountRotationLog();
|
||||||
|
resetDebridLinkRuntimeStateForTests();
|
||||||
|
resetMegaDebridRuntimeStateForTests();
|
||||||
|
flushLoggerSync();
|
||||||
|
configureLogger(process.cwd());
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||||
}
|
}
|
||||||
@@ -151,6 +172,8 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
|
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
|
||||||
expect(entries).toContain("overview/meta.json");
|
expect(entries).toContain("overview/meta.json");
|
||||||
expect(entries).toContain("overview/settings.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_host.txt");
|
||||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||||
expect(entries).toContain("overview/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");
|
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
|
||||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
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 () => {
|
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);
|
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 () => {
|
it("bounds recent item logs to the newest diagnostic files", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -240,6 +627,113 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
expect(itemEntries).toContain("logs/item-logs/item-364.txt");
|
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 () => {
|
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -344,6 +838,91 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
expect(itemOverview.items?.[0]).not.toHaveProperty("url");
|
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 () => {
|
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-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -394,6 +973,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("support bundle export runner", () => {
|
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 () => {
|
it("returns a visible busy result for reentry without choosing another target", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -430,6 +1102,32 @@ describe("support bundle export runner", () => {
|
|||||||
await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target });
|
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 () => {
|
it("reports success only after the target write has completed", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-"));
|
||||||
tempDirs.push(root);
|
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 });
|
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);
|
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 () => {
|
it("auto-disables support trace after the requested duration", async () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
|
|||||||
Reference in New Issue
Block a user