fix: harden rotation recovery and support diagnostics
Persist resume recovery across restarts, reconcile lifecycle controls with authoritative state, and wait for locked partial files before clean restarts. Capture real account-attempt trails, redact historical account masks from support bundles, and keep all active telemetry on a stable 500 ms cadence.
This commit is contained in:
@@ -28,10 +28,6 @@ export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): Rotati
|
||||
return slice;
|
||||
}
|
||||
|
||||
function isUiRelevantRotationEvent(event: string): boolean {
|
||||
return event !== "TEST";
|
||||
}
|
||||
|
||||
function pushRotationEvent(
|
||||
level: RotationLevel,
|
||||
provider: string,
|
||||
@@ -62,16 +58,16 @@ function pushRotationEvent(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUiRelevantRotationEvent(event)) {
|
||||
return;
|
||||
}
|
||||
rotationEventRing.push(entry);
|
||||
const uiEntry = event === "TIMEOUT_COOLDOWN"
|
||||
? { ...entry, reason: entry.reason ? `Versuch fehlgeschlagen: ${entry.reason}` : "Versuch fehlgeschlagen" }
|
||||
: entry;
|
||||
rotationEventRing.push(uiEntry);
|
||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||
}
|
||||
if (rotationEventListener) {
|
||||
try {
|
||||
rotationEventListener(entry);
|
||||
rotationEventListener(uiEntry);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
+165
-85
@@ -1735,6 +1735,33 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
||||
return Math.floor(jitter);
|
||||
}
|
||||
|
||||
function clearResumeRecoveryState(item: DownloadItem): void {
|
||||
delete item.resumeLinkRenewalFailures;
|
||||
delete item.resumeHardResetUsed;
|
||||
delete item.resumeResetPending;
|
||||
}
|
||||
|
||||
async function removeResumePartialForReset(targetPath: string): Promise<boolean> {
|
||||
const retryableCodes = new Set(["EBUSY", "EPERM", "EACCES"]);
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
} catch (error) {
|
||||
const code = String((error as NodeJS.ErrnoException)?.code || "");
|
||||
if (!retryableCodes.has(code)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return true;
|
||||
}
|
||||
if (attempt < 3) {
|
||||
await sleep((attempt + 1) * 100);
|
||||
}
|
||||
}
|
||||
return !fs.existsSync(targetPath);
|
||||
}
|
||||
|
||||
function isMegaDebridProviderKey(value: string): boolean {
|
||||
return value === "megadebrid"
|
||||
|| value === "megadebrid-api"
|
||||
@@ -2083,9 +2110,9 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
private logItemOnly(
|
||||
item: DownloadItem,
|
||||
level: "INFO" | "WARN" | "ERROR",
|
||||
private logItemOnly(
|
||||
item: DownloadItem,
|
||||
level: "INFO" | "WARN" | "ERROR",
|
||||
message: string,
|
||||
fields?: Record<string, unknown>
|
||||
): void {
|
||||
@@ -2098,9 +2125,21 @@ export class DownloadManager extends EventEmitter {
|
||||
fileName: item.fileName,
|
||||
status: item.status,
|
||||
targetPath: item.targetPath,
|
||||
...fields
|
||||
});
|
||||
}
|
||||
...fields
|
||||
});
|
||||
}
|
||||
|
||||
private logRotationEventForItem(item: DownloadItem, event: RotationEvent): void {
|
||||
this.logItemOnly(item, event.level, "Account-Rotation", {
|
||||
provider: event.provider,
|
||||
account: event.accountLabel,
|
||||
event: event.event,
|
||||
reason: event.reason,
|
||||
category: event.category,
|
||||
cooldownSec: event.cooldownSec,
|
||||
next: event.next
|
||||
});
|
||||
}
|
||||
|
||||
private collectRenameMatchTokensForItem(pkg: PackageEntry, item: DownloadItem): string[] {
|
||||
const tokens = new Set<string>();
|
||||
@@ -5434,9 +5473,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.runOutcomes.delete(itemId);
|
||||
this.runItemIds.delete(itemId);
|
||||
this.retryAfterByItem.delete(itemId);
|
||||
this.retryStateByItem.delete(itemId);
|
||||
|
||||
item.status = "queued";
|
||||
this.retryStateByItem.delete(itemId);
|
||||
clearResumeRecoveryState(item);
|
||||
|
||||
item.status = "queued";
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
@@ -5517,9 +5557,10 @@ export class DownloadManager extends EventEmitter {
|
||||
this.dropItemContribution(itemId);
|
||||
this.runOutcomes.delete(itemId);
|
||||
this.retryAfterByItem.delete(itemId);
|
||||
this.retryStateByItem.delete(itemId);
|
||||
|
||||
item.status = "queued";
|
||||
this.retryStateByItem.delete(itemId);
|
||||
clearResumeRecoveryState(item);
|
||||
|
||||
item.status = "queued";
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
@@ -6238,9 +6279,10 @@ export class DownloadManager extends EventEmitter {
|
||||
item.updatedAt = nowMs();
|
||||
continue;
|
||||
}
|
||||
if (item.status === "extracting" || item.status === "integrity_check") {
|
||||
item.status = "completed";
|
||||
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||
if (item.status === "extracting" || item.status === "integrity_check") {
|
||||
item.status = "completed";
|
||||
clearResumeRecoveryState(item);
|
||||
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
} else if (item.status === "downloading"
|
||||
@@ -6529,11 +6571,11 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
const itemCount = this.itemCount;
|
||||
const emitDelay = this.session.running
|
||||
? itemCount >= 1500
|
||||
? 700
|
||||
: itemCount >= 700
|
||||
? 500
|
||||
const emitDelay = this.session.running
|
||||
? itemCount >= 1500
|
||||
? 500
|
||||
: itemCount >= 700
|
||||
? 500
|
||||
: itemCount >= 250
|
||||
? 300
|
||||
: 150
|
||||
@@ -6986,8 +7028,9 @@ export class DownloadManager extends EventEmitter {
|
||||
error: errorText || undefined
|
||||
});
|
||||
|
||||
item.status = "completed";
|
||||
item.fullStatus = this.settings.autoExtract
|
||||
item.status = "completed";
|
||||
clearResumeRecoveryState(item);
|
||||
item.fullStatus = this.settings.autoExtract
|
||||
? "Entpacken - Ausstehend"
|
||||
: `Fertig (${humanSize(diskState.size)})`;
|
||||
item.downloadedBytes = diskState.size;
|
||||
@@ -9010,7 +9053,7 @@ export class DownloadManager extends EventEmitter {
|
||||
return count;
|
||||
}
|
||||
|
||||
private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void {
|
||||
private queueRetry(item: DownloadItem, active: ActiveTask, delayMs: number, statusText: string): void {
|
||||
const waitMs = Math.max(0, Math.floor(delayMs));
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
@@ -9019,13 +9062,14 @@ export class DownloadManager extends EventEmitter {
|
||||
item.attempts = 0;
|
||||
active.abortController = new AbortController();
|
||||
active.abortReason = "none";
|
||||
this.retryStateByItem.set(item.id, {
|
||||
this.retryStateByItem.set(item.id, {
|
||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||
stallRetries: Number(active.stallRetries || 0),
|
||||
genericErrorRetries: Number(active.genericErrorRetries || 0),
|
||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||
});
|
||||
unrestrictRetries: Number(active.unrestrictRetries || 0)
|
||||
});
|
||||
item.resumeHardResetUsed = Boolean(active.resumeHardResetUsed) || undefined;
|
||||
this.logPackageForItem(item, "WARN", "Retry eingeplant", {
|
||||
delayMs: waitMs,
|
||||
statusText,
|
||||
@@ -9039,6 +9083,25 @@ export class DownloadManager extends EventEmitter {
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg) this.refreshPackageStatus(pkg);
|
||||
}
|
||||
|
||||
private async applyPendingResumeReset(item: DownloadItem, active: ActiveTask, targetPath: string): Promise<boolean> {
|
||||
const removed = !targetPath || await removeResumePartialForReset(targetPath);
|
||||
if (!removed) {
|
||||
active.resumeHardResetUsed = false;
|
||||
delete item.resumeHardResetUsed;
|
||||
return false;
|
||||
}
|
||||
active.resumeHardResetUsed = true;
|
||||
item.resumeHardResetUsed = true;
|
||||
delete item.resumeResetPending;
|
||||
this.releaseTargetPath(item.id);
|
||||
this.dropItemContribution(item.id);
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
item.speedBps = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
private scheduleHttp416Retry(
|
||||
item: DownloadItem,
|
||||
@@ -9199,19 +9262,32 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryState = this.retryStateByItem.get(item.id) || {
|
||||
freshRetryUsed: false,
|
||||
resumeHardResetUsed: false,
|
||||
stallRetries: 0,
|
||||
genericErrorRetries: 0,
|
||||
unrestrictRetries: 0
|
||||
const retryState = this.retryStateByItem.get(item.id) || {
|
||||
freshRetryUsed: false,
|
||||
resumeHardResetUsed: Boolean(item.resumeHardResetUsed),
|
||||
stallRetries: 0,
|
||||
genericErrorRetries: Math.max(0, Number(item.resumeLinkRenewalFailures || 0)),
|
||||
unrestrictRetries: 0
|
||||
};
|
||||
this.retryStateByItem.set(item.id, retryState);
|
||||
active.freshRetryUsed = retryState.freshRetryUsed;
|
||||
active.resumeHardResetUsed = retryState.resumeHardResetUsed;
|
||||
active.stallRetries = retryState.stallRetries;
|
||||
active.genericErrorRetries = retryState.genericErrorRetries;
|
||||
active.unrestrictRetries = retryState.unrestrictRetries;
|
||||
active.genericErrorRetries = retryState.genericErrorRetries;
|
||||
active.unrestrictRetries = retryState.unrestrictRetries;
|
||||
if (item.resumeResetPending) {
|
||||
const resetTargetPath = String(item.targetPath || "").trim();
|
||||
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||
this.queueRetry(
|
||||
item,
|
||||
active,
|
||||
resetApplied ? 300 : 1000,
|
||||
resetApplied ? "Resume-Fehler erkannt, kompletter Neuversuch" : "Warte auf Teildatei-Freigabe"
|
||||
);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit);
|
||||
const retryDisplayLimit = retryLimitLabel(configuredRetryLimit);
|
||||
const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit);
|
||||
@@ -9291,30 +9367,33 @@ export class DownloadManager extends EventEmitter {
|
||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||
let unrestricted;
|
||||
try {
|
||||
unrestricted = await runWithConversionTrace(
|
||||
{
|
||||
itemId: item.id,
|
||||
itemName: item.fileName || item.id,
|
||||
link: item.url,
|
||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
||||
},
|
||||
async () => {
|
||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
||||
try {
|
||||
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
||||
} catch (innerError) {
|
||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||
traceConversionPhase({
|
||||
phase: "caller-timeout",
|
||||
outcome: "timeout",
|
||||
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
||||
});
|
||||
}
|
||||
throw innerError;
|
||||
}
|
||||
}
|
||||
);
|
||||
unrestricted = await runWithRotationItemSink(
|
||||
(event) => this.logRotationEventForItem(item, event),
|
||||
() => runWithConversionTrace(
|
||||
{
|
||||
itemId: item.id,
|
||||
itemName: item.fileName || item.id,
|
||||
link: item.url,
|
||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
||||
},
|
||||
async () => {
|
||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
||||
try {
|
||||
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
||||
} catch (innerError) {
|
||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||
traceConversionPhase({
|
||||
phase: "caller-timeout",
|
||||
outcome: "timeout",
|
||||
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
||||
});
|
||||
}
|
||||
throw innerError;
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (unrestrictError) {
|
||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||
this.recordProviderFailure(cooldownProvider);
|
||||
@@ -9518,8 +9597,9 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const completedAt = nowMs();
|
||||
item.status = "completed";
|
||||
item.fullStatus = this.settings.autoExtract
|
||||
item.status = "completed";
|
||||
clearResumeRecoveryState(item);
|
||||
item.fullStatus = this.settings.autoExtract
|
||||
? "Entpacken - Ausstehend"
|
||||
: `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||
item.progressPercent = 100;
|
||||
@@ -9779,32 +9859,30 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
||||
active.resumeHardResetUsed = true;
|
||||
item.retries += 1;
|
||||
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||
if (resetTargetPath) {
|
||||
try {
|
||||
fs.rmSync(resetTargetPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.releaseTargetPath(item.id);
|
||||
this.dropItemContribution(item.id);
|
||||
item.lastError = exhaustedReason;
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = null;
|
||||
item.progressPercent = 0;
|
||||
this.queueRetry(item, active, 300, "Resume-Fehler erkannt, kompletter Neuversuch");
|
||||
item.retries += 1;
|
||||
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||
item.resumeResetPending = true;
|
||||
item.lastError = exhaustedReason;
|
||||
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||
this.queueRetry(
|
||||
item,
|
||||
active,
|
||||
resetApplied ? 300 : 1000,
|
||||
resetApplied ? "Resume-Fehler erkannt, kompletter Neuversuch" : "Warte auf Teildatei-Freigabe"
|
||||
);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
||||
active.genericErrorRetries += 1;
|
||||
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
||||
active.genericErrorRetries += 1;
|
||||
item.retries += 1;
|
||||
const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, "");
|
||||
const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText).replace(/^Error:\s*/i, "");
|
||||
if (isResumeHardResetReason(exhaustedReason, 1)) {
|
||||
item.resumeLinkRenewalFailures = active.genericErrorRetries;
|
||||
}
|
||||
const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
||||
logger.warn(
|
||||
`Direktlink erschöpft: item=${item.fileName || item.id}, ` +
|
||||
@@ -11265,9 +11343,10 @@ export class DownloadManager extends EventEmitter {
|
||||
return recovered + finalized;
|
||||
}
|
||||
|
||||
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
||||
this.retryStateByItem.delete(item.id);
|
||||
const targetPath = String(item.targetPath || "").trim();
|
||||
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
||||
this.retryStateByItem.delete(item.id);
|
||||
clearResumeRecoveryState(item);
|
||||
const targetPath = String(item.targetPath || "").trim();
|
||||
if (options.hardReset && targetPath) {
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
@@ -12213,8 +12292,9 @@ export class DownloadManager extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
||||
item.status = "completed";
|
||||
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
||||
item.status = "completed";
|
||||
clearResumeRecoveryState(item);
|
||||
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
||||
item.downloadedBytes = stat.size;
|
||||
item.progressPercent = 100;
|
||||
item.speedBps = 0;
|
||||
|
||||
+13
-8
@@ -759,11 +759,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
|
||||
const statusRaw = asText(item.status) as DownloadStatus;
|
||||
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
||||
const providerRaw = asText(item.provider) as DebridProvider;
|
||||
|
||||
const onlineStatusRaw = asText(item.onlineStatus);
|
||||
|
||||
itemsById[id] = {
|
||||
const providerRaw = asText(item.provider) as DebridProvider;
|
||||
|
||||
const onlineStatusRaw = asText(item.onlineStatus);
|
||||
const lastError = asText(item.lastError);
|
||||
const legacyResumeFailureCount = /^(?:range_ignored_on_resume:|range_mismatch_on_resume:|resume_download_underflow:)/i.test(lastError) ? 1 : 0;
|
||||
|
||||
itemsById[id] = {
|
||||
id,
|
||||
packageId,
|
||||
url,
|
||||
@@ -781,9 +783,12 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
||||
targetPath: asText(item.targetPath),
|
||||
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
|
||||
attempts: clampNumber(item.attempts, 0, 0, 10_000),
|
||||
lastError: asText(item.lastError),
|
||||
fullStatus: asText(item.fullStatus),
|
||||
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
||||
lastError,
|
||||
fullStatus: asText(item.fullStatus),
|
||||
resumeLinkRenewalFailures: clampNumber(item.resumeLinkRenewalFailures, legacyResumeFailureCount, 0, 1_000_000) || undefined,
|
||||
resumeHardResetUsed: Boolean(item.resumeHardResetUsed) || undefined,
|
||||
resumeResetPending: Boolean(item.resumeResetPending) || 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)
|
||||
};
|
||||
|
||||
+56
-31
@@ -14,8 +14,10 @@ import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
||||
import { getSessionLogPath } from "./session-log";
|
||||
import { createStoragePaths, loadSettings } from "./storage";
|
||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||
import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
||||
|
||||
@@ -41,27 +43,37 @@ function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function addSensitiveValue(output: Set<string>, value: string): void {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
output.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function collectSensitiveValues(value: unknown, key = "", output = new Set<string>()): Set<string> {
|
||||
if (typeof value === "string") {
|
||||
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) {
|
||||
for (const candidate of [value, ...value.split(/\r?\n/)]) {
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed.length >= 4) {
|
||||
output.add(trimmed);
|
||||
}
|
||||
addSensitiveValue(output, trimmed);
|
||||
if (/credential/i.test(key)) {
|
||||
const separator = trimmed.indexOf(":");
|
||||
if (separator > 0) {
|
||||
const login = trimmed.slice(0, separator).trim();
|
||||
const password = trimmed.slice(separator + 1).trim();
|
||||
if (login.length >= 4) {
|
||||
output.add(login);
|
||||
}
|
||||
if (password.length >= 4) {
|
||||
output.add(password);
|
||||
addSensitiveValue(output, login);
|
||||
addSensitiveValue(output, password);
|
||||
if (/mega.?debrid/i.test(key)) {
|
||||
addSensitiveValue(output, maskMegaDebridLogin(login));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (/debrid.?link.*api.?keys?/i.test(key)) {
|
||||
for (const entry of parseDebridLinkApiKeys(trimmed)) {
|
||||
addSensitiveValue(output, entry.token);
|
||||
addSensitiveValue(output, entry.masked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
@@ -80,33 +92,46 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set<strin
|
||||
return output;
|
||||
}
|
||||
|
||||
function redactUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}/<redacted>`;
|
||||
} catch {
|
||||
return "<redacted-url>";
|
||||
}
|
||||
}
|
||||
|
||||
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): string {
|
||||
let output = String(value || "").replace(/\0/g, "");
|
||||
const secrets = [...sensitiveValues].sort((a, b) => b.length - a.length);
|
||||
for (const secret of secrets) {
|
||||
output = output.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
|
||||
}
|
||||
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url));
|
||||
output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*')/gi, "$1\"<redacted>\"");
|
||||
const raw = String(value || "").replace(/\0/g, "");
|
||||
const findMarker = (source: string, offset: number): string => {
|
||||
for (let index = offset; index < 0x1900; index += 1) {
|
||||
const marker = String.fromCharCode(0xe000 + index);
|
||||
if (!source.includes(marker) && [...sensitiveValues].every((secret) => !secret.includes(marker))) {
|
||||
return marker;
|
||||
}
|
||||
}
|
||||
return String.fromCharCode(0xf8ff - offset);
|
||||
};
|
||||
const urlMarker = findMarker(raw, 0);
|
||||
let output = raw.replace(/\b(?:https?|file):\/\/[^\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(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;|]+)/gi, "$1$2<redacted>");
|
||||
output = output.replace(/\b((?: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>");
|
||||
const secretVariants = new Set<string>();
|
||||
for (const secret of sensitiveValues) {
|
||||
secretVariants.add(secret);
|
||||
const jsonEscaped = JSON.stringify(secret).slice(1, -1);
|
||||
if (jsonEscaped) {
|
||||
secretVariants.add(jsonEscaped);
|
||||
}
|
||||
}
|
||||
for (const secret of [...secretVariants].sort((a, b) => b.length - a.length)) {
|
||||
const escaped = escapeRegExp(secret);
|
||||
output = secret.length >= 4
|
||||
? output.replace(new RegExp(escaped, "g"), "<redacted>")
|
||||
: output.replace(new RegExp(`(^|[^A-Za-z0-9])${escaped}(?=$|[^A-Za-z0-9])`, "g"), "$1<redacted>");
|
||||
}
|
||||
output = output.replace(/\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g, "<redacted>");
|
||||
output = output.replace(/\b(?=[A-Za-z0-9+/_=-]{24,}\b)(?=[A-Za-z0-9+/_=-]*[A-Za-z])(?=[A-Za-z0-9+/_=-]*\d)[A-Za-z0-9+/_=-]+\b/g, "<redacted>");
|
||||
output = output.replace(/\b[A-Z]:\\[^\r\n|"<>]+/gi, "<local-path>");
|
||||
output = output.replace(/\\\\[^\r\n|"<>]+/g, "<local-path>");
|
||||
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, "<local-path>");
|
||||
output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "<redacted-email>");
|
||||
return output;
|
||||
return output.replaceAll(urlMarker, "<redacted-url>").replaceAll(pathMarker, "<local-path>");
|
||||
}
|
||||
|
||||
function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>, key = ""): unknown {
|
||||
|
||||
+17
-5
@@ -872,7 +872,7 @@ 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 (activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||
return delay;
|
||||
}
|
||||
|
||||
@@ -1292,10 +1292,9 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
|
||||
if (!running || paused) {
|
||||
return;
|
||||
}
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const interval = setInterval(() => {
|
||||
drawChart();
|
||||
}, reducedMotion ? 1000 : 500);
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [drawChart, running, paused]);
|
||||
|
||||
@@ -4726,8 +4725,21 @@ export function App(): ReactElement {
|
||||
},
|
||||
onStartDownloads: () => {
|
||||
if (snapshot.session.paused) {
|
||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } }));
|
||||
void window.rd.togglePause().catch(() => {});
|
||||
void (async () => {
|
||||
try {
|
||||
await window.rd.togglePause();
|
||||
} catch (error) {
|
||||
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
|
||||
} finally {
|
||||
try {
|
||||
const fresh = await window.rd.getSnapshot();
|
||||
masterSnapshotRef.current = fresh;
|
||||
latestStateRef.current = null;
|
||||
setSnapshot(fresh);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
void onStartDownloads();
|
||||
}
|
||||
|
||||
+7
-4
@@ -360,10 +360,13 @@ export interface DownloadItem {
|
||||
fileName: string;
|
||||
targetPath: string;
|
||||
resumable: boolean;
|
||||
attempts: number;
|
||||
lastError: string;
|
||||
fullStatus: string;
|
||||
createdAt: number;
|
||||
attempts: number;
|
||||
lastError: string;
|
||||
fullStatus: string;
|
||||
resumeLinkRenewalFailures?: number;
|
||||
resumeHardResetUsed?: boolean;
|
||||
resumeResetPending?: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
onlineStatus?: "online" | "offline" | "checking";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user