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:
@@ -1,28 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { RotationEvent } from "../shared/types";
|
||||
import { sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export type RotationItemSink = (event: RotationEvent) => void;
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||
|
||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||
return rotationItemContext.run(sink, fn);
|
||||
}
|
||||
export interface RotationCorrelationContext {
|
||||
attemptId?: string;
|
||||
itemId?: string;
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
export type CorrelatedRotationEvent = RotationEvent & RotationCorrelationContext;
|
||||
export type RotationItemSink = (event: CorrelatedRotationEvent) => void;
|
||||
|
||||
interface RotationItemContext extends RotationCorrelationContext {
|
||||
sink: RotationItemSink;
|
||||
}
|
||||
|
||||
const rotationItemContext = new AsyncLocalStorage<RotationItemContext>();
|
||||
|
||||
export function runWithRotationItemSink<T>(
|
||||
sink: RotationItemSink,
|
||||
fn: () => Promise<T>,
|
||||
correlation: RotationCorrelationContext = {}
|
||||
): Promise<T> {
|
||||
return rotationItemContext.run({ ...correlation, sink }, fn);
|
||||
}
|
||||
|
||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
const ROTATION_EVENT_RING_MAX = 60;
|
||||
const rotationEventRing: RotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||
const rotationEventRing: CorrelatedRotationEvent[] = [];
|
||||
let rotationEventSeq = 0;
|
||||
let rotationEventListener: ((event: CorrelatedRotationEvent) => void) | null = null;
|
||||
|
||||
export function setRotationEventListener(listener: ((event: CorrelatedRotationEvent) => void) | null): void {
|
||||
rotationEventListener = listener;
|
||||
}
|
||||
|
||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): CorrelatedRotationEvent[] {
|
||||
const slice = rotationEventRing.slice(-limit);
|
||||
slice.reverse();
|
||||
return slice;
|
||||
@@ -35,25 +52,28 @@ function pushRotationEvent(
|
||||
event: string,
|
||||
fields?: Record<string, unknown>,
|
||||
at = Date.now()
|
||||
): void {
|
||||
rotationEventSeq += 1;
|
||||
const entry: RotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
): CorrelatedRotationEvent {
|
||||
rotationEventSeq += 1;
|
||||
const context = rotationItemContext.getStore();
|
||||
const entry: CorrelatedRotationEvent = {
|
||||
id: `rot_${at}_${rotationEventSeq}`,
|
||||
at,
|
||||
level,
|
||||
provider,
|
||||
accountLabel,
|
||||
event,
|
||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||
};
|
||||
|
||||
const itemSink = rotationItemContext.getStore();
|
||||
if (itemSink) {
|
||||
try {
|
||||
itemSink(entry);
|
||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : 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
|
||||
};
|
||||
|
||||
if (context) {
|
||||
try {
|
||||
context.sink(entry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -67,11 +87,12 @@ function pushRotationEvent(
|
||||
}
|
||||
if (rotationEventListener) {
|
||||
try {
|
||||
rotationEventListener(uiEntry);
|
||||
rotationEventListener(uiEntry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
||||
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
||||
@@ -82,14 +103,14 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
if (typeof value === "string") {
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
try {
|
||||
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
@@ -155,24 +176,34 @@ export function initAccountRotationLog(baseDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function logAccountRotation(
|
||||
export function logAccountRotation(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
accountLabel: string,
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
event: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const safeProvider = sanitizeDiagnosticText(provider);
|
||||
const safeAccountLabel = sanitizeDiagnosticAccountLabel(accountLabel);
|
||||
const safeEvent = sanitizeDiagnosticText(event);
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
const entry = pushRotationEvent(level, safeProvider, safeAccountLabel, safeEvent, safeFields);
|
||||
if (!rotationLogPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
rotateIfNeeded(rotationLogPath);
|
||||
if (!fs.existsSync(rotationLogPath)) {
|
||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||
}
|
||||
const head = `${logTimestamp()} [${level}] ${safeProvider} | ${safeAccountLabel} | ${safeEvent}`;
|
||||
const logFields = {
|
||||
...safeFields,
|
||||
...(entry.attemptId ? { attemptId: entry.attemptId } : {}),
|
||||
...(entry.itemId ? { itemId: entry.itemId } : {}),
|
||||
...(entry.packageId ? { packageId: entry.packageId } : {})
|
||||
};
|
||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(logFields)}\n`, "utf8");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
+127
-59
@@ -62,7 +62,8 @@ import { buildLinkExportSelection, serializeLinkExportText } from "./link-export
|
||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
||||
import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import type { SupportBundleExportLifecycleEvent } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
@@ -190,21 +191,14 @@ export class AppController {
|
||||
this.settings = this.manager.getSettings();
|
||||
this.checkMemoryPressure();
|
||||
}, 60_000);
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
if (hasPending && this.hasAnyProviderToken(this.settings)) {
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
} else {
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.runtimeStatsTimer.unref?.();
|
||||
|
||||
if (this.settings.autoResumeOnStart) {
|
||||
void this.manager.waitForStartupRecovery().then(() => {
|
||||
this.prepareAutoResume();
|
||||
}).catch((err) => logger.warn(`Auto-Resume Startup-Recovery Fehler: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Early-warning for OOM on a long-running process. Measured against the V8
|
||||
// heap_size_limit (the real ceiling at which the process is killed), NOT against
|
||||
@@ -234,19 +228,34 @@ export class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||
return Boolean(
|
||||
settings.token.trim()
|
||||
|| settings.realDebridUseWebLogin
|
||||
|| (settings.megaLogin.trim() && settings.megaPassword.trim())
|
||||
|| settings.bestToken.trim()
|
||||
|| settings.bestDebridUseWebLogin
|
||||
|| settings.allDebridUseWebLogin
|
||||
|| settings.allDebridToken.trim()
|
||||
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|
||||
|| settings.oneFichierApiKey.trim()
|
||||
);
|
||||
}
|
||||
private prepareAutoResume(): void {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const items = Object.values(snapshot.session.items);
|
||||
const pendingCount = items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length;
|
||||
if (pendingCount === 0) {
|
||||
this.audit("INFO", "Auto-Resume übersprungen", {
|
||||
reason: "no_pending",
|
||||
itemCount: items.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!snapshot.canStart) {
|
||||
this.audit("WARN", "Auto-Resume übersprungen", {
|
||||
reason: "cannot_start",
|
||||
pendingCount,
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
configuredAccounts: snapshot.accounts.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.onStateHandler) {
|
||||
this.beginAutoResume();
|
||||
return;
|
||||
}
|
||||
this.autoResumePending = true;
|
||||
logger.info("Auto-Resume beim Start vorgemerkt");
|
||||
}
|
||||
|
||||
public get onState(): ((snapshot: UiSnapshot) => void) | null {
|
||||
return this.onStateHandler;
|
||||
@@ -708,16 +717,41 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
await this.manager.startItems(itemIds);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.audit("INFO", "Session-Stopp ausgelöst");
|
||||
this.manager.stop();
|
||||
}
|
||||
|
||||
public togglePause(): boolean {
|
||||
const paused = this.manager.togglePause();
|
||||
this.audit("INFO", "Pause umgeschaltet", { paused });
|
||||
return paused;
|
||||
}
|
||||
public stop(): void {
|
||||
const before = this.manager.getSnapshot();
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Session-Stopp angefordert", this.sessionControlFields(before));
|
||||
this.manager.stop();
|
||||
this.audit("INFO", "Session-Stopp angewendet", {
|
||||
...this.sessionControlFields(this.manager.getSnapshot()),
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
}
|
||||
|
||||
public togglePause(): boolean {
|
||||
const before = this.manager.getSnapshot();
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Pause angefordert", this.sessionControlFields(before));
|
||||
const paused = this.manager.togglePause();
|
||||
this.audit("INFO", "Pause angewendet", {
|
||||
...this.sessionControlFields(this.manager.getSnapshot()),
|
||||
paused,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
return paused;
|
||||
}
|
||||
|
||||
private sessionControlFields(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
const items = Object.values(snapshot.session.items);
|
||||
return {
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
packageCount: Object.keys(snapshot.session.packages).length,
|
||||
itemCount: items.length,
|
||||
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length,
|
||||
queuedItemCount: items.filter((item) => item.status === "queued" || item.status === "reconnect_wait").length
|
||||
};
|
||||
}
|
||||
|
||||
public retryExtraction(packageId: string): void {
|
||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
||||
@@ -729,9 +763,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
this.manager.extractNow(packageId);
|
||||
}
|
||||
|
||||
public resetPackage(packageId: string): void {
|
||||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
||||
this.manager.resetPackage(packageId);
|
||||
public async resetPackage(packageId: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Paket-Reset angefordert", { packageId });
|
||||
try {
|
||||
await this.manager.resetPackage(packageId);
|
||||
this.audit("INFO", "Paket-Reset abgeschlossen", { packageId, durationMs: Date.now() - startedAt });
|
||||
} catch (error) {
|
||||
this.audit("ERROR", "Paket-Reset fehlgeschlagen", {
|
||||
packageId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public cancelPackage(packageId: string): void {
|
||||
@@ -761,7 +806,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
|
||||
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
||||
this.audit("INFO", "Paket-Auswahl exportiert", {
|
||||
this.audit("INFO", "Paket-Auswahl für Export vorbereitet", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
packageIds
|
||||
@@ -776,7 +821,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
|
||||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
||||
this.audit("INFO", "Item-Auswahl exportiert", {
|
||||
this.audit("INFO", "Item-Auswahl für Export vorbereitet", {
|
||||
packageCount: selection.packageCount,
|
||||
linkCount: selection.linkCount,
|
||||
itemIds
|
||||
@@ -870,24 +915,31 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
};
|
||||
}
|
||||
|
||||
public recordSupportBundleExported(filePath: string, bytes: number): void {
|
||||
public recordSupportBundleExportSelected(): void {
|
||||
const snapshot = this.manager.getSnapshot();
|
||||
const items = Object.values(snapshot.session.items);
|
||||
const fields = {
|
||||
fileName: path.basename(filePath),
|
||||
bytes,
|
||||
phase: "selected",
|
||||
running: snapshot.session.running,
|
||||
paused: snapshot.session.paused,
|
||||
packageCount: Object.keys(snapshot.session.packages).length,
|
||||
itemCount: Object.keys(snapshot.session.items).length
|
||||
itemCount: items.length,
|
||||
activeItemCount: items.filter((item) => item.status === "validating" || item.status === "downloading").length
|
||||
};
|
||||
this.audit("INFO", "Support-Bundle exportiert", fields);
|
||||
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
|
||||
this.audit("INFO", "Support-Bundle-Ziel ausgewählt", fields);
|
||||
}
|
||||
|
||||
public recordSupportBundleExportFailed(error: unknown): void {
|
||||
const fields = {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
public recordSupportBundleExportLifecycle(event: SupportBundleExportLifecycleEvent): void {
|
||||
const messages: Record<SupportBundleExportLifecycleEvent["phase"], string> = {
|
||||
busy: "Support-Bundle-Export bereits aktiv",
|
||||
cancel: "Support-Bundle-Export abgebrochen",
|
||||
build: "Support-Bundle aufgebaut",
|
||||
write: "Support-Bundle geschrieben",
|
||||
success: "Support-Bundle-Export abgeschlossen",
|
||||
failure: "Support-Bundle-Export fehlgeschlagen"
|
||||
};
|
||||
this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields);
|
||||
const level = event.phase === "failure" ? "ERROR" : event.phase === "busy" ? "WARN" : "INFO";
|
||||
this.audit(level, messages[event.phase], { ...event });
|
||||
}
|
||||
|
||||
public getSupportBundleDefaultFileName(): string {
|
||||
@@ -1113,10 +1165,26 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
this.manager.skipItems(itemIds);
|
||||
}
|
||||
|
||||
public resetItems(itemIds: string[]): void {
|
||||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
||||
this.manager.resetItems(itemIds);
|
||||
}
|
||||
public async resetItems(itemIds: string[]): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
this.audit("INFO", "Item-Reset angefordert", { itemIds, itemCount: itemIds.length });
|
||||
try {
|
||||
await this.manager.resetItems(itemIds);
|
||||
this.audit("INFO", "Item-Reset abgeschlossen", {
|
||||
itemIds,
|
||||
itemCount: itemIds.length,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
} catch (error) {
|
||||
this.audit("ERROR", "Item-Reset fehlgeschlagen", {
|
||||
itemIds,
|
||||
itemCount: itemIds.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public removeHistoryEntry(entryId: string): void {
|
||||
this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
|
||||
|
||||
+13
-11
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -26,11 +27,12 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -91,9 +93,9 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
||||
if (!fs.existsSync(auditLogPath)) {
|
||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
||||
}
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
||||
fs.appendFileSync(
|
||||
auditLogPath,
|
||||
`${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { clipboard } from "electron";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { logger } from "./logger";
|
||||
|
||||
type ClipboardIpcEvent = {
|
||||
senderFrame?: { url: string } | null;
|
||||
sender?: { getURL?: () => string };
|
||||
};
|
||||
|
||||
const CLIPBOARD_TEXT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export function writeClipboardTextFromIpc(
|
||||
event: ClipboardIpcEvent,
|
||||
text: unknown,
|
||||
trustedOptions: TrustedIpcOptions
|
||||
): true {
|
||||
assertTrustedIpcSender(event, trustedOptions);
|
||||
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > CLIPBOARD_TEXT_MAX_BYTES) {
|
||||
throw new Error("Ungültiger Zwischenablageinhalt");
|
||||
}
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
try {
|
||||
clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
logger.warn(`Zwischenablage-Schreiben fehlgeschlagen: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`Zwischenablage geschrieben: bytes=${bytes}`);
|
||||
return true;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { formatDiagnosticLink, sanitizeDiagnosticAccountLabel, sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export interface ConversionPhase {
|
||||
atMs: number;
|
||||
@@ -15,37 +16,59 @@ export interface ConversionPhase {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
export interface ConversionTrace {
|
||||
startedAt: number;
|
||||
attemptId?: string;
|
||||
itemId: string;
|
||||
packageId?: string;
|
||||
itemName: string;
|
||||
link: string;
|
||||
providerOrder: string;
|
||||
notes: Record<string, string | number>;
|
||||
phases: ConversionPhase[];
|
||||
}
|
||||
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
const conversionContext = new AsyncLocalStorage<ConversionTrace>();
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function sanitizeConversionText(value: unknown, itemName = ""): string {
|
||||
let safeValue = sanitizeDiagnosticText(value)
|
||||
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,63}#[a-f0-9]{10}\b/gi, "<redacted-link>");
|
||||
const safeItemName = sanitizeDiagnosticText(itemName).trim();
|
||||
if (safeItemName) {
|
||||
safeValue = safeValue.replace(new RegExp(escapeRegExp(safeItemName), "gi"), "<redacted-item>");
|
||||
}
|
||||
return safeValue;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
function shortLink(link: string): string {
|
||||
const raw = String(link || "").trim();
|
||||
return raw.length > 90 ? `${raw.slice(0, 90)}…` : raw;
|
||||
}
|
||||
|
||||
export function traceConversionPhase(phase: Omit<ConversionPhase, "atMs">): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.phases.push({ ...phase, atMs: Date.now() - trace.startedAt });
|
||||
}
|
||||
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
trace.notes[key] = value;
|
||||
export function traceConversionNote(key: string, value: string | number): void {
|
||||
const trace = conversionContext.getStore();
|
||||
if (!trace) {
|
||||
return;
|
||||
}
|
||||
const safeKey = sanitizeDiagnosticText(key);
|
||||
const safeValue = sanitizeDiagnosticFields({ [key]: value })?.[key];
|
||||
trace.notes[safeKey] = typeof safeValue === "number" ? safeValue : sanitizeDiagnosticText(safeValue);
|
||||
}
|
||||
|
||||
export function hasActiveConversionTrace(): boolean {
|
||||
@@ -57,23 +80,31 @@ export function formatConversionBlock(
|
||||
outcome: string,
|
||||
detail: string,
|
||||
totalMs: number
|
||||
): string {
|
||||
const noteParts = Object.entries(trace.notes)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ");
|
||||
const header = `${logTimestamp()} [CONV] item=${trace.itemName || trace.itemId} | order=${trace.providerOrder || "?"}`
|
||||
+ ` | result=${outcome}${detail ? ` (${detail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`
|
||||
+ ` | link=${shortLink(trace.link)}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${p.provider}`);
|
||||
if (p.account) parts.push(`account=${p.account}`);
|
||||
if (p.tokenState) parts.push(`token=${p.tokenState}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${p.outcome}`);
|
||||
if (p.detail) parts.push(`detail=${String(p.detail).replace(/\r?\n/g, "\\n")}`);
|
||||
return ` +${p.atMs}ms ${p.phase}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
): string {
|
||||
const safeNotes = sanitizeDiagnosticFields(trace.notes) || {};
|
||||
const noteParts = Object.entries(safeNotes)
|
||||
.map(([key, value]) => `${sanitizeConversionText(key, trace.itemName)}=${sanitizeConversionText(value, trace.itemName)}`)
|
||||
.join(" ");
|
||||
const safeOrder = sanitizeConversionText(trace.providerOrder || "?", trace.itemName);
|
||||
const safeOutcome = sanitizeConversionText(outcome, trace.itemName);
|
||||
const safeDetail = sanitizeConversionText(detail, trace.itemName);
|
||||
const correlation = [
|
||||
trace.attemptId ? `attemptId=${sanitizeConversionText(trace.attemptId)}` : "",
|
||||
`itemId=${sanitizeConversionText(trace.itemId)}`,
|
||||
trace.packageId ? `packageId=${sanitizeConversionText(trace.packageId)}` : ""
|
||||
].filter(Boolean).join(" | ");
|
||||
const header = `${logTimestamp()} [CONV] ${correlation} | order=${safeOrder}`
|
||||
+ ` | result=${safeOutcome}${safeDetail ? ` (${safeDetail})` : ""} | total=${totalMs}ms${noteParts ? ` | ${noteParts}` : ""}`;
|
||||
const lines = trace.phases.map((p) => {
|
||||
const parts: string[] = [];
|
||||
if (p.provider) parts.push(`provider=${sanitizeConversionText(p.provider, trace.itemName)}`);
|
||||
if (p.account) parts.push(`account=${sanitizeDiagnosticAccountLabel(p.account)}`);
|
||||
if (p.tokenState) parts.push(`token=${sanitizeConversionText(p.tokenState, trace.itemName)}`);
|
||||
if (typeof p.queueWaitMs === "number") parts.push(`queueWaitMs=${p.queueWaitMs}`);
|
||||
if (typeof p.workMs === "number") parts.push(`workMs=${p.workMs}`);
|
||||
if (p.outcome) parts.push(`outcome=${sanitizeConversionText(p.outcome, trace.itemName)}`);
|
||||
if (p.detail) parts.push(`detail=${sanitizeConversionText(p.detail, trace.itemName)}`);
|
||||
return ` +${p.atMs}ms ${sanitizeConversionText(p.phase, trace.itemName)}${parts.length ? ` | ${parts.join(" | ")}` : ""}`;
|
||||
});
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
@@ -161,16 +192,18 @@ function writeConversionBlock(block: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { itemId: string; itemName: string; link: string; providerOrder: string },
|
||||
export async function runWithConversionTrace<T>(
|
||||
meta: { attemptId?: string; itemId: string; packageId?: string; itemName: string; link: string; providerOrder: string },
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
itemId: meta.itemId,
|
||||
itemName: meta.itemName,
|
||||
link: meta.link,
|
||||
providerOrder: meta.providerOrder,
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: Date.now(),
|
||||
attemptId: meta.attemptId ? sanitizeDiagnosticText(meta.attemptId) : undefined,
|
||||
itemId: sanitizeDiagnosticText(meta.itemId),
|
||||
packageId: meta.packageId ? sanitizeDiagnosticText(meta.packageId) : undefined,
|
||||
itemName: sanitizeDiagnosticText(meta.itemName),
|
||||
link: formatDiagnosticLink(meta.link),
|
||||
providerOrder: sanitizeDiagnosticText(meta.providerOrder),
|
||||
notes: {},
|
||||
phases: []
|
||||
};
|
||||
@@ -179,9 +212,9 @@ export async function runWithConversionTrace<T>(
|
||||
try {
|
||||
const result = await conversionContext.run(trace, fn);
|
||||
return result;
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160);
|
||||
} catch (error) {
|
||||
outcome = "FAIL";
|
||||
detail = sanitizeDiagnosticText(String((error as { message?: string })?.message || error || "").replace(/^Error:\s*/i, "").slice(0, 160));
|
||||
throw error;
|
||||
} finally {
|
||||
const totalMs = Date.now() - trace.startedAt;
|
||||
|
||||
+122
-68
@@ -6,9 +6,10 @@ import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMeg
|
||||
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
|
||||
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
import { logAccountRotation } from "./account-rotation-log";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||
import { logAccountRotation } from "./account-rotation-log";
|
||||
import { traceConversionPhase } from "./conversion-trace";
|
||||
import { sanitizeDiagnosticText, type DiagnosticRedactions } from "./diagnostic-sanitizer";
|
||||
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
|
||||
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
|
||||
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
|
||||
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
|
||||
@@ -23,8 +24,12 @@ const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";
|
||||
|
||||
const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
|
||||
|
||||
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
|
||||
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||
|
||||
function sanitizeProviderErrorText(error: unknown, redactions: DiagnosticRedactions = {}): string {
|
||||
return sanitizeDiagnosticText(compactErrorText(error).replace(/^Error:\s*/i, ""), redactions);
|
||||
}
|
||||
|
||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
||||
@@ -67,7 +72,7 @@ export function resetDebridLinkRuntimeStateForTests(): void {
|
||||
debridLinkKeyHostCooldownDetails.clear();
|
||||
}
|
||||
|
||||
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
||||
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
||||
for (const keyId of debridLinkKeyCooldowns.keys()) {
|
||||
if (!activeKeyIds.has(keyId)) {
|
||||
debridLinkKeyCooldowns.delete(keyId);
|
||||
@@ -333,6 +338,22 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||
}
|
||||
|
||||
function clearDebridLinkRuntimeStateForKeys(keyIds: Set<string>): void {
|
||||
for (const keyId of keyIds) {
|
||||
debridLinkKeyCooldowns.delete(keyId);
|
||||
debridLinkKeyCooldownDetails.delete(keyId);
|
||||
debridLinkKeyRuntimeStatuses.delete(keyId);
|
||||
}
|
||||
for (const stateKey of [...debridLinkKeyHostCooldowns.keys()]) {
|
||||
const separator = stateKey.indexOf("|");
|
||||
const keyId = separator >= 0 ? stateKey.slice(0, separator) : stateKey;
|
||||
if (keyIds.has(keyId)) {
|
||||
debridLinkKeyHostCooldowns.delete(stateKey);
|
||||
debridLinkKeyHostCooldownDetails.delete(stateKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getMegaDebridAccountAttemptTimeoutMs(): number {
|
||||
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
|
||||
return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000
|
||||
@@ -642,14 +663,18 @@ function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
||||
return parseMegaDebridAccounts(mergeMegaDebridCredentialPools(settings.megaDebridApiCredentials || "", settings.megaDebridWebCredentials || "") || settings.megaCredentials || "").length > 0;
|
||||
}
|
||||
|
||||
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
|
||||
if (mode === "api") {
|
||||
return settings.megaDebridApiEnabled
|
||||
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||
}
|
||||
return settings.megaDebridWebEnabled
|
||||
|| (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
||||
}
|
||||
function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
|
||||
const hasDedicatedPoolCredentials = Boolean(
|
||||
String(settings.megaDebridApiCredentials || "").trim()
|
||||
|| String(settings.megaDebridWebCredentials || "").trim()
|
||||
);
|
||||
if (mode === "api") {
|
||||
return settings.megaDebridApiEnabled
|
||||
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
|
||||
}
|
||||
return settings.megaDebridWebEnabled
|
||||
|| (!hasDedicatedPoolCredentials && hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
|
||||
}
|
||||
|
||||
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
|
||||
if (provider !== "megadebrid") {
|
||||
@@ -768,6 +793,7 @@ function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal):
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
void promise.catch(() => {});
|
||||
return Promise.reject(new Error("aborted:debrid"));
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -1076,12 +1102,13 @@ async function requestDebridLinkPayloadWithKey(
|
||||
body: payloadBody,
|
||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const payload = parseJsonSafe(responseText);
|
||||
if (!payload) {
|
||||
const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
|
||||
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
|
||||
: compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
|
||||
const responseText = await response.text();
|
||||
const payload = parseJsonSafe(responseText);
|
||||
if (!payload) {
|
||||
const rawDescription = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
|
||||
? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
|
||||
: compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
|
||||
const description = sanitizeDiagnosticText(rawDescription, { secretValues: [apiKey.token] });
|
||||
const error = new DebridLinkApiError(
|
||||
response.status,
|
||||
"requestError",
|
||||
@@ -1096,11 +1123,15 @@ async function requestDebridLinkPayloadWithKey(
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok || !parseDebridLinkSuccess(payload)) {
|
||||
const error = new DebridLinkApiError(
|
||||
response.status,
|
||||
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
|
||||
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
||||
if (!response.ok || !parseDebridLinkSuccess(payload)) {
|
||||
const description = sanitizeDiagnosticText(
|
||||
parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
|
||||
{ secretValues: [apiKey.token] }
|
||||
);
|
||||
const error = new DebridLinkApiError(
|
||||
response.status,
|
||||
parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
|
||||
description,
|
||||
parseRetryAfterMs(response.headers.get("retry-after")),
|
||||
payload
|
||||
);
|
||||
@@ -1116,9 +1147,9 @@ async function requestDebridLinkPayloadWithKey(
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
throw error;
|
||||
}
|
||||
lastTransportError = compactErrorText(error);
|
||||
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
|
||||
throw error;
|
||||
lastTransportError = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
|
||||
if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
|
||||
throw new Error(lastTransportError);
|
||||
}
|
||||
if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
|
||||
throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
|
||||
@@ -1955,7 +1986,11 @@ class MegaDebridClient {
|
||||
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
|
||||
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
|
||||
}
|
||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
|
||||
const detail = sanitizeDiagnosticText(`response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim(), {
|
||||
accountValues: [this.login],
|
||||
secretValues: [this.password]
|
||||
});
|
||||
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail });
|
||||
return null;
|
||||
}
|
||||
const token = String(payload.token || "").trim();
|
||||
@@ -2002,7 +2037,10 @@ class MegaDebridClient {
|
||||
if (tokenInvalidated) {
|
||||
this.clearTokenCache();
|
||||
}
|
||||
const errorText = String(payload?.response_text || "").trim();
|
||||
const errorText = sanitizeDiagnosticText(String(payload?.response_text || "").trim(), {
|
||||
accountValues: [this.login],
|
||||
secretValues: [this.password, token]
|
||||
});
|
||||
traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
|
||||
if (errorText) {
|
||||
throw new Error(`Mega-Debrid API: ${errorText}`);
|
||||
@@ -2144,8 +2182,8 @@ class MegaDebridClient {
|
||||
const entry = orderedEntries[orderPos];
|
||||
const account = entry.account;
|
||||
const idx = entry.idx;
|
||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
||||
const accountLabel = ` (${account.label}/${totalAccounts})`;
|
||||
const rotationLabel = `${account.label}/${totalAccounts}`;
|
||||
|
||||
if (isMegaDebridAccountDisabled(settings, account.id, mode)) {
|
||||
logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
|
||||
@@ -2195,8 +2233,8 @@ class MegaDebridClient {
|
||||
: accountAttemptTimeoutSignal;
|
||||
try {
|
||||
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
|
||||
const result = await client.unrestrictLink(link, accountAttemptSignal);
|
||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||
const result = await waitForPromiseWithSignal(client.unrestrictLink(link, accountAttemptSignal), accountAttemptSignal);
|
||||
clearMegaDebridAccountCooldownState(cooldownKey);
|
||||
clearMegaDebridEmptyResponseStreak(cooldownKey);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
|
||||
@@ -2221,9 +2259,10 @@ class MegaDebridClient {
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const redactions = { accountValues: [account.login], secretValues: [account.password] };
|
||||
const abortText = sanitizeProviderErrorText(error, redactions);
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
throw new Error(abortText);
|
||||
}
|
||||
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
|
||||
// account-wide cooldown exists ONLY to make the retry rotate to another
|
||||
@@ -2278,7 +2317,7 @@ class MegaDebridClient {
|
||||
}
|
||||
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
|
||||
}
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error);
|
||||
const failure = MegaDebridClient.classifyAccountFailure(error, redactions);
|
||||
traceConversionPhase({
|
||||
phase: "mega-account",
|
||||
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
|
||||
@@ -2326,7 +2365,7 @@ class MegaDebridClient {
|
||||
for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
|
||||
const nextAcc = orderedEntries[nextPos].account;
|
||||
if (!isMegaDebridAccountDisabled(settings, nextAcc.id, mode) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
|
||||
nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
|
||||
nextLabel = `${nextAcc.label}/${totalAccounts}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2363,10 +2402,11 @@ class MegaDebridClient {
|
||||
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||
}
|
||||
|
||||
static classifyAccountFailure(
|
||||
error: unknown
|
||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
static classifyAccountFailure(
|
||||
error: unknown,
|
||||
redactions: DiagnosticRedactions = {}
|
||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||
const errorText = sanitizeProviderErrorText(error, redactions);
|
||||
|
||||
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
||||
@@ -2909,8 +2949,8 @@ class DebridLinkClient {
|
||||
|
||||
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
||||
const apiKey = this.apiKeys[keyIdx];
|
||||
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
||||
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
||||
const keyLabel = ` (${apiKey.label}/${totalKeys})`;
|
||||
const rotationLabel = `${apiKey.label}/${totalKeys}`;
|
||||
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
||||
logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
|
||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
|
||||
@@ -2978,7 +3018,7 @@ class DebridLinkClient {
|
||||
} catch (error) {
|
||||
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
|
||||
const elapsedMs = Date.now() - testStartedAt;
|
||||
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
const abortText = sanitizeProviderErrorText(error, { secretValues: [apiKey.token] });
|
||||
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
|
||||
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
|
||||
if (ranLongEnough) {
|
||||
@@ -3059,7 +3099,7 @@ class DebridLinkClient {
|
||||
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
||||
const nextKey = this.apiKeys[nextIdx];
|
||||
if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
|
||||
nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
|
||||
nextLabel = `${nextKey.label}/${totalKeys}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -3226,22 +3266,24 @@ class DebridLinkClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async classifyKeyFailure(
|
||||
private async classifyKeyFailure(
|
||||
error: unknown,
|
||||
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
|
||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
||||
const description = error.message || code;
|
||||
link: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
|
||||
const redactions = { secretValues: [apiKey.token] };
|
||||
const errorText = sanitizeProviderErrorText(error, redactions);
|
||||
if (error instanceof DebridLinkApiError) {
|
||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
||||
const safeCode = sanitizeDiagnosticText(code, redactions);
|
||||
const description = sanitizeDiagnosticText(error.message || code, redactions);
|
||||
|
||||
if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
|
||||
message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`,
|
||||
message: `ungueltiger oder deaktivierter API-Key (${safeCode}: ${description})`,
|
||||
category: "invalid"
|
||||
};
|
||||
}
|
||||
@@ -3249,7 +3291,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
|
||||
message: `API-Rate-Limit erreicht (${code}: ${description})`,
|
||||
message: `API-Rate-Limit erreicht (${safeCode}: ${description})`,
|
||||
category: "rate_limit"
|
||||
};
|
||||
}
|
||||
@@ -3260,7 +3302,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs,
|
||||
message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
|
||||
message: `Quota erreicht fuer ${hosterLabel} (${safeCode}: ${description})`,
|
||||
category: "quota",
|
||||
hostOnly: true,
|
||||
hoster: hosterRaw
|
||||
@@ -3271,7 +3313,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs,
|
||||
message: `Quota erreicht (${code}: ${description})`,
|
||||
message: `Quota erreicht (${safeCode}: ${description})`,
|
||||
category: "quota"
|
||||
};
|
||||
}
|
||||
@@ -3279,7 +3321,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
|
||||
message: `Link kann aktuell nicht generiert werden (${safeCode}: ${description})`,
|
||||
category: "temporary",
|
||||
providerWide: true
|
||||
};
|
||||
@@ -3288,7 +3330,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: 0,
|
||||
message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`,
|
||||
message: `Key kann Link aktuell nicht verarbeiten (${safeCode}: ${description})`,
|
||||
category: "skip"
|
||||
};
|
||||
}
|
||||
@@ -3304,7 +3346,7 @@ class DebridLinkClient {
|
||||
return {
|
||||
fatal: false,
|
||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||
message: `temporärer API-Fehler (${code}: ${description})`
|
||||
message: `temporärer API-Fehler (${safeCode}: ${description})`
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -3744,13 +3786,25 @@ export class DebridService {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public setSettings(next: AppSettings): void {
|
||||
const prev = this.settings;
|
||||
this.settings = cloneSettings(next);
|
||||
|
||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
|
||||
this.cachedDebridLinkClient = null;
|
||||
this.cachedDebridLinkKey = "";
|
||||
public setSettings(next: AppSettings): void {
|
||||
const prev = this.settings;
|
||||
this.settings = cloneSettings(next);
|
||||
|
||||
const previousDebridLinkDisabled = new Set(prev.debridLinkDisabledKeyIds || []);
|
||||
const nextDebridLinkDisabled = new Set(next.debridLinkDisabledKeyIds || []);
|
||||
const changedDebridLinkKeys = new Set<string>();
|
||||
for (const keyId of new Set([...previousDebridLinkDisabled, ...nextDebridLinkDisabled])) {
|
||||
if (previousDebridLinkDisabled.has(keyId) !== nextDebridLinkDisabled.has(keyId)) {
|
||||
changedDebridLinkKeys.add(keyId);
|
||||
}
|
||||
}
|
||||
if (changedDebridLinkKeys.size > 0) {
|
||||
clearDebridLinkRuntimeStateForKeys(changedDebridLinkKeys);
|
||||
}
|
||||
|
||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys || changedDebridLinkKeys.size > 0) {
|
||||
this.cachedDebridLinkClient = null;
|
||||
this.cachedDebridLinkKey = "";
|
||||
}
|
||||
if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) {
|
||||
this.cachedLinkSnappyClient = null;
|
||||
|
||||
@@ -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)
|
||||
]));
|
||||
}
|
||||
+536
-265
File diff suppressed because it is too large
Load Diff
+37
-26
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
||||
@@ -53,11 +54,12 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -163,11 +165,16 @@ export function ensureItemLog(meta: ItemLogMeta): string | null {
|
||||
fs.writeFileSync(logPath, "", "utf8");
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
||||
initializedThisProcess.add(normalizedItemId);
|
||||
const startedAt = logTimestamp();
|
||||
const headerFields = sanitizeDiagnosticFields({
|
||||
itemId: String(meta.itemId || ""),
|
||||
logKey: normalizedItemId,
|
||||
fileName: meta.fileName
|
||||
}) || {};
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(headerFields.itemId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | fileName=${sanitizeFieldValue(headerFields.fileName)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
@@ -194,27 +201,31 @@ export function logItemEvent(
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||
appendLine(itemId, line);
|
||||
}
|
||||
|
||||
export function getItemLogPath(itemId: string): string | null {
|
||||
export function getItemLogPath(itemId: string): string | null {
|
||||
const logPath = getItemLogFilePath(itemId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function flushItemLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownItemLogs(): void {
|
||||
flushItemLogs();
|
||||
for (const itemId of knownLogPaths.keys()) {
|
||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
||||
if (!logPath) {
|
||||
|
||||
+61
-49
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
import { recordRecentError } from "./error-ring";
|
||||
import path from "node:path";
|
||||
import { sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
@@ -33,7 +34,7 @@ let legacyLogListener: LogListener | null = null;
|
||||
let pendingLines: string[] = [];
|
||||
let pendingChars = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let flushInFlight = false;
|
||||
let flushInFlight: Promise<void> | null = null;
|
||||
let exitHookAttached = false;
|
||||
|
||||
export function setLogListener(listener: LogListener | null): void {
|
||||
@@ -70,6 +71,17 @@ export function flushLoggerSync(): void {
|
||||
}
|
||||
flushSyncPending();
|
||||
}
|
||||
|
||||
export async function flushLogger(): Promise<void> {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (flushInFlight) {
|
||||
await flushInFlight;
|
||||
}
|
||||
flushLoggerSync();
|
||||
}
|
||||
|
||||
function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } {
|
||||
try {
|
||||
@@ -185,23 +197,13 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
if (flushInFlight || pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
flushInFlight = true;
|
||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
||||
// here means await-time writes queue independently and nothing desyncs.
|
||||
const linesSnapshot = pendingLines;
|
||||
async function performAsyncFlush(): Promise<void> {
|
||||
const linesSnapshot = pendingLines;
|
||||
pendingLines = [];
|
||||
pendingChars = 0;
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
pendingChars = 0;
|
||||
const chunk = linesSnapshot.join("");
|
||||
|
||||
try {
|
||||
await rotateIfNeededAsync(logFilePath);
|
||||
const primary = await appendChunk(logFilePath, chunk);
|
||||
let wroteAny = primary.ok;
|
||||
@@ -212,30 +214,39 @@ async function flushAsync(): Promise<void> {
|
||||
if (!fallback.ok) {
|
||||
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||
}
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
// Write failed: requeue the unwritten lines AHEAD of anything that arrived
|
||||
// during the await (preserve order), then re-apply the buffer cap so a
|
||||
// persistent write failure cannot grow the buffer without bound.
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!primary.ok) {
|
||||
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||
}
|
||||
if (!wroteAny) {
|
||||
pendingLines = linesSnapshot.concat(pendingLines);
|
||||
pendingChars += chunk.length;
|
||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||
const removed = pendingLines.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushInFlight = null;
|
||||
if (pendingLines.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
if (flushInFlight) {
|
||||
return flushInFlight;
|
||||
}
|
||||
if (pendingLines.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const operation = performAsyncFlush();
|
||||
flushInFlight = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
function ensureExitHook(): void {
|
||||
if (exitHookAttached) {
|
||||
@@ -246,17 +257,18 @@ function ensureExitHook(): void {
|
||||
process.once("exit", flushSyncPending);
|
||||
}
|
||||
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const line = `${ts} [${level}] ${message}\n`;
|
||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
||||
ensureExitHook();
|
||||
const ts = logTimestamp();
|
||||
const safeMessage = sanitizeDiagnosticText(message);
|
||||
const line = `${ts} [${level}] ${safeMessage}\n`;
|
||||
pendingLines.push(line);
|
||||
pendingChars += line.length;
|
||||
|
||||
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
|
||||
// "what failed recently" is answerable even after the file rotates.
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, message, ts);
|
||||
if (level === "ERROR" || level === "WARN") {
|
||||
recordRecentError(level, safeMessage, ts);
|
||||
}
|
||||
|
||||
for (const listener of logListeners) {
|
||||
|
||||
+6
-9
@@ -22,6 +22,7 @@ import { validateRendererSettingsUpdate } from "./renderer-settings";
|
||||
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
|
||||
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
|
||||
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
|
||||
import { writeClipboardTextFromIpc } from "./clipboard-ipc";
|
||||
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
@@ -612,13 +613,9 @@ function registerIpcHandlers(): void {
|
||||
updateClipboardWatcher();
|
||||
return next;
|
||||
});
|
||||
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => {
|
||||
if (typeof text !== "string" || text.length > 16 * 1024 * 1024) {
|
||||
throw new Error("Ungültiger Zwischenablageinhalt");
|
||||
}
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (event, text: unknown) => (
|
||||
writeClipboardTextFromIpc(event, text, getTrustedIpcOptions())
|
||||
));
|
||||
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||
const options = {
|
||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||
@@ -684,10 +681,10 @@ function registerIpcHandlers(): void {
|
||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||
return result.canceled || !result.filePath ? null : result.filePath;
|
||||
},
|
||||
onStart: () => controller.recordSupportBundleExportSelected(),
|
||||
build: async () => (await controller.exportSupportBundle()).buffer,
|
||||
write: writeSupportBundleAtomically,
|
||||
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
|
||||
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
|
||||
onLifecycle: (event) => controller.recordSupportBundleExportLifecycle(event)
|
||||
});
|
||||
|
||||
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
|
||||
|
||||
+36
-25
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
||||
@@ -52,11 +53,12 @@ function sanitizeFieldValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -163,10 +165,15 @@ export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
||||
}
|
||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
||||
initializedThisProcess.add(normalizedPackageId);
|
||||
const startedAt = logTimestamp();
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
||||
const startedAt = logTimestamp();
|
||||
const headerFields = sanitizeDiagnosticFields({
|
||||
packageId: String(meta.packageId || ""),
|
||||
logKey: normalizedPackageId,
|
||||
name: meta.name
|
||||
}) || {};
|
||||
fs.appendFileSync(
|
||||
logPath,
|
||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(headerFields.packageId)} | logKey=${sanitizeFieldValue(headerFields.logKey)} | name=${sanitizeFieldValue(headerFields.name)} ===\n`,
|
||||
"utf8"
|
||||
);
|
||||
fs.appendFileSync(
|
||||
@@ -192,27 +199,31 @@ export function logPackageEvent(
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
||||
if (!logPath) {
|
||||
return;
|
||||
}
|
||||
const line = `${logTimestamp()} [${level}] ${sanitizeDiagnosticText(message)}${formatFields(fields)}\n`;
|
||||
appendLine(packageId, line);
|
||||
}
|
||||
|
||||
export function getPackageLogPath(packageId: string): string | null {
|
||||
export function getPackageLogPath(packageId: string): string | null {
|
||||
const logPath = getPackageLogFilePath(packageId);
|
||||
if (!logPath) {
|
||||
return null;
|
||||
}
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
return fs.existsSync(logPath) ? logPath : null;
|
||||
}
|
||||
|
||||
export function flushPackageLogs(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownPackageLogs(): void {
|
||||
flushPackageLogs();
|
||||
for (const packageId of knownLogPaths.keys()) {
|
||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
||||
if (!logPath) {
|
||||
|
||||
+14
-10
@@ -97,20 +97,24 @@ export function initSessionLog(baseDir: string): void {
|
||||
void cleanupOldSessionLogs(sessionLogsDir, 7);
|
||||
}
|
||||
|
||||
export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
export function getSessionLogPath(): string | null {
|
||||
return sessionLogPath;
|
||||
}
|
||||
|
||||
export function flushSessionLog(): void {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
}
|
||||
|
||||
export function shutdownSessionLog(): void {
|
||||
if (!sessionLogPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
flushSessionLog();
|
||||
|
||||
const isoTimestamp = logTimestamp();
|
||||
try {
|
||||
|
||||
@@ -788,6 +788,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
|
||||
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
|
||||
resumeResetPending: Boolean(item.resumeResetPending) || undefined,
|
||||
http416FreshRestarts: clampNumber(item.http416FreshRestarts, 0, 0, 100) || undefined,
|
||||
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
||||
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||
|
||||
+369
-34
@@ -7,17 +7,25 @@ import { getAccountRotationLogPath } from "./account-rotation-log";
|
||||
import { getConversionLogPath } from "./conversion-trace";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { getDebugSetupCheck } from "./debug-setup";
|
||||
import { getLogFilePath } from "./logger";
|
||||
import { flushLogger, getLogFilePath } from "./logger";
|
||||
import { getRecentErrors } from "./error-ring";
|
||||
import { getRenameLogPath } from "./rename-log";
|
||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { flushSessionLog, getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { flushTraceLog, getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { flushPackageLogs, getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
||||
import { flushItemLogs, getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import {
|
||||
getMegaDebridAccountsForMode,
|
||||
getMegaDebridDisabledAccountIdsForMode,
|
||||
maskMegaDebridLogin,
|
||||
type MegaDebridAccountMode
|
||||
} from "../shared/mega-debrid-accounts";
|
||||
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown, type ProviderRuntimeSnapshot } from "./debrid";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
||||
|
||||
@@ -104,13 +112,14 @@ function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>):
|
||||
return String.fromCharCode(0xf8ff - offset);
|
||||
};
|
||||
const urlMarker = findMarker(raw, 0);
|
||||
let output = raw.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, urlMarker);
|
||||
let output = raw.replace(/\b(?:https?|file):(?:\\?\/){2}[^\s"'<>]+/gi, urlMarker);
|
||||
const pathMarker = findMarker(output, 1);
|
||||
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
|
||||
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
|
||||
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker);
|
||||
output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
|
||||
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
|
||||
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*,\s*[^)\r\n]+(?=\))/gi, "$1, <redacted-account>");
|
||||
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 (<redacted-account>)");
|
||||
output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, "$1\"<redacted>\"");
|
||||
output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)[^\s,;|]+/gi, "$1$2<redacted>");
|
||||
@@ -151,10 +160,20 @@ function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>
|
||||
return value;
|
||||
}
|
||||
|
||||
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string {
|
||||
return redactSupportText(zipPath, sensitiveValues)
|
||||
.split("/")
|
||||
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry")
|
||||
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>, redactFileName: boolean): string {
|
||||
const parts = zipPath.split("/");
|
||||
return parts
|
||||
.map((part, index) => {
|
||||
const redacted = redactFileName && index === parts.length - 1
|
||||
? redactSupportText(part, sensitiveValues)
|
||||
: part;
|
||||
const extension = path.posix.extname(redacted);
|
||||
const name = extension ? redacted.slice(0, -extension.length) : redacted;
|
||||
const safeName = name.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry";
|
||||
const safeExtension = extension.replace(/[<>:"\\|?*\x00-\x1f]/g, "_");
|
||||
const uniqueness = redacted === part ? "" : `-${randomUUID().slice(0, 12)}`;
|
||||
return `${safeName}${uniqueness}${safeExtension}`;
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
@@ -211,7 +230,8 @@ async function addTextFileIfExists(
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget,
|
||||
maxFileBytes: number,
|
||||
maxAgeMs?: number
|
||||
maxAgeMs?: number,
|
||||
redactArchiveFileName = false
|
||||
): Promise<boolean> {
|
||||
if (!sourcePath || budget.remainingBytes <= 0) {
|
||||
return false;
|
||||
@@ -231,7 +251,7 @@ async function addTextFileIfExists(
|
||||
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
|
||||
}
|
||||
await yieldToEventLoop();
|
||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
|
||||
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
|
||||
includedSourcePaths.add(sourcePathKey);
|
||||
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
|
||||
return true;
|
||||
@@ -250,6 +270,9 @@ async function addRecentDirectoryFiles(
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget
|
||||
): Promise<number> {
|
||||
if (maxFiles <= 0 || budget.remainingBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
|
||||
let directory;
|
||||
try {
|
||||
@@ -292,7 +315,45 @@ async function addRecentDirectoryFiles(
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
budget,
|
||||
MAX_TEXT_FILE_BYTES
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
undefined,
|
||||
true
|
||||
)) {
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
async function addRelevantLogFiles<T extends { id: string }>(
|
||||
zip: AdmZip,
|
||||
entries: readonly T[],
|
||||
resolveSourcePath: (id: string) => string | null,
|
||||
zipRoot: string,
|
||||
maxFiles: number,
|
||||
includedSourcePaths: Set<string>,
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
budget: TextBudget
|
||||
): Promise<number> {
|
||||
let added = 0;
|
||||
for (const entry of entries.slice(0, maxFiles)) {
|
||||
if (budget.remainingBytes <= 0) {
|
||||
break;
|
||||
}
|
||||
const sourcePath = resolveSourcePath(entry.id);
|
||||
if (!sourcePath) {
|
||||
continue;
|
||||
}
|
||||
if (await addTextFileIfExists(
|
||||
zip,
|
||||
sourcePath,
|
||||
path.posix.join(zipRoot, path.basename(sourcePath)),
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
budget,
|
||||
MAX_TEXT_FILE_BYTES,
|
||||
undefined,
|
||||
true
|
||||
)) {
|
||||
added += 1;
|
||||
}
|
||||
@@ -304,12 +365,38 @@ function isActiveStatus(status: unknown): boolean {
|
||||
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
|
||||
}
|
||||
|
||||
function createPackageDto(entry: PackageEntry): Record<string, unknown> {
|
||||
function getBundleAliasExtension(value: string): string {
|
||||
const extension = path.extname(String(value || "")).toLowerCase();
|
||||
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : "";
|
||||
}
|
||||
|
||||
function createBundleAlias(prefix: "package" | "item" | "history", index: number, sourceName = ""): string {
|
||||
const extension = getBundleAliasExtension(sourceName);
|
||||
return `${prefix}-${String(index + 1).padStart(3, "0")}${extension}`;
|
||||
}
|
||||
|
||||
function createPackageDto(
|
||||
entry: PackageEntry,
|
||||
name: string,
|
||||
items: Readonly<Record<string, DownloadItem>>
|
||||
): Record<string, unknown> {
|
||||
const currentItems = entry.itemIds.map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
|
||||
const downloadedBytes = Math.max(0, Number(entry.cleanedDownloadedBytes || 0))
|
||||
+ currentItems.reduce((sum, item) => sum + Math.max(0, Number(item.downloadedBytes || 0)), 0);
|
||||
const currentKnownTotals = currentItems
|
||||
.map((item) => item.totalBytes)
|
||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0);
|
||||
const hasKnownTotal = typeof entry.cleanedTotalBytes === "number" || currentKnownTotals.length > 0;
|
||||
const totalBytes = hasKnownTotal
|
||||
? Math.max(0, Number(entry.cleanedTotalBytes || 0)) + currentKnownTotals.reduce((sum, value) => sum + value, 0)
|
||||
: null;
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
name,
|
||||
status: entry.status,
|
||||
itemCount: entry.itemIds.length,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
cancelled: entry.cancelled,
|
||||
enabled: entry.enabled,
|
||||
priority: entry.priority,
|
||||
@@ -331,7 +418,7 @@ function getSourceHost(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
||||
function createItemDto(entry: DownloadItem, fileName: string): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
packageId: entry.packageId,
|
||||
@@ -345,12 +432,16 @@ function createItemDto(entry: DownloadItem): Record<string, unknown> {
|
||||
downloadedBytes: entry.downloadedBytes,
|
||||
totalBytes: entry.totalBytes,
|
||||
progressPercent: entry.progressPercent,
|
||||
fileName: entry.fileName,
|
||||
fileName,
|
||||
targetPath: entry.targetPath ? "<local-path>" : "",
|
||||
resumable: entry.resumable,
|
||||
attempts: entry.attempts,
|
||||
lastError: entry.lastError,
|
||||
fullStatus: entry.fullStatus,
|
||||
resumeLinkRenewalFailures: entry.resumeLinkRenewalFailures,
|
||||
resumeHardResetUsed: entry.resumeHardResetUsed,
|
||||
resumeResetPending: entry.resumeResetPending,
|
||||
http416FreshRestarts: entry.http416FreshRestarts,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
onlineStatus: entry.onlineStatus
|
||||
@@ -377,10 +468,10 @@ function createSessionDto(session: SessionState): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function createHistoryDto(entry: HistoryEntry): Record<string, unknown> {
|
||||
function createHistoryDto(entry: HistoryEntry, name: string): Record<string, unknown> {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
name,
|
||||
status: entry.status,
|
||||
provider: entry.provider,
|
||||
fileCount: entry.fileCount,
|
||||
@@ -403,7 +494,11 @@ async function loadBoundedHistory(filePath: string): Promise<{ total: number | n
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { total: 0, entries: [], omitted: 0 };
|
||||
}
|
||||
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry));
|
||||
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES)
|
||||
.map((entry, index) => createHistoryDto(
|
||||
entry as HistoryEntry,
|
||||
createBundleAlias("history", index, String((entry as HistoryEntry).name || ""))
|
||||
));
|
||||
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
|
||||
} catch {
|
||||
return { total: 0, entries: [], omitted: 0 };
|
||||
@@ -436,20 +531,64 @@ interface SupportBundleExportSuccess {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type SupportBundleExportPhase = "busy" | "cancel" | "build" | "write" | "success" | "failure";
|
||||
|
||||
export interface SupportBundleExportLifecycleEvent {
|
||||
phase: SupportBundleExportPhase;
|
||||
durationMs: number;
|
||||
totalDurationMs: number;
|
||||
bytes?: number;
|
||||
failedPhase?: "choose" | "build" | "write";
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export class SupportBundleExportError extends Error {
|
||||
public readonly phase: "choose" | "build" | "write";
|
||||
public readonly durationMs: number;
|
||||
public readonly code?: string;
|
||||
|
||||
public constructor(phase: "choose" | "build" | "write", durationMs: number, code?: string) {
|
||||
super(`Support-Bundle-Export fehlgeschlagen (${phase}${code ? `, ${code}` : ""}).`);
|
||||
this.name = "SupportBundleExportError";
|
||||
this.phase = phase;
|
||||
this.durationMs = durationMs;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface SupportBundleExportRunnerOptions {
|
||||
chooseFile: () => Promise<string | null>;
|
||||
build: () => Promise<Buffer>;
|
||||
write: (filePath: string, buffer: Buffer) => Promise<void>;
|
||||
now?: () => number;
|
||||
onStart?: (result: { filePath: string }) => Promise<void> | void;
|
||||
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
|
||||
onFailure?: (error: unknown) => Promise<void> | void;
|
||||
onFailure?: (error: SupportBundleExportError) => Promise<void> | void;
|
||||
onLifecycle?: (event: SupportBundleExportLifecycleEvent) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function getExportErrorCode(error: unknown): string | undefined {
|
||||
const code = String((error as NodeJS.ErrnoException | null)?.code || "").trim().toUpperCase();
|
||||
return /^[A-Z0-9_]{1,32}$/.test(code) ? code : undefined;
|
||||
}
|
||||
|
||||
export function createSupportBundleExportRunner(
|
||||
options: SupportBundleExportRunnerOptions
|
||||
): () => Promise<SupportBundleExportResult> {
|
||||
let active = false;
|
||||
const now = options.now || Date.now;
|
||||
const emitLifecycle = async (event: SupportBundleExportLifecycleEvent): Promise<void> => {
|
||||
if (!options.onLifecycle) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await options.onLifecycle(event);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
return async () => {
|
||||
if (active) {
|
||||
await emitLifecycle({ phase: "busy", durationMs: 0, totalDurationMs: 0 });
|
||||
return {
|
||||
saved: false,
|
||||
busy: true,
|
||||
@@ -457,28 +596,77 @@ export function createSupportBundleExportRunner(
|
||||
};
|
||||
}
|
||||
active = true;
|
||||
const startedAt = now();
|
||||
let phase: "choose" | "build" | "write" = "choose";
|
||||
let phaseStartedAt = startedAt;
|
||||
try {
|
||||
const filePath = await options.chooseFile();
|
||||
if (!filePath) {
|
||||
const finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "cancel",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt)
|
||||
});
|
||||
return { saved: false, busy: false };
|
||||
}
|
||||
if (options.onStart) {
|
||||
try {
|
||||
await options.onStart({ filePath });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
phase = "build";
|
||||
phaseStartedAt = now();
|
||||
const buffer = await options.build();
|
||||
let finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "build",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
phase = "write";
|
||||
phaseStartedAt = now();
|
||||
await options.write(filePath, buffer);
|
||||
finishedAt = now();
|
||||
await emitLifecycle({
|
||||
phase: "write",
|
||||
durationMs: Math.max(0, finishedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
if (options.onSuccess) {
|
||||
try {
|
||||
await options.onSuccess({ filePath, bytes: buffer.length });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
await emitLifecycle({
|
||||
phase: "success",
|
||||
durationMs: Math.max(0, finishedAt - startedAt),
|
||||
totalDurationMs: Math.max(0, finishedAt - startedAt),
|
||||
bytes: buffer.length
|
||||
});
|
||||
return { saved: true, busy: false, filePath };
|
||||
} catch (error) {
|
||||
const failedAt = now();
|
||||
const code = getExportErrorCode(error);
|
||||
const safeError = new SupportBundleExportError(phase, Math.max(0, failedAt - startedAt), code);
|
||||
await emitLifecycle({
|
||||
phase: "failure",
|
||||
failedPhase: phase,
|
||||
durationMs: Math.max(0, failedAt - phaseStartedAt),
|
||||
totalDurationMs: Math.max(0, failedAt - startedAt),
|
||||
...(code ? { code } : {})
|
||||
});
|
||||
if (options.onFailure) {
|
||||
try {
|
||||
await options.onFailure(error);
|
||||
await options.onFailure(safeError);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
throw safeError;
|
||||
} finally {
|
||||
active = false;
|
||||
}
|
||||
@@ -531,7 +719,7 @@ function createDeferredHostDiagnostics(reason: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
if (mode === "none") {
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
||||
}
|
||||
@@ -542,30 +730,139 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
||||
}
|
||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
||||
}
|
||||
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> {
|
||||
const zip = new AdmZip();
|
||||
const includedSourcePaths = new Set<string>();
|
||||
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
|
||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
||||
const debugSetupMode = options.debugSetupMode || "full";
|
||||
const generatedAt = new Date().toISOString();
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const sensitiveValues = collectSensitiveValues(settings);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const packageEntries = Object.values(snapshot.session.packages);
|
||||
const itemEntries = Object.values(snapshot.session.items);
|
||||
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
|
||||
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
|
||||
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
|
||||
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
|
||||
const selectedPackages = selectedPackageEntries
|
||||
.map((entry, index) => createPackageDto(entry, createBundleAlias("package", index, entry.name), snapshot.session.items));
|
||||
const selectedItems = selectedItemEntries
|
||||
.map((entry, index) => createItemDto(entry, createBundleAlias("item", index, entry.fileName)));
|
||||
const history = await loadBoundedHistory(storagePaths.historyFile);
|
||||
const debugSetup = options.debugSetupMode === "deferred"
|
||||
const debugSetup = debugSetupMode === "deferred"
|
||||
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
|
||||
: getDebugSetupCheck(baseDir);
|
||||
|
||||
await addJson(zip, "overview/meta.json", {
|
||||
appVersion: APP_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
runtimeBaseDir: "<local-path>",
|
||||
packageCount: packageEntries.length,
|
||||
itemCount: itemEntries.length,
|
||||
@@ -574,7 +871,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
itemDtos: MAX_ITEM_DTOS,
|
||||
textBytes: MAX_TOTAL_TEXT_BYTES,
|
||||
textFileBytes: MAX_TEXT_FILE_BYTES,
|
||||
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000
|
||||
directoryLogDiscoveryWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000,
|
||||
currentAndRelevantLogsIgnoreAgeFilter: true
|
||||
}
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
|
||||
@@ -589,7 +887,6 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
}
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
|
||||
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
|
||||
await addJson(zip, "overview/history.json", history, sensitiveValues);
|
||||
await addJson(zip, "overview/packages.json", {
|
||||
count: packageEntries.length,
|
||||
@@ -603,6 +900,17 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
omitted: Math.max(0, itemEntries.length - selectedItems.length),
|
||||
items: selectedItems
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/runtime-diagnostics.json", {
|
||||
bundleBuild: {
|
||||
state: "building",
|
||||
startedAt: generatedAt,
|
||||
hostDiagnosticsMode,
|
||||
debugSetupMode
|
||||
},
|
||||
rotationEvents: (snapshot.rotationEvents || []).slice(0, 60),
|
||||
diskWaitEvents: (snapshot.diskWaitEvents || []).slice(-60),
|
||||
providerRuntime: createProviderRuntimeDto(settings)
|
||||
}, sensitiveValues);
|
||||
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
|
||||
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
|
||||
const recentErrors = getRecentErrors().slice(-100);
|
||||
@@ -642,6 +950,33 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
|
||||
|
||||
await flushLogger();
|
||||
flushSessionLog();
|
||||
flushPackageLogs();
|
||||
flushItemLogs();
|
||||
flushTraceLog();
|
||||
|
||||
const relevantPackageLogCount = await addRelevantLogFiles(
|
||||
zip,
|
||||
selectedPackageEntries,
|
||||
getPersistedPackageLogPath,
|
||||
"logs/package-logs",
|
||||
MAX_PACKAGE_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
);
|
||||
const relevantItemLogCount = await addRelevantLogFiles(
|
||||
zip,
|
||||
selectedItemEntries,
|
||||
getPersistedItemLogPath,
|
||||
"logs/item-logs",
|
||||
MAX_ITEM_LOG_FILES,
|
||||
includedSourcePaths,
|
||||
sensitiveValues,
|
||||
textBudget
|
||||
);
|
||||
|
||||
const mainLogPath = getLogFilePath();
|
||||
const auditLogPath = getAuditLogPath();
|
||||
const renameLogPath = getRenameLogPath();
|
||||
@@ -664,8 +999,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
|
||||
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_PACKAGE_LOG_FILES - relevantPackageLogCount), includedSourcePaths, sensitiveValues, textBudget);
|
||||
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_ITEM_LOG_FILES - relevantItemLogCount), includedSourcePaths, sensitiveValues, textBudget);
|
||||
|
||||
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
|
||||
if (supportManifest) {
|
||||
|
||||
+33
-27
@@ -1,8 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import { logTimestamp } from "./log-timestamp";
|
||||
import path from "node:path";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
import { addLogListener, removeLogListener } from "./logger";
|
||||
import type { SupportTraceConfig } from "../shared/types";
|
||||
import { sanitizeDiagnosticFields, sanitizeDiagnosticText } from "./diagnostic-sanitizer";
|
||||
|
||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -28,28 +29,29 @@ let pendingLines: string[] = [];
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
function sanitizeFieldValue(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.replace(/\r?\n/g, "\\n");
|
||||
if (typeof value === "string") {
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
||||
} catch {
|
||||
return String(value);
|
||||
try {
|
||||
return sanitizeDiagnosticText(JSON.stringify(value));
|
||||
} catch {
|
||||
return sanitizeDiagnosticText(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
if (!fields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(fields)
|
||||
function formatFields(fields?: Record<string, unknown>): string {
|
||||
const safeFields = sanitizeDiagnosticFields(fields);
|
||||
if (!safeFields) {
|
||||
return "";
|
||||
}
|
||||
const parts = Object.entries(safeFields)
|
||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||
@@ -276,7 +278,7 @@ export function setTraceEnabled(enabled: boolean, note = "", durationMs: number
|
||||
return next;
|
||||
}
|
||||
|
||||
export function logTraceEvent(
|
||||
export function logTraceEvent(
|
||||
level: TraceLevel,
|
||||
category: string,
|
||||
message: string,
|
||||
@@ -285,23 +287,27 @@ export function logTraceEvent(
|
||||
if (!traceConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
||||
}
|
||||
|
||||
export function shutdownTraceLog(): void {
|
||||
if (category === "audit" && !traceConfig.includeAudit) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
removeLogListener(mainLogListener);
|
||||
clearAutoDisableTimer();
|
||||
if (!traceLogPath) {
|
||||
return;
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
flushPending();
|
||||
flushTraceLog();
|
||||
try {
|
||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||
} catch {
|
||||
|
||||
+172
-113
@@ -38,7 +38,12 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||
import {
|
||||
preservePackageOrderForDisplay,
|
||||
reconcileCollapsedPackageState,
|
||||
reconcileOptimisticPackageOrder,
|
||||
sortPackageOrderByName
|
||||
} from "./package-order";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
@@ -869,11 +874,56 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
|
||||
|
||||
const AUTO_RENDER_PACKAGE_LIMIT = 260;
|
||||
|
||||
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
||||
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
||||
if (!running) delay = Math.min(delay, 200);
|
||||
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||
return delay;
|
||||
export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _activeTab: MainView): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ResetUiActionGate {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
interface ResetUiActionOptions {
|
||||
gate: ResetUiActionGate;
|
||||
reset: () => Promise<void>;
|
||||
reconcile: () => Promise<void>;
|
||||
setBusy: (busy: boolean) => void;
|
||||
onError: (error: unknown) => void;
|
||||
onBusy?: () => void;
|
||||
}
|
||||
|
||||
export async function runResetUiAction(options: ResetUiActionOptions): Promise<"completed" | "failed" | "busy"> {
|
||||
if (options.gate.busy) {
|
||||
options.onBusy?.();
|
||||
return "busy";
|
||||
}
|
||||
options.gate.busy = true;
|
||||
options.setBusy(true);
|
||||
let failed = false;
|
||||
let failure: unknown;
|
||||
try {
|
||||
await options.reset();
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
await options.reconcile();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failure = error;
|
||||
}
|
||||
failed = true;
|
||||
}
|
||||
try {
|
||||
if (failed) {
|
||||
options.onError(failure);
|
||||
return "failed";
|
||||
}
|
||||
return "completed";
|
||||
} finally {
|
||||
options.gate.busy = false;
|
||||
options.setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
interface SupportBundleExportUiOptions {
|
||||
@@ -1466,19 +1516,7 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "
|
||||
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
|
||||
const COLUMN_DEFS = downloadColumnDefinitions;
|
||||
|
||||
function sameStringArray(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatMbpsInputFromKbps(kbps: number): string {
|
||||
function formatMbpsInputFromKbps(kbps: number): string {
|
||||
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
|
||||
return String(Number(mbps.toFixed(2)));
|
||||
}
|
||||
@@ -1567,14 +1605,54 @@ export function App(): ReactElement {
|
||||
return () => localizer.disconnect();
|
||||
}, [settingsDraft.language]);
|
||||
const panelDirtyRevisionRef = useRef(0);
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
const tabRef = useRef(tab);
|
||||
tabRef.current = tab;
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const tabRef = useRef(tab);
|
||||
tabRef.current = tab;
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stageAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): UiSnapshot => {
|
||||
masterSnapshotRef.current = fresh;
|
||||
serverPackageOrderRef.current = fresh.session.packageOrder;
|
||||
const order = reconcileOptimisticPackageOrder(
|
||||
fresh.session.packageOrder,
|
||||
pendingPackageOrderRef.current,
|
||||
pendingPackageOrderAtRef.current,
|
||||
Date.now()
|
||||
);
|
||||
pendingPackageOrderRef.current = order.pendingOrder;
|
||||
pendingPackageOrderAtRef.current = order.pendingAt;
|
||||
packageOrderRef.current = order.displayOrder;
|
||||
if (order.displayOrder === fresh.session.packageOrder) {
|
||||
return fresh;
|
||||
}
|
||||
return {
|
||||
...fresh,
|
||||
session: {
|
||||
...fresh.session,
|
||||
packageOrder: order.displayOrder
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const applyAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): void => {
|
||||
if (stateFlushTimerRef.current) {
|
||||
clearTimeout(stateFlushTimerRef.current);
|
||||
stateFlushTimerRef.current = null;
|
||||
}
|
||||
const displaySnapshot = stageAuthoritativeSnapshot(fresh);
|
||||
latestStateRef.current = null;
|
||||
snapshotRef.current = displaySnapshot;
|
||||
setSnapshot(displaySnapshot);
|
||||
}, [stageAuthoritativeSnapshot]);
|
||||
const reconcileAuthoritativeSnapshot = useCallback(async (): Promise<void> => {
|
||||
applyAuthoritativeSnapshot(await window.rd.getSnapshot());
|
||||
}, [applyAuthoritativeSnapshot]);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null);
|
||||
@@ -1590,12 +1668,8 @@ export function App(): ReactElement {
|
||||
const [collectorError, setCollectorError] = useState("");
|
||||
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
|
||||
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const packageOrderRef = useRef<string[]>([]);
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||
const activeTabRef = useRef<Tab>(tab);
|
||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||
const [downloadSearch, setDownloadSearch] = useState("");
|
||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||
@@ -1605,6 +1679,7 @@ export function App(): ReactElement {
|
||||
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
|
||||
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
|
||||
@@ -1612,6 +1687,7 @@ export function App(): ReactElement {
|
||||
const accountToggleRevisionRef = useRef(0);
|
||||
const accountTogglePendingRef = useRef(0);
|
||||
const actionBusyRef = useRef(false);
|
||||
const resetUiActionGateRef = useRef<ResetUiActionGate>({ busy: false });
|
||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
||||
@@ -1734,35 +1810,7 @@ export function App(): ReactElement {
|
||||
activeTabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
const incoming = snapshot.session.packageOrder;
|
||||
serverPackageOrderRef.current = incoming;
|
||||
|
||||
const pending = pendingPackageOrderRef.current;
|
||||
if (!pending) {
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sameStringArray(pending, incoming)) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxOptimisticHoldMs = 1500;
|
||||
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = incoming;
|
||||
return;
|
||||
}
|
||||
|
||||
packageOrderRef.current = pending;
|
||||
}, [snapshot.session.packageOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
|
||||
}, [settingsDraft.speedLimitKbps]);
|
||||
|
||||
@@ -1805,6 +1853,23 @@ export function App(): ReactElement {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const performReset = useCallback(async (reset: () => Promise<void>): Promise<void> => {
|
||||
if (!resetUiActionGateRef.current.busy) {
|
||||
showToast("Zurücksetzen läuft …", 60_000);
|
||||
}
|
||||
const result = await runResetUiAction({
|
||||
gate: resetUiActionGateRef.current,
|
||||
reset,
|
||||
reconcile: reconcileAuthoritativeSnapshot,
|
||||
setBusy: setResetBusy,
|
||||
onError: (error) => { showToast(`Zurücksetzen fehlgeschlagen: ${String(error)}`, 3200); },
|
||||
onBusy: () => { showToast("Zurücksetzen läuft bereits …", 2200); }
|
||||
});
|
||||
if (result === "completed") {
|
||||
showToast("Zurücksetzen abgeschlossen", 1800);
|
||||
}
|
||||
}, [reconcileAuthoritativeSnapshot, showToast]);
|
||||
|
||||
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
|
||||
const availableIds = entries.map((entry) => entry.id);
|
||||
const availableSet = new Set(availableIds);
|
||||
@@ -1943,8 +2008,7 @@ export function App(): ReactElement {
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
masterSnapshotRef.current = state;
|
||||
setSnapshot(state);
|
||||
applyAuthoritativeSnapshot(state);
|
||||
if (state.settings.columnOrder?.length > 0) {
|
||||
setColumnOrder(state.settings.columnOrder);
|
||||
}
|
||||
@@ -1989,8 +2053,7 @@ export function App(): ReactElement {
|
||||
} else {
|
||||
merged = wireState;
|
||||
}
|
||||
masterSnapshotRef.current = merged;
|
||||
latestStateRef.current = merged;
|
||||
latestStateRef.current = stageAuthoritativeSnapshot(merged);
|
||||
if (stateFlushTimerRef.current) { return; }
|
||||
|
||||
const itemCount = Object.keys(merged.session.items).length;
|
||||
@@ -1999,8 +2062,9 @@ export function App(): ReactElement {
|
||||
stateFlushTimerRef.current = setTimeout(() => {
|
||||
stateFlushTimerRef.current = null;
|
||||
if (latestStateRef.current) {
|
||||
const next = latestStateRef.current;
|
||||
setSnapshot(next);
|
||||
const next = latestStateRef.current;
|
||||
snapshotRef.current = next;
|
||||
setSnapshot(next);
|
||||
if (next.settings.columnOrder?.length > 0) {
|
||||
setColumnOrder(next.settings.columnOrder);
|
||||
}
|
||||
@@ -2055,7 +2119,7 @@ export function App(): ReactElement {
|
||||
if (unsubClipboard) { unsubClipboard(); }
|
||||
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
||||
};
|
||||
}, [clearImportQueueFocusListener]);
|
||||
}, [applyAuthoritativeSnapshot, clearImportQueueFocusListener, stageAuthoritativeSnapshot]);
|
||||
|
||||
const downloadsTabActive = tab === "downloads";
|
||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||
@@ -2090,28 +2154,16 @@ export function App(): ReactElement {
|
||||
}, [downloadsTabActive, snapshot.session.packageOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadsTabActive) {
|
||||
return;
|
||||
}
|
||||
setCollapsedPackages((prev) => {
|
||||
let changed = false;
|
||||
const next: Record<string, boolean> = { ...prev };
|
||||
const defaultCollapsed = totalPackageCount >= 24;
|
||||
for (const packageId of snapshot.session.packageOrder) {
|
||||
if (!(packageId in prev)) {
|
||||
next[packageId] = defaultCollapsed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const packageId of Object.keys(next)) {
|
||||
if (!snapshot.session.packages[packageId]) {
|
||||
delete next[packageId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||
if (!downloadsTabActive) {
|
||||
return;
|
||||
}
|
||||
setCollapsedPackages((prev) => reconcileCollapsedPackageState(
|
||||
prev,
|
||||
snapshot.session.packageOrder,
|
||||
snapshot.session.packages,
|
||||
totalPackageCount >= 24
|
||||
));
|
||||
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
|
||||
|
||||
// Prune selection when its packages/items disappear (e.g. via delta-removal or
|
||||
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
|
||||
@@ -3316,7 +3368,11 @@ export function App(): ReactElement {
|
||||
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,
|
||||
canPause: snapshot.canPause,
|
||||
canStop: snapshot.canStop,
|
||||
actionBusy,
|
||||
actionBusy: actionBusy || resetBusy,
|
||||
reconnectSeconds: snapshot.reconnectSeconds,
|
||||
reconnectReason: snapshot.session.reconnectReason,
|
||||
clipboardWatcher: snapshot.clipboardActive,
|
||||
@@ -4712,7 +4768,7 @@ export function App(): ReactElement {
|
||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||
eta: snapshot.etaText
|
||||
}
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, resetBusy, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||
|
||||
const downloadsActions: DownloadsViewActions = {
|
||||
onDisplayModeChange: setDownloadDisplayMode,
|
||||
@@ -4732,10 +4788,7 @@ export function App(): ReactElement {
|
||||
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
|
||||
} finally {
|
||||
try {
|
||||
const fresh = await window.rd.getSnapshot();
|
||||
masterSnapshotRef.current = fresh;
|
||||
latestStateRef.current = null;
|
||||
setSnapshot(fresh);
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -4746,11 +4799,11 @@ export function App(): ReactElement {
|
||||
},
|
||||
onPauseDownloads: () => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
|
||||
void window.rd.togglePause().then((paused) => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
|
||||
void window.rd.togglePause().then(async () => {
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
}).catch(async (error) => {
|
||||
try {
|
||||
setSnapshot(await window.rd.getSnapshot());
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
|
||||
@@ -4758,9 +4811,11 @@ export function App(): ReactElement {
|
||||
},
|
||||
onStopDownloads: () => {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
|
||||
void window.rd.stop().catch(async (error) => {
|
||||
void window.rd.stop().then(async () => {
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
}).catch(async (error) => {
|
||||
try {
|
||||
setSnapshot(await window.rd.getSnapshot());
|
||||
await reconcileAuthoritativeSnapshot();
|
||||
} catch {
|
||||
}
|
||||
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
|
||||
@@ -4920,7 +4975,7 @@ export function App(): ReactElement {
|
||||
if (failedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
void window.rd.resetItems(failedIds).catch(() => {});
|
||||
void performReset(() => window.rd.resetItems(failedIds));
|
||||
}
|
||||
};
|
||||
const collectorActions: CollectorViewActions = {
|
||||
@@ -6097,17 +6152,21 @@ export function App(): ReactElement {
|
||||
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
|
||||
)}
|
||||
{hasPackages && !contextMenu.itemId && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
|
||||
setContextMenu(null);
|
||||
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
|
||||
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||
void performReset(async () => {
|
||||
for (const id of selectedPackageIds) {
|
||||
await window.rd.resetPackage(id);
|
||||
}
|
||||
});
|
||||
setContextMenu(null);
|
||||
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedPackageIds.length})` : ""}`}</button>
|
||||
)}
|
||||
{contextMenu.itemId && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||
void window.rd.resetItems(itemIds).catch(() => {});
|
||||
setContextMenu(null);
|
||||
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
|
||||
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
|
||||
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
|
||||
void performReset(() => window.rd.resetItems(itemIds));
|
||||
setContextMenu(null);
|
||||
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedItemIds.length})` : ""}`}</button>
|
||||
)}
|
||||
{hasPackages && !multi && (() => {
|
||||
const pkg = snapshot.session.packages[contextMenu.packageId];
|
||||
|
||||
@@ -9,6 +9,8 @@ export type AccountToggleTarget =
|
||||
export interface AccountToggleSettings {
|
||||
disabledProviders: DebridProvider[];
|
||||
debridLinkDisabledKeyIds: string[];
|
||||
megaDebridApiEnabled: boolean;
|
||||
megaDebridWebEnabled: boolean;
|
||||
megaDebridDisabledAccountIds: string[];
|
||||
megaDebridApiDisabledAccountIds: string[];
|
||||
megaDebridWebDisabledAccountIds: string[];
|
||||
@@ -45,6 +47,8 @@ export function setAccountTargetEnabled<T extends AccountToggleSettings>(
|
||||
: settings.megaDebridWebDisabledAccountIds;
|
||||
return {
|
||||
...settings,
|
||||
megaDebridApiEnabled: target.kind === "mega-api" && enabled ? true : settings.megaDebridApiEnabled,
|
||||
megaDebridWebEnabled: target.kind === "mega-web" && enabled ? true : settings.megaDebridWebEnabled,
|
||||
megaDebridApiDisabledAccountIds: apiDisabled,
|
||||
megaDebridWebDisabledAccountIds: webDisabled,
|
||||
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
|
||||
@@ -55,6 +59,8 @@ export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings
|
||||
return {
|
||||
disabledProviders: settings.disabledProviders,
|
||||
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
|
||||
megaDebridApiEnabled: settings.megaDebridApiEnabled,
|
||||
megaDebridWebEnabled: settings.megaDebridWebEnabled,
|
||||
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
|
||||
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
|
||||
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
|
||||
|
||||
@@ -27,3 +27,78 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
||||
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
|
||||
return packages;
|
||||
}
|
||||
|
||||
export type OptimisticPackageOrderStatus = "idle" | "pending" | "acknowledged" | "timed-out";
|
||||
|
||||
export interface OptimisticPackageOrderReconciliation {
|
||||
displayOrder: string[];
|
||||
pendingOrder: string[] | null;
|
||||
pendingAt: number;
|
||||
status: OptimisticPackageOrderStatus;
|
||||
}
|
||||
|
||||
function samePackageOrder(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((packageId, index) => packageId === right[index]);
|
||||
}
|
||||
|
||||
export function reconcileOptimisticPackageOrder(
|
||||
authoritativeOrder: string[],
|
||||
pendingOrder: string[] | null,
|
||||
pendingAt: number,
|
||||
now: number,
|
||||
holdMs = 1_500
|
||||
): OptimisticPackageOrderReconciliation {
|
||||
if (!pendingOrder) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "idle"
|
||||
};
|
||||
}
|
||||
if (samePackageOrder(authoritativeOrder, pendingOrder)) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "acknowledged"
|
||||
};
|
||||
}
|
||||
if (now - pendingAt >= holdMs) {
|
||||
return {
|
||||
displayOrder: authoritativeOrder,
|
||||
pendingOrder: null,
|
||||
pendingAt: 0,
|
||||
status: "timed-out"
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayOrder: pendingOrder,
|
||||
pendingOrder,
|
||||
pendingAt,
|
||||
status: "pending"
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileCollapsedPackageState(
|
||||
previous: Record<string, boolean>,
|
||||
packageOrder: string[],
|
||||
packages: Record<string, PackageEntry>,
|
||||
defaultCollapsed: boolean
|
||||
): Record<string, boolean> {
|
||||
let changed = false;
|
||||
const next = { ...previous };
|
||||
for (const packageId of packageOrder) {
|
||||
if (!(packageId in previous)) {
|
||||
next[packageId] = defaultCollapsed;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const packageId of Object.keys(next)) {
|
||||
if (!packages[packageId]) {
|
||||
delete next[packageId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : previous;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export function getRollingMetricDirection(previous: number, next: number): Rolli
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function shouldAnimateRollingMetric(
|
||||
direction: RollingMetricDirection,
|
||||
reducedMotion: boolean
|
||||
): direction is Exclude<RollingMetricDirection, "none"> {
|
||||
return direction !== "none" && !reducedMotion;
|
||||
}
|
||||
|
||||
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
|
||||
const previousRef = useRef({ numericValue, value });
|
||||
const sequenceRef = useRef(0);
|
||||
@@ -34,7 +41,10 @@ export function RollingMetricValue({ numericValue, value }: RollingMetricValuePr
|
||||
if (previous.value === value && previous.numericValue === numericValue) return;
|
||||
previousRef.current = { numericValue, value };
|
||||
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
|
||||
if (direction === "none") {
|
||||
const reducedMotion = typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (!shouldAnimateRollingMetric(direction, reducedMotion)) {
|
||||
setTransition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
+11
-3
@@ -366,6 +366,7 @@ export interface DownloadItem {
|
||||
resumeLinkRenewalFailures?: number;
|
||||
resumeHardResetUsed?: boolean;
|
||||
resumeResetPending?: boolean;
|
||||
http416FreshRestarts?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
onlineStatus?: "online" | "offline" | "checking";
|
||||
@@ -449,7 +450,7 @@ export interface ContainerImportResult {
|
||||
source: "dlc";
|
||||
}
|
||||
|
||||
export interface RotationEvent {
|
||||
export interface RotationEvent {
|
||||
id: string;
|
||||
at: number;
|
||||
level: "INFO" | "WARN" | "ERROR";
|
||||
@@ -459,8 +460,11 @@ export interface RotationEvent {
|
||||
reason?: string;
|
||||
category?: string;
|
||||
cooldownSec?: number;
|
||||
next?: string;
|
||||
}
|
||||
next?: string;
|
||||
attemptId?: string;
|
||||
itemId?: string;
|
||||
packageId?: string;
|
||||
}
|
||||
|
||||
export interface UiSnapshot {
|
||||
settings: RendererSettings;
|
||||
@@ -485,7 +489,11 @@ export interface UiSnapshot {
|
||||
requiredBytes: number;
|
||||
availableBytes: number;
|
||||
deficitBytes: number;
|
||||
safetyBytes: number;
|
||||
retryAt: number;
|
||||
at: number;
|
||||
state: "waiting" | "resolved";
|
||||
resolvedAt?: number;
|
||||
}>;
|
||||
payloadKind?: "full" | "delta";
|
||||
removedItemIds?: string[];
|
||||
|
||||
Reference in New Issue
Block a user