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:
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.30] - 2026-08-13
|
||||||
|
|
||||||
|
### Rotation diagnostics
|
||||||
|
|
||||||
|
- Recorded every real account conversion attempt in the affected file log, including masked TEST, FAILED, timeout, fallback, and successful outcome events.
|
||||||
|
- Kept timeout-cooldown attempts visible as failures in the live rotation diagnostics without changing their technical event type.
|
||||||
|
- Removed historical and current account masks, short credentials, URLs, and local paths from generated support bundles.
|
||||||
|
|
||||||
|
### Download recovery
|
||||||
|
|
||||||
|
- Persisted resumed-link recovery state across application restarts so repeated range rejection advances to one clean full download instead of restarting the same retry cycle.
|
||||||
|
- Retried transient Windows file locks before discarding a partial download and prevented a new request until the old partial file is confirmed removed.
|
||||||
|
- Reconciled Pause and Resume controls with the authoritative main-process snapshot after success, rejection, or a missing state event.
|
||||||
|
|
||||||
|
### Live interface cadence
|
||||||
|
|
||||||
|
- Limited large running queues and the live bandwidth chart to the same stable 500 ms refresh cadence used by the other active download telemetry.
|
||||||
|
|
||||||
## [2.0.29] - 2026-08-13
|
## [2.0.29] - 2026-08-13
|
||||||
|
|
||||||
### Account and rotation reliability
|
### Account and rotation reliability
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.29",
|
"version": "2.0.30",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.29",
|
"version": "2.0.30",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.29",
|
"version": "2.0.30",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): Rotati
|
|||||||
return slice;
|
return slice;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUiRelevantRotationEvent(event: string): boolean {
|
|
||||||
return event !== "TEST";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushRotationEvent(
|
function pushRotationEvent(
|
||||||
level: RotationLevel,
|
level: RotationLevel,
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -62,16 +58,16 @@ function pushRotationEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUiRelevantRotationEvent(event)) {
|
const uiEntry = event === "TIMEOUT_COOLDOWN"
|
||||||
return;
|
? { ...entry, reason: entry.reason ? `Versuch fehlgeschlagen: ${entry.reason}` : "Versuch fehlgeschlagen" }
|
||||||
}
|
: entry;
|
||||||
rotationEventRing.push(entry);
|
rotationEventRing.push(uiEntry);
|
||||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
||||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
||||||
}
|
}
|
||||||
if (rotationEventListener) {
|
if (rotationEventListener) {
|
||||||
try {
|
try {
|
||||||
rotationEventListener(entry);
|
rotationEventListener(uiEntry);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-85
@@ -1735,6 +1735,33 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
|
|||||||
return Math.floor(jitter);
|
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 {
|
function isMegaDebridProviderKey(value: string): boolean {
|
||||||
return value === "megadebrid"
|
return value === "megadebrid"
|
||||||
|| value === "megadebrid-api"
|
|| value === "megadebrid-api"
|
||||||
@@ -2083,9 +2110,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private logItemOnly(
|
private logItemOnly(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
level: "INFO" | "WARN" | "ERROR",
|
level: "INFO" | "WARN" | "ERROR",
|
||||||
message: string,
|
message: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
@@ -2098,9 +2125,21 @@ export class DownloadManager extends EventEmitter {
|
|||||||
fileName: item.fileName,
|
fileName: item.fileName,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
targetPath: item.targetPath,
|
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[] {
|
private collectRenameMatchTokensForItem(pkg: PackageEntry, item: DownloadItem): string[] {
|
||||||
const tokens = new Set<string>();
|
const tokens = new Set<string>();
|
||||||
@@ -5434,9 +5473,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runOutcomes.delete(itemId);
|
this.runOutcomes.delete(itemId);
|
||||||
this.runItemIds.delete(itemId);
|
this.runItemIds.delete(itemId);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
this.retryStateByItem.delete(itemId);
|
this.retryStateByItem.delete(itemId);
|
||||||
|
clearResumeRecoveryState(item);
|
||||||
item.status = "queued";
|
|
||||||
|
item.status = "queued";
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
@@ -5517,9 +5557,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.dropItemContribution(itemId);
|
this.dropItemContribution(itemId);
|
||||||
this.runOutcomes.delete(itemId);
|
this.runOutcomes.delete(itemId);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
this.retryStateByItem.delete(itemId);
|
this.retryStateByItem.delete(itemId);
|
||||||
|
clearResumeRecoveryState(item);
|
||||||
item.status = "queued";
|
|
||||||
|
item.status = "queued";
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.totalBytes = null;
|
item.totalBytes = null;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
@@ -6238,9 +6279,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (item.status === "extracting" || item.status === "integrity_check") {
|
if (item.status === "extracting" || item.status === "integrity_check") {
|
||||||
item.status = "completed";
|
item.status = "completed";
|
||||||
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
clearResumeRecoveryState(item);
|
||||||
|
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
} else if (item.status === "downloading"
|
} else if (item.status === "downloading"
|
||||||
@@ -6529,11 +6571,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const itemCount = this.itemCount;
|
const itemCount = this.itemCount;
|
||||||
const emitDelay = this.session.running
|
const emitDelay = this.session.running
|
||||||
? itemCount >= 1500
|
? itemCount >= 1500
|
||||||
? 700
|
? 500
|
||||||
: itemCount >= 700
|
: itemCount >= 700
|
||||||
? 500
|
? 500
|
||||||
: itemCount >= 250
|
: itemCount >= 250
|
||||||
? 300
|
? 300
|
||||||
: 150
|
: 150
|
||||||
@@ -6986,8 +7028,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
error: errorText || undefined
|
error: errorText || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
item.status = "completed";
|
item.status = "completed";
|
||||||
item.fullStatus = this.settings.autoExtract
|
clearResumeRecoveryState(item);
|
||||||
|
item.fullStatus = this.settings.autoExtract
|
||||||
? "Entpacken - Ausstehend"
|
? "Entpacken - Ausstehend"
|
||||||
: `Fertig (${humanSize(diskState.size)})`;
|
: `Fertig (${humanSize(diskState.size)})`;
|
||||||
item.downloadedBytes = diskState.size;
|
item.downloadedBytes = diskState.size;
|
||||||
@@ -9010,7 +9053,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return count;
|
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));
|
const waitMs = Math.max(0, Math.floor(delayMs));
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
@@ -9019,13 +9062,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.attempts = 0;
|
item.attempts = 0;
|
||||||
active.abortController = new AbortController();
|
active.abortController = new AbortController();
|
||||||
active.abortReason = "none";
|
active.abortReason = "none";
|
||||||
this.retryStateByItem.set(item.id, {
|
this.retryStateByItem.set(item.id, {
|
||||||
freshRetryUsed: Boolean(active.freshRetryUsed),
|
freshRetryUsed: Boolean(active.freshRetryUsed),
|
||||||
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
|
||||||
stallRetries: Number(active.stallRetries || 0),
|
stallRetries: Number(active.stallRetries || 0),
|
||||||
genericErrorRetries: Number(active.genericErrorRetries || 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", {
|
this.logPackageForItem(item, "WARN", "Retry eingeplant", {
|
||||||
delayMs: waitMs,
|
delayMs: waitMs,
|
||||||
statusText,
|
statusText,
|
||||||
@@ -9039,6 +9083,25 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
if (pkg) this.refreshPackageStatus(pkg);
|
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(
|
private scheduleHttp416Retry(
|
||||||
item: DownloadItem,
|
item: DownloadItem,
|
||||||
@@ -9199,19 +9262,32 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryState = this.retryStateByItem.get(item.id) || {
|
const retryState = this.retryStateByItem.get(item.id) || {
|
||||||
freshRetryUsed: false,
|
freshRetryUsed: false,
|
||||||
resumeHardResetUsed: false,
|
resumeHardResetUsed: Boolean(item.resumeHardResetUsed),
|
||||||
stallRetries: 0,
|
stallRetries: 0,
|
||||||
genericErrorRetries: 0,
|
genericErrorRetries: Math.max(0, Number(item.resumeLinkRenewalFailures || 0)),
|
||||||
unrestrictRetries: 0
|
unrestrictRetries: 0
|
||||||
};
|
};
|
||||||
this.retryStateByItem.set(item.id, retryState);
|
this.retryStateByItem.set(item.id, retryState);
|
||||||
active.freshRetryUsed = retryState.freshRetryUsed;
|
active.freshRetryUsed = retryState.freshRetryUsed;
|
||||||
active.resumeHardResetUsed = retryState.resumeHardResetUsed;
|
active.resumeHardResetUsed = retryState.resumeHardResetUsed;
|
||||||
active.stallRetries = retryState.stallRetries;
|
active.stallRetries = retryState.stallRetries;
|
||||||
active.genericErrorRetries = retryState.genericErrorRetries;
|
active.genericErrorRetries = retryState.genericErrorRetries;
|
||||||
active.unrestrictRetries = retryState.unrestrictRetries;
|
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 configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit);
|
||||||
const retryDisplayLimit = retryLimitLabel(configuredRetryLimit);
|
const retryDisplayLimit = retryLimitLabel(configuredRetryLimit);
|
||||||
const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit);
|
const maxItemRetries = retryLimitToMaxRetries(configuredRetryLimit);
|
||||||
@@ -9291,30 +9367,33 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||||
let unrestricted;
|
let unrestricted;
|
||||||
try {
|
try {
|
||||||
unrestricted = await runWithConversionTrace(
|
unrestricted = await runWithRotationItemSink(
|
||||||
{
|
(event) => this.logRotationEventForItem(item, event),
|
||||||
itemId: item.id,
|
() => runWithConversionTrace(
|
||||||
itemName: item.fileName || item.id,
|
{
|
||||||
link: item.url,
|
itemId: item.id,
|
||||||
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
itemName: item.fileName || item.id,
|
||||||
},
|
link: item.url,
|
||||||
async () => {
|
providerOrder: (this.settings.providerOrder || []).join(",") || String(this.getExpectedProviderForItem(item) || "?")
|
||||||
traceConversionNote("slots", this.describeSlotOccupancy());
|
},
|
||||||
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
async () => {
|
||||||
try {
|
traceConversionNote("slots", this.describeSlotOccupancy());
|
||||||
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
traceConversionNote("retry", Number(active.unrestrictRetries || 0));
|
||||||
} catch (innerError) {
|
try {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
return await this.debridService.unrestrictLink(item.url, unrestrictedSignal, undefined, preferredLeadProvider);
|
||||||
traceConversionPhase({
|
} catch (innerError) {
|
||||||
phase: "caller-timeout",
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
outcome: "timeout",
|
traceConversionPhase({
|
||||||
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
phase: "caller-timeout",
|
||||||
});
|
outcome: "timeout",
|
||||||
}
|
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
|
||||||
throw innerError;
|
});
|
||||||
}
|
}
|
||||||
}
|
throw innerError;
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (unrestrictError) {
|
} catch (unrestrictError) {
|
||||||
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
|
||||||
this.recordProviderFailure(cooldownProvider);
|
this.recordProviderFailure(cooldownProvider);
|
||||||
@@ -9518,8 +9597,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completedAt = nowMs();
|
const completedAt = nowMs();
|
||||||
item.status = "completed";
|
item.status = "completed";
|
||||||
item.fullStatus = this.settings.autoExtract
|
clearResumeRecoveryState(item);
|
||||||
|
item.fullStatus = this.settings.autoExtract
|
||||||
? "Entpacken - Ausstehend"
|
? "Entpacken - Ausstehend"
|
||||||
: `Fertig (${humanSize(item.downloadedBytes)})`;
|
: `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||||
item.progressPercent = 100;
|
item.progressPercent = 100;
|
||||||
@@ -9779,32 +9859,30 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
|
||||||
active.resumeHardResetUsed = true;
|
item.retries += 1;
|
||||||
item.retries += 1;
|
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
||||||
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
|
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
||||||
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
|
item.resumeResetPending = true;
|
||||||
if (resetTargetPath) {
|
item.lastError = exhaustedReason;
|
||||||
try {
|
const resetApplied = await this.applyPendingResumeReset(item, active, resetTargetPath);
|
||||||
fs.rmSync(resetTargetPath, { force: true });
|
this.queueRetry(
|
||||||
} catch {
|
item,
|
||||||
}
|
active,
|
||||||
}
|
resetApplied ? 300 : 1000,
|
||||||
this.releaseTargetPath(item.id);
|
resetApplied ? "Resume-Fehler erkannt, kompletter Neuversuch" : "Warte auf Teildatei-Freigabe"
|
||||||
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");
|
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) {
|
||||||
active.genericErrorRetries += 1;
|
active.genericErrorRetries += 1;
|
||||||
item.retries += 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);
|
const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Direktlink erschöpft: item=${item.fileName || item.id}, ` +
|
`Direktlink erschöpft: item=${item.fileName || item.id}, ` +
|
||||||
@@ -11265,9 +11343,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return recovered + finalized;
|
return recovered + finalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
|
||||||
this.retryStateByItem.delete(item.id);
|
this.retryStateByItem.delete(item.id);
|
||||||
const targetPath = String(item.targetPath || "").trim();
|
clearResumeRecoveryState(item);
|
||||||
|
const targetPath = String(item.targetPath || "").trim();
|
||||||
if (options.hardReset && targetPath) {
|
if (options.hardReset && targetPath) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(targetPath, { force: true });
|
fs.rmSync(targetPath, { force: true });
|
||||||
@@ -12213,8 +12292,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
||||||
item.status = "completed";
|
item.status = "completed";
|
||||||
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
clearResumeRecoveryState(item);
|
||||||
|
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
||||||
item.downloadedBytes = stat.size;
|
item.downloadedBytes = stat.size;
|
||||||
item.progressPercent = 100;
|
item.progressPercent = 100;
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
|
|||||||
+13
-8
@@ -759,11 +759,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
|
|
||||||
const statusRaw = asText(item.status) as DownloadStatus;
|
const statusRaw = asText(item.status) as DownloadStatus;
|
||||||
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
|
||||||
const providerRaw = asText(item.provider) as DebridProvider;
|
const providerRaw = asText(item.provider) as DebridProvider;
|
||||||
|
|
||||||
const onlineStatusRaw = asText(item.onlineStatus);
|
const onlineStatusRaw = asText(item.onlineStatus);
|
||||||
|
const lastError = asText(item.lastError);
|
||||||
itemsById[id] = {
|
const legacyResumeFailureCount = /^(?:range_ignored_on_resume:|range_mismatch_on_resume:|resume_download_underflow:)/i.test(lastError) ? 1 : 0;
|
||||||
|
|
||||||
|
itemsById[id] = {
|
||||||
id,
|
id,
|
||||||
packageId,
|
packageId,
|
||||||
url,
|
url,
|
||||||
@@ -781,9 +783,12 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
targetPath: asText(item.targetPath),
|
targetPath: asText(item.targetPath),
|
||||||
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
|
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
|
||||||
attempts: clampNumber(item.attempts, 0, 0, 10_000),
|
attempts: clampNumber(item.attempts, 0, 0, 10_000),
|
||||||
lastError: asText(item.lastError),
|
lastError,
|
||||||
fullStatus: asText(item.fullStatus),
|
fullStatus: asText(item.fullStatus),
|
||||||
onlineStatus: VALID_ONLINE_STATUSES.has(onlineStatusRaw) ? onlineStatusRaw as "online" | "offline" | "checking" : undefined,
|
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),
|
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||||
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
|
|||||||
+56
-31
@@ -14,8 +14,10 @@ import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
|||||||
import { getSessionLogPath } from "./session-log";
|
import { getSessionLogPath } from "./session-log";
|
||||||
import { createStoragePaths, loadSettings } from "./storage";
|
import { createStoragePaths, loadSettings } from "./storage";
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
||||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
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 { DownloadManager } from "./download-manager";
|
||||||
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
|
||||||
|
|
||||||
@@ -41,27 +43,37 @@ function escapeRegExp(value: string): string {
|
|||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
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> {
|
function collectSensitiveValues(value: unknown, key = "", output = new Set<string>()): Set<string> {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) {
|
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) {
|
||||||
for (const candidate of [value, ...value.split(/\r?\n/)]) {
|
for (const candidate of [value, ...value.split(/\r?\n/)]) {
|
||||||
const trimmed = candidate.trim();
|
const trimmed = candidate.trim();
|
||||||
if (trimmed.length >= 4) {
|
addSensitiveValue(output, trimmed);
|
||||||
output.add(trimmed);
|
|
||||||
}
|
|
||||||
if (/credential/i.test(key)) {
|
if (/credential/i.test(key)) {
|
||||||
const separator = trimmed.indexOf(":");
|
const separator = trimmed.indexOf(":");
|
||||||
if (separator > 0) {
|
if (separator > 0) {
|
||||||
const login = trimmed.slice(0, separator).trim();
|
const login = trimmed.slice(0, separator).trim();
|
||||||
const password = trimmed.slice(separator + 1).trim();
|
const password = trimmed.slice(separator + 1).trim();
|
||||||
if (login.length >= 4) {
|
addSensitiveValue(output, login);
|
||||||
output.add(login);
|
addSensitiveValue(output, password);
|
||||||
}
|
if (/mega.?debrid/i.test(key)) {
|
||||||
if (password.length >= 4) {
|
addSensitiveValue(output, maskMegaDebridLogin(login));
|
||||||
output.add(password);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (/debrid.?link.*api.?keys?/i.test(key)) {
|
||||||
|
for (const entry of parseDebridLinkApiKeys(trimmed)) {
|
||||||
|
addSensitiveValue(output, entry.token);
|
||||||
|
addSensitiveValue(output, entry.masked);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -80,33 +92,46 @@ function collectSensitiveValues(value: unknown, key = "", output = new Set<strin
|
|||||||
return output;
|
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 {
|
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): string {
|
||||||
let output = String(value || "").replace(/\0/g, "");
|
const raw = String(value || "").replace(/\0/g, "");
|
||||||
const secrets = [...sensitiveValues].sort((a, b) => b.length - a.length);
|
const findMarker = (source: string, offset: number): string => {
|
||||||
for (const secret of secrets) {
|
for (let index = offset; index < 0x1900; index += 1) {
|
||||||
output = output.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
|
const marker = String.fromCharCode(0xe000 + index);
|
||||||
}
|
if (!source.includes(marker) && [...sensitiveValues].every((secret) => !secret.includes(marker))) {
|
||||||
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url));
|
return marker;
|
||||||
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>\"");
|
}
|
||||||
|
}
|
||||||
|
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(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
|
||||||
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
|
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
|
||||||
output = output.replace(/\b(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_-]{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-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>");
|
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 {
|
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 {
|
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
|
||||||
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
|
||||||
if (!running) delay = Math.min(delay, 200);
|
if (!running) delay = Math.min(delay, 200);
|
||||||
if (activeTab !== "downloads") delay = Math.max(delay, 800);
|
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
|
||||||
return delay;
|
return delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,10 +1292,9 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
|
|||||||
if (!running || paused) {
|
if (!running || paused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
drawChart();
|
drawChart();
|
||||||
}, reducedMotion ? 1000 : 500);
|
}, 500);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
@@ -4726,8 +4725,21 @@ export function App(): ReactElement {
|
|||||||
},
|
},
|
||||||
onStartDownloads: () => {
|
onStartDownloads: () => {
|
||||||
if (snapshot.session.paused) {
|
if (snapshot.session.paused) {
|
||||||
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } }));
|
void (async () => {
|
||||||
void window.rd.togglePause().catch(() => {});
|
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 {
|
} else {
|
||||||
void onStartDownloads();
|
void onStartDownloads();
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -360,10 +360,13 @@ export interface DownloadItem {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
resumable: boolean;
|
resumable: boolean;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
fullStatus: string;
|
fullStatus: string;
|
||||||
createdAt: number;
|
resumeLinkRenewalFailures?: number;
|
||||||
|
resumeHardResetUsed?: boolean;
|
||||||
|
resumeResetPending?: boolean;
|
||||||
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
onlineStatus?: "online" | "offline" | "checking";
|
onlineStatus?: "online" | "offline" | "checking";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,26 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
|||||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
it("feeds the global UI ring with TEST and outcome events", () => {
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||||
const ring = getRecentRotationEvents(10);
|
const ring = getRecentRotationEvents(10);
|
||||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks TIMEOUT_COOLDOWN as a failed attempt in the global UI ring without changing its event type", () => {
|
||||||
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 10 (xy)", "TIMEOUT_COOLDOWN", {
|
||||||
|
reason: "aborted:debrid",
|
||||||
|
cooldownSec: 30,
|
||||||
|
next: "Account 11 (yz)"
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = getRecentRotationEvents(10).find((entry) => entry.accountLabel === "Account 10 (xy)");
|
||||||
|
|
||||||
|
expect(event).toMatchObject({
|
||||||
|
event: "TIMEOUT_COOLDOWN",
|
||||||
|
reason: "Versuch fehlgeschlagen: aborted:debrid"
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("desktop shell", () => {
|
|||||||
|
|
||||||
it("renders active download telemetry at a stable half-second cadence", () => {
|
it("renders active download telemetry at a stable half-second cadence", () => {
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
|
||||||
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800);
|
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps support bundle progress tied to the unresolved export", async () => {
|
it("keeps support bundle progress tied to the unresolved export", async () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createStoragePaths, emptySession, loadSession, saveSession } from "../s
|
|||||||
import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
|
||||||
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
|
||||||
|
import { logAccountRotation } from "../src/main/account-rotation-log";
|
||||||
import { UnrestrictedLink } from "../src/main/realdebrid";
|
import { UnrestrictedLink } from "../src/main/realdebrid";
|
||||||
import { resetVideoToolingCache } from "../src/main/video-processor";
|
import { resetVideoToolingCache } from "../src/main/video-processor";
|
||||||
import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types";
|
import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types";
|
||||||
@@ -813,6 +814,61 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("download manager", () => {
|
describe("download manager", () => {
|
||||||
|
it("writes the real unrestrict account trail into the active item log", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rotation-item-log-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const stateDir = path.join(root, "state");
|
||||||
|
initItemLogs(stateDir);
|
||||||
|
const session = emptySession();
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: false,
|
||||||
|
retryLimit: 0
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(stateDir)
|
||||||
|
);
|
||||||
|
manager.addPackages([{ name: "rotation-proof", links: ["https://rapidgator.net/file/rotation-proof"] }]);
|
||||||
|
const packageId = session.packageOrder[0];
|
||||||
|
const itemId = session.packages[packageId].itemIds[0];
|
||||||
|
const active = {
|
||||||
|
itemId,
|
||||||
|
packageId,
|
||||||
|
abortController: new AbortController(),
|
||||||
|
abortReason: "none",
|
||||||
|
resumable: true,
|
||||||
|
nonResumableCounted: false,
|
||||||
|
blockedOnDiskWrite: false,
|
||||||
|
blockedOnDiskSince: 0
|
||||||
|
};
|
||||||
|
(manager as any).activeTasks.set(itemId, active);
|
||||||
|
(manager as any).debridService.unrestrictLink = async () => {
|
||||||
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (al***ha)", "TEST");
|
||||||
|
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (al***ha)", "FAILED", { reason: "aborted" });
|
||||||
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "TEST");
|
||||||
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (be***ta)", "OK");
|
||||||
|
throw new Error("rotation-proof-stop");
|
||||||
|
};
|
||||||
|
|
||||||
|
await (manager as any).processItem(active);
|
||||||
|
const itemLogPath = getItemLogPath(itemId);
|
||||||
|
expect(itemLogPath).not.toBeNull();
|
||||||
|
shutdownItemLogs();
|
||||||
|
const content = fs.readFileSync(itemLogPath!, "utf8");
|
||||||
|
const firstTest = content.indexOf("Account 1/3 (al***ha)");
|
||||||
|
const firstFailure = content.indexOf("event=FAILED");
|
||||||
|
const secondTest = content.indexOf("Account 2/3 (be***ta)");
|
||||||
|
const secondSuccess = content.indexOf("event=OK");
|
||||||
|
expect(firstTest).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(firstFailure).toBeGreaterThan(firstTest);
|
||||||
|
expect(secondTest).toBeGreaterThan(firstFailure);
|
||||||
|
expect(secondSuccess).toBeGreaterThan(secondTest);
|
||||||
|
});
|
||||||
|
|
||||||
it("stores RapidGator metadata before a download starts", () => {
|
it("stores RapidGator metadata before a download starts", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@@ -2772,7 +2828,7 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores a persisted Mega-Web partial and hard-resets after two HTTP 200 range rejections", async () => {
|
it("restores a persisted Mega-Web resume failure and retries transient cleanup before the fresh restart", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const binary = Buffer.alloc(192 * 1024, 37);
|
const binary = Buffer.alloc(192 * 1024, 37);
|
||||||
@@ -2784,6 +2840,17 @@ describe("download manager", () => {
|
|||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
fs.writeFileSync(targetPath, binary.subarray(0, partialSize));
|
fs.writeFileSync(targetPath, binary.subarray(0, partialSize));
|
||||||
const rangeHeaders: string[] = [];
|
const rangeHeaders: string[] = [];
|
||||||
|
const originalRmSync = fs.rmSync;
|
||||||
|
let targetRemovalAttempts = 0;
|
||||||
|
const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((candidate, options) => {
|
||||||
|
if (path.resolve(String(candidate)) === path.resolve(targetPath)) {
|
||||||
|
targetRemovalAttempts += 1;
|
||||||
|
if (targetRemovalAttempts === 1) {
|
||||||
|
throw Object.assign(new Error("target busy"), { code: "EBUSY" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalRmSync(candidate, options);
|
||||||
|
}) as typeof fs.rmSync);
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
@@ -2887,14 +2954,15 @@ describe("download manager", () => {
|
|||||||
downloadedBytes: binary.length,
|
downloadedBytes: binary.length,
|
||||||
onlineStatus: "online"
|
onlineStatus: "online"
|
||||||
}));
|
}));
|
||||||
expect(megaWebUnrestrict).toHaveBeenCalledTimes(3);
|
expect(megaWebUnrestrict).toHaveBeenCalledTimes(2);
|
||||||
expect(rangeHeaders).toEqual([
|
expect(rangeHeaders).toEqual([
|
||||||
`bytes=${partialSize}-`,
|
|
||||||
`bytes=${partialSize}-`,
|
`bytes=${partialSize}-`,
|
||||||
""
|
""
|
||||||
]);
|
]);
|
||||||
|
expect(targetRemovalAttempts).toBeGreaterThanOrEqual(2);
|
||||||
expect(fs.readFileSync(targetPath)).toEqual(binary);
|
expect(fs.readFileSync(targetPath)).toEqual(binary);
|
||||||
} finally {
|
} finally {
|
||||||
|
rmSpy.mockRestore();
|
||||||
server.close();
|
server.close();
|
||||||
await once(server, "close");
|
await once(server, "close");
|
||||||
}
|
}
|
||||||
@@ -13639,7 +13707,7 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => {
|
it("bulk-adds large DLC containers without initializing per-item logs (avoids 1-2 min sync-FS freeze)", () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bulk-add-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bulk-add-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const stateDir = path.join(root, "state");
|
const stateDir = path.join(root, "state");
|
||||||
@@ -13681,10 +13749,42 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const packageLogsDir = path.join(stateDir, "package-logs");
|
const packageLogsDir = path.join(stateDir, "package-logs");
|
||||||
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
||||||
expect(pkgLogFiles.length).toBe(60);
|
expect(pkgLogFiles.length).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => {
|
it("emits running large-queue state updates within 500 ms", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-state-cadence-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
defaultSettings(),
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
const internal = manager as unknown as {
|
||||||
|
session: { running: boolean };
|
||||||
|
itemCount: number;
|
||||||
|
emitState: () => void;
|
||||||
|
};
|
||||||
|
internal.session.running = true;
|
||||||
|
internal.itemCount = 1_500;
|
||||||
|
let emitted = 0;
|
||||||
|
manager.on("state", () => {
|
||||||
|
emitted += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
internal.emitState();
|
||||||
|
await vi.advanceTimersByTimeAsync(499);
|
||||||
|
expect(emitted).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(emitted).toBe(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes parallel auto-rename invocations for the same package (no Ziel existiert / ENOENT race)", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-race-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const stateDir = path.join(root, "state");
|
const stateDir = path.join(root, "state");
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultSettings } from "../src/main/constants";
|
||||||
|
import { createRendererState } from "../src/main/renderer-state";
|
||||||
|
import { emptySession } from "../src/main/storage";
|
||||||
|
import type { UiSnapshot } from "../src/shared/types";
|
||||||
|
|
||||||
|
const hookState = vi.hoisted(() => ({
|
||||||
|
capturedSnapshot: false,
|
||||||
|
currentSnapshot: null as unknown,
|
||||||
|
initialSnapshot: null as unknown
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react")>("react");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
memo: <T,>(component: T): T => component,
|
||||||
|
useCallback: <T,>(callback: T): T => callback,
|
||||||
|
useDeferredValue: <T,>(value: T): T => value,
|
||||||
|
useEffect: (): void => {},
|
||||||
|
useLayoutEffect: (): void => {},
|
||||||
|
useMemo: <T,>(factory: () => T): T => factory(),
|
||||||
|
useRef: <T,>(initial: T): { current: T } => ({ current: initial }),
|
||||||
|
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((current: T) => T)) => void] => {
|
||||||
|
const initialValue = typeof initial === "function" ? (initial as () => T)() : initial;
|
||||||
|
const candidate = initialValue as Record<string, unknown> | null;
|
||||||
|
if (!hookState.capturedSnapshot && candidate && "session" in candidate && "canStart" in candidate) {
|
||||||
|
hookState.capturedSnapshot = true;
|
||||||
|
const setSnapshot = (next: T | ((current: T) => T)): void => {
|
||||||
|
const current = hookState.currentSnapshot as T;
|
||||||
|
hookState.currentSnapshot = typeof next === "function"
|
||||||
|
? (next as (value: T) => T)(current)
|
||||||
|
: next;
|
||||||
|
};
|
||||||
|
return [hookState.initialSnapshot as T, setSnapshot];
|
||||||
|
}
|
||||||
|
let current = initialValue;
|
||||||
|
return [current, (next): void => {
|
||||||
|
current = typeof next === "function" ? (next as (value: T) => T)(current) : next;
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { App } from "../src/renderer/App";
|
||||||
|
|
||||||
|
function createSnapshot(running: boolean, paused: boolean): UiSnapshot {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
...createRendererState(defaultSettings()),
|
||||||
|
session: {
|
||||||
|
...emptySession(),
|
||||||
|
running,
|
||||||
|
paused,
|
||||||
|
updatedAt: now
|
||||||
|
},
|
||||||
|
summary: null,
|
||||||
|
stats: {
|
||||||
|
totalDownloaded: 0,
|
||||||
|
totalDownloadedAllTime: 0,
|
||||||
|
totalFilesSession: 0,
|
||||||
|
totalFilesAllTime: 0,
|
||||||
|
totalPackages: 0,
|
||||||
|
sessionStartedAt: now,
|
||||||
|
appSessionStartedAt: now,
|
||||||
|
sessionRuntimeMs: 0,
|
||||||
|
totalRuntimeMs: 0,
|
||||||
|
runtimeMeasuredAt: now
|
||||||
|
},
|
||||||
|
speedText: "Geschwindigkeit: 0 B/s",
|
||||||
|
etaText: "ETA: --",
|
||||||
|
canStart: !running || paused,
|
||||||
|
canStop: running,
|
||||||
|
canPause: running,
|
||||||
|
clipboardActive: false,
|
||||||
|
reconnectSeconds: 0,
|
||||||
|
packageSpeedBps: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStartAction(node: ReactNode): (() => void) | null {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const child of node) {
|
||||||
|
const action = findStartAction(child);
|
||||||
|
if (action) return action;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isValidElement(node)) return null;
|
||||||
|
const props = node.props as {
|
||||||
|
actions?: { onStartDownloads?: () => void };
|
||||||
|
children?: ReactNode;
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
};
|
||||||
|
if (typeof props.actions?.onStartDownloads === "function") {
|
||||||
|
return props.actions.onStartDownloads;
|
||||||
|
}
|
||||||
|
return findStartAction(props.toolbar) ?? findStartAction(props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncAction(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPausedStartAction(
|
||||||
|
initialSnapshot: UiSnapshot,
|
||||||
|
togglePause: () => Promise<boolean>,
|
||||||
|
getSnapshot: () => Promise<UiSnapshot>
|
||||||
|
): () => void {
|
||||||
|
hookState.capturedSnapshot = false;
|
||||||
|
hookState.initialSnapshot = initialSnapshot;
|
||||||
|
hookState.currentSnapshot = initialSnapshot;
|
||||||
|
vi.stubGlobal("HTMLElement", class {});
|
||||||
|
vi.stubGlobal("document", {
|
||||||
|
activeElement: null,
|
||||||
|
body: {},
|
||||||
|
documentElement: {},
|
||||||
|
querySelector: () => null
|
||||||
|
});
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
addEventListener: () => {},
|
||||||
|
clearInterval,
|
||||||
|
clearTimeout,
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
matchMedia: () => ({ matches: false }),
|
||||||
|
prompt: () => null,
|
||||||
|
rd: { getSnapshot, togglePause },
|
||||||
|
removeEventListener: () => {},
|
||||||
|
setInterval,
|
||||||
|
setTimeout
|
||||||
|
});
|
||||||
|
const action = findStartAction(App() as ReactElement);
|
||||||
|
if (!action) throw new Error("Download-Startaktion nicht gefunden");
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("paused download resume reconciliation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hookState.capturedSnapshot = false;
|
||||||
|
hookState.currentSnapshot = null;
|
||||||
|
hookState.initialSnapshot = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the authoritative paused state when togglePause rejects without a state event", async () => {
|
||||||
|
const initial = createSnapshot(true, true);
|
||||||
|
const authoritative = createSnapshot(true, true);
|
||||||
|
const action = renderPausedStartAction(
|
||||||
|
initial,
|
||||||
|
async () => { throw new Error("Kein aktiver Download-Account verfügbar"); },
|
||||||
|
async () => authoritative
|
||||||
|
);
|
||||||
|
|
||||||
|
action();
|
||||||
|
await flushAsyncAction();
|
||||||
|
|
||||||
|
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces stale running state when togglePause returns false without a state event", async () => {
|
||||||
|
const initial = createSnapshot(true, true);
|
||||||
|
const authoritative = createSnapshot(false, false);
|
||||||
|
const action = renderPausedStartAction(
|
||||||
|
initial,
|
||||||
|
async () => false,
|
||||||
|
async () => authoritative
|
||||||
|
);
|
||||||
|
|
||||||
|
action();
|
||||||
|
await flushAsyncAction();
|
||||||
|
|
||||||
|
expect(hookState.currentSnapshot).toEqual(authoritative);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -475,14 +475,14 @@ describe("bandwidth chart palette", () => {
|
|||||||
expect(palette).toEqual({ accent: "rgb(74, 222, 128)" });
|
expect(palette).toEqual({ accent: "rgb(74, 222, 128)" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels the live chart and slows redraws when reduced motion is requested", () => {
|
it("labels the live chart and keeps live redraws at 500 ms", () => {
|
||||||
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
||||||
|
|
||||||
expect(chartBlock).toContain('role="img"');
|
expect(chartBlock).toContain('role="img"');
|
||||||
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
|
||||||
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")');
|
expect(chartBlock).toContain("}, 500);");
|
||||||
expect(chartBlock).toContain("reducedMotion ? 1000 : 500");
|
expect(chartBlock).not.toContain("reducedMotion ? 1000 : 500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks for confirmation before deleting all saved download statistics", () => {
|
it("asks for confirmation before deleting all saved download statistics", () => {
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
} from "../src/main/support-bundle";
|
} from "../src/main/support-bundle";
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
import type { DownloadManager } from "../src/main/download-manager";
|
||||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||||
|
import { initAccountRotationLog, logAccountRotation, shutdownAccountRotationLog } from "../src/main/account-rotation-log";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
shutdownAccountRotationLog();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||||
}
|
}
|
||||||
@@ -241,10 +243,17 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
|
it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
const escapedSecret = "prefix\"suffix\\trail\tend";
|
||||||
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({
|
||||||
megaDebridWebCredentials: "primary-user:primary-password-secret\nsecondary-user:secondary-password-secret",
|
megaDebridWebCredentials: `primary-user:primary-password-secret\nsecondary-user:secondary-password-secret\nZ9:Q7!\nAlice:${escapedSecret}`,
|
||||||
|
debridLinkApiKeys: "abc123456789xyz,def987654321uvw",
|
||||||
megaDebridWebEnabled: true
|
megaDebridWebEnabled: true
|
||||||
}), "utf8");
|
}), "utf8");
|
||||||
|
initAccountRotationLog(root);
|
||||||
|
logAccountRotation("INFO", "Debrid-Link", "Key 1 (abc*********xyz)", "TEST");
|
||||||
|
logAccountRotation("WARN", "Debrid-Link", "Key 2 (def*********uvw)", "FAILED", { reason: "fixture" });
|
||||||
|
logAccountRotation("WARN", "Mega-Debrid", "Account 9/9 (Re*******er)", "FAILED", { next: "Account 8/9 (Al*******ce)" });
|
||||||
|
logAccountRotation("WARN", "Debrid-Link", "Key 9/9 (old***********ken)", "FAILED", { next: "Key 8/9 (ret***********key)" });
|
||||||
const itemLogs = path.join(root, "item-logs");
|
const itemLogs = path.join(root, "item-logs");
|
||||||
fs.mkdirSync(itemLogs, { recursive: true });
|
fs.mkdirSync(itemLogs, { recursive: true });
|
||||||
fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [
|
fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [
|
||||||
@@ -254,8 +263,16 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
"password=log-password-secret",
|
"password=log-password-secret",
|
||||||
"api_key=log-api-key-secret",
|
"api_key=log-api-key-secret",
|
||||||
"provider rejected secondary-password-secret during rotation",
|
"provider rejected secondary-password-secret during rotation",
|
||||||
|
"rotation rejected account Z9 before fallback Q7!",
|
||||||
|
"rotation tried Mega account Z*",
|
||||||
"https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret",
|
"https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret",
|
||||||
"C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin"
|
"https://private-support-host.invalid/archive?id=private-resource",
|
||||||
|
"https://private.example/Alice/after-alice-url-segment?token=x",
|
||||||
|
"C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin",
|
||||||
|
"C:/Users/Alice/Downloads/Private Collection/forward-private.bin",
|
||||||
|
"C:/Users/Alice/after-alice-path-segment/private.bin",
|
||||||
|
"file:///C:/Users/Alice/Downloads/Private%20Collection/file-private.bin",
|
||||||
|
JSON.stringify({ password: escapedSecret })
|
||||||
].join("\n"), "utf8");
|
].join("\n"), "utf8");
|
||||||
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({
|
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({
|
||||||
authorization: "Bearer manifest-bearer-secret",
|
authorization: "Bearer manifest-bearer-secret",
|
||||||
@@ -292,8 +309,24 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
|||||||
"log-api-key-secret",
|
"log-api-key-secret",
|
||||||
"primary-password-secret",
|
"primary-password-secret",
|
||||||
"secondary-password-secret",
|
"secondary-password-secret",
|
||||||
|
"Z9",
|
||||||
|
"Q7!",
|
||||||
|
"Z*",
|
||||||
|
"abc*********xyz",
|
||||||
|
"def*********uvw",
|
||||||
|
"Re*******er",
|
||||||
|
"Al*******ce",
|
||||||
|
"old***********ken",
|
||||||
|
"ret***********key",
|
||||||
|
escapedSecret,
|
||||||
|
JSON.stringify(escapedSecret).slice(1, -1),
|
||||||
"log-query-secret",
|
"log-query-secret",
|
||||||
"log-fragment-secret",
|
"log-fragment-secret",
|
||||||
|
"private-support-host.invalid",
|
||||||
|
"after-alice-url-segment",
|
||||||
|
"after-alice-path-segment",
|
||||||
|
"forward-private.bin",
|
||||||
|
"file-private.bin",
|
||||||
"manifest-bearer-secret",
|
"manifest-bearer-secret",
|
||||||
"manifest-query-secret",
|
"manifest-query-secret",
|
||||||
"manifest-fragment"
|
"manifest-fragment"
|
||||||
|
|||||||
Reference in New Issue
Block a user