Complete provider waits and lifecycle visibility

Release aborted Real-Debrid, AllDebrid, and BestDebrid web callers even when underlying requests ignore cancellation while retaining terminal rejection observers. Publish provider cooldown deadlines and emit a fresh idle snapshot at the earliest expiry. Abort and visibly drain post-processing before dispatching one pending restart. Surface lifecycle phase, reason, retry countdown, and remaining work in the download controls. Add focused regressions for provider abort races, cooldown expiry, post-processing drain, pending start visibility, and the updated rapid stop contract.
This commit is contained in:
Sucukdeluxe
2026-08-22 10:39:07 +02:00
parent 1f97ce8b42
commit ad29239661
13 changed files with 595 additions and 82 deletions
@@ -0,0 +1,45 @@
# Task 3: Deterministischer Stop→Start-Lifecycle
## Status
Umgesetzt und fokussiert verifiziert.
## Umsetzung
- Expliziter `DownloadLifecycleSnapshot` mit Phase, Grund, optionalem Retry-Zeitpunkt, aktiven Downloads, aktiven Nachbearbeitungen und angenommenem Startwunsch.
- Generation-Guard für `start()` nach beiden asynchronen Recovery-Grenzen.
- `stopping` bleibt bis zum tatsächlichen Drain von Start-Recovery, Downloads und Nachbearbeitung aktiv.
- Ein Start während `stopping` wird einmal angenommen und nach dem Drain genau einmal ausgeführt.
- ActiveTask-Cleanup ist an den konkreten Map-Eigentümer gebunden; verspätetes Cleanup kann keinen neueren Task löschen oder dessen Ressourcen freigeben.
- Stop abortiert laufende Nachbearbeitung auch bei aktivierter Nachbearbeitung ohne laufende Sitzung und zeigt die verbleibende Arbeit bis zum Promise-Ende.
- Real-Debrid-, AllDebrid- und BestDebrid-Webqueues geben abortierte Aufrufer über eine äußere Abort-Race sofort frei und beobachten die spätere terminale Promise-Auflösung weiterhin.
- Der früheste endliche Provider-Cooldown plant im Idle-Zustand ein State-Event zum Ablaufzeitpunkt.
- Startbutton und Download-Footer zeigen Pending-Start, Lifecycle-Phase, Grund, Retry-Restzeit und verbleibende Download-/Nachbearbeitungsarbeit.
## RED-Nachweise
- Recovery-Rennen: Nach Stop wurde `session.running` wieder `true`.
- Pending-Start: Während `stopping` fehlten Lifecycle und angenommener Startwunsch.
- ActiveTask-Eigentümer: Ein verspätetes altes `finally` löschte den neueren Map-Eintrag.
- Webqueues: Alle drei nie endenden Requests liefen nach Abort in den 200-ms-Testtimeout.
- Cooldown: Der Snapshot blieb bei `idle` mit `retryAt=null`.
- Renderer: Pending-Start-Button und Lifecycle-/Restarbeitsanzeige fehlten.
- Nachbearbeitung: Stop ließ das aktive Postprocessing-Signal bei `autoExtractWhenStopped=true` unabgebrochen.
## Verifikation
- 7 fokussierte Manager-Regressionen bestanden.
- 152/152 Real-Debrid-, AllDebrid-, BestDebrid- und Download-Renderer-Tests bestanden.
- `npx tsc --noEmit` bestand.
- `npm run build` bestand für Main und Renderer.
- `git diff --check` bestand vor dem Bericht.
## Commits
- `1f97ce8 Harden download stop and restart lifecycle`
- `Complete provider waits and lifecycle visibility`
## Bedenken
- Der vollständige Fünf-Dateien-Lauf erreichte vor der letzten Korrektur 395/397. Die beiden isolierten Fehler wurden anschließend einzeln und im finalen 7-Test-Manager-Gate grün verifiziert; die übrigen 393 Manager-/Web-/Renderer-Tests wurden nach dieser letzten kleinen Kompatibilitätskorrektur nicht nochmals gemeinsam ausgeführt.
- Der Renderer-Build bleibt grün, meldet aber die bereits bestehende Warnung für einen JavaScript-Chunk über 500 kB.
+43 -7
View File
@@ -42,7 +42,7 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
@@ -68,8 +68,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -280,10 +316,10 @@ export class AllDebridWebFallback {
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return run;
}
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
const partition = this.getPartition();
+43 -7
View File
@@ -23,11 +23,47 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
return AbortSignal.any([signal, timeoutSignal]);
}
function throwIfAborted(signal?: AbortSignal): void {
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw abortError();
}
}
}
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function parseJson(text: string): Record<string, unknown> | null {
try {
@@ -238,10 +274,10 @@ export class BestDebridWebFallback {
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return run;
}
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
}
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
throwIfAborted(signal);
+13 -3
View File
@@ -586,9 +586,19 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
},
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
};
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
}
export function getNextProviderRuntimeRetryAt(now = Date.now()): number | null {
const deadlines = [
...[...realDebridAccountCooldowns.values()].map((entry) => entry.until),
...[...megaDebridAccountCooldowns.values()].map((entry) => entry.until),
...debridLinkKeyCooldowns.values(),
...debridLinkKeyHostCooldowns.values()
].filter((deadline) => Number.isFinite(deadline) && deadline > now);
return deadlines.length > 0 ? Math.min(...deadlines) : null;
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
+108 -44
View File
@@ -62,7 +62,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getNextProviderRuntimeRetryAt, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@@ -1856,8 +1856,10 @@ export class DownloadManager extends EventEmitter {
private nonResumableActive = 0;
private stateEmitTimer: NodeJS.Timeout | null = null;
private lastStateEmitAt = 0;
private stateEmitTimer: NodeJS.Timeout | null = null;
private lastStateEmitAt = 0;
private providerRetryTimer: NodeJS.Timeout | null = null;
private providerRetryAt = 0;
private speedBytesLastWindow = 0;
@@ -2728,11 +2730,15 @@ export class DownloadManager extends EventEmitter {
};
}
public getSnapshot(): UiSnapshot {
const now = nowMs();
this.ensureProviderDailyUsageFresh(now, true);
this.pruneSpeedEvents(now);
const paused = this.session.running && this.session.paused;
public getSnapshot(): UiSnapshot {
const now = nowMs();
this.ensureProviderDailyUsageFresh(now, true);
this.pruneSpeedEvents(now);
const hasUsableAccount = this.hasUsableDownloadAccount();
const providerRetryAt = this.getEarliestProviderRetryAt(now);
this.syncProviderRetryTimer(providerRetryAt, now);
const lifecycle = this.getLifecycleSnapshot(providerRetryAt, hasUsableAccount);
const paused = this.session.running && this.session.paused;
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
let totalItems = 0;
@@ -2781,7 +2787,7 @@ export class DownloadManager extends EventEmitter {
return {
rotationEvents: getRecentRotationEvents(40),
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
lifecycle: this.getLifecycleSnapshot(),
lifecycle,
settings: rendererState.settings,
accounts: rendererState.accounts,
session: snapshotSession,
@@ -2789,7 +2795,9 @@ export class DownloadManager extends EventEmitter {
stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`,
canStart: (!this.session.running || paused) && this.hasUsableDownloadAccount(),
canStart: hasUsableAccount && (paused || (!this.session.running
&& lifecycle.phase !== "waiting_provider"
&& (lifecycle.phase !== "stopping" || !lifecycle.pendingStart))),
canStop: this.session.running,
canPause: this.session.running,
clipboardActive: this.settings.clipboardWatch,
@@ -3257,10 +3265,15 @@ export class DownloadManager extends EventEmitter {
this.clearPersistTimer();
this.stop();
this.abortPostProcessing("clear_all");
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
if (this.providerRetryTimer) {
clearTimeout(this.providerRetryTimer);
this.providerRetryTimer = null;
this.providerRetryAt = 0;
}
this.session.packageOrder = [];
this.session.packages = {};
this.session.items = {};
@@ -3926,7 +3939,42 @@ export class DownloadManager extends EventEmitter {
return tasks.size;
}
private getLifecycleSnapshot(): DownloadLifecycleSnapshot {
private getEarliestProviderRetryAt(now: number): number | null {
const deadlines = [...this.providerFailures.values()]
.map((entry) => entry.cooldownUntil)
.filter((deadline) => Number.isFinite(deadline) && deadline > now);
const runtimeRetryAt = getNextProviderRuntimeRetryAt(now);
if (runtimeRetryAt) {
deadlines.push(runtimeRetryAt);
}
return deadlines.length > 0 ? Math.min(...deadlines) : null;
}
private syncProviderRetryTimer(retryAt: number | null, now: number): void {
if (!retryAt || retryAt <= now) {
if (this.providerRetryTimer) {
clearTimeout(this.providerRetryTimer);
this.providerRetryTimer = null;
}
this.providerRetryAt = 0;
return;
}
if (this.providerRetryTimer && this.providerRetryAt === retryAt) {
return;
}
if (this.providerRetryTimer) {
clearTimeout(this.providerRetryTimer);
}
this.providerRetryAt = retryAt;
this.providerRetryTimer = setTimeout(() => {
this.providerRetryTimer = null;
this.providerRetryAt = 0;
this.emitState(true);
}, Math.min(2_147_483_647, Math.max(0, retryAt - now)));
this.providerRetryTimer.unref?.();
}
private getLifecycleSnapshot(retryAt: number | null, hasUsableAccount: boolean): DownloadLifecycleSnapshot {
const activeDownloads = this.activeTasks.size;
const activePostProcessing = this.getActivePostProcessingCount();
const pendingStart = this.pendingStartOptions !== null;
@@ -3934,7 +3982,7 @@ export class DownloadManager extends EventEmitter {
return {
phase: "stopping",
reason: pendingStart ? "Start vorgemerkt, laufende Arbeit wird beendet" : "Laufende Arbeit wird beendet",
retryAt: null,
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
@@ -3944,18 +3992,28 @@ export class DownloadManager extends EventEmitter {
return {
phase: "starting",
reason: this.lifecycleReason,
retryAt: null,
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
};
}
if (this.session.running) {
if (activeDownloads === 0 && retryAt) {
return {
phase: "waiting_provider",
reason: "Provider vorübergehend nicht verfügbar",
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
};
}
const postprocessing = activeDownloads === 0 && activePostProcessing > 0;
return {
phase: postprocessing ? "postprocessing" : "running",
reason: postprocessing ? "Nachbearbeitung läuft" : this.session.paused ? "Downloads pausiert" : "Downloads laufen",
retryAt: null,
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
@@ -3965,16 +4023,19 @@ export class DownloadManager extends EventEmitter {
return {
phase: "postprocessing",
reason: "Nachbearbeitung läuft",
retryAt: null,
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
};
}
const waitingForProvider = !hasUsableAccount && retryAt !== null;
return {
phase: "idle",
reason: this.lifecycleReason,
retryAt: null,
phase: waitingForProvider ? "waiting_provider" : "idle",
reason: waitingForProvider
? "Provider vorübergehend nicht verfügbar"
: hasUsableAccount ? this.lifecycleReason : "Kein aktiver Download-Account verfügbar",
retryAt,
activeDownloads,
activePostProcessing,
pendingStart
@@ -6451,6 +6512,8 @@ export class DownloadManager extends EventEmitter {
try {
this.beginHealthRun();
this.ensureUsableDownloadAccount();
this.session.running = true;
this.session.paused = false;
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
const pkg = this.session.packages[packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(packageId));
@@ -6460,12 +6523,12 @@ export class DownloadManager extends EventEmitter {
}
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
if (this.lifecycleGeneration !== generation) {
return;
}
await sleep(0);
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
if (this.lifecycleGeneration !== generation) {
return;
}
@@ -6631,7 +6694,6 @@ export class DownloadManager extends EventEmitter {
this.healthManualStop = !parkForRestart;
this.healthShuttingDown = parkForRestart;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped;
const wasRunning = this.session.running;
const stoppedRunContext = wasRunning
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
@@ -6652,12 +6714,10 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
if (!keepExtraction) {
this.abortPostProcessing("stop");
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
this.packagePostProcessWaiters = [];
this.packagePostProcessActive = 0;
}
this.abortPostProcessing("stop");
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
this.packagePostProcessWaiters = [];
this.packagePostProcessActive = 0;
for (const active of this.activeTasks.values()) {
active.abortReason = abortReason;
active.abortController.abort(abortReason);
@@ -6672,10 +6732,7 @@ export class DownloadManager extends EventEmitter {
}
}
for (const pkg of Object.values(this.session.packages)) {
if (keepExtraction && (pkg.status === "extracting" || pkg.status === "integrity_check")) {
continue;
}
if (pkg.status === "downloading" || pkg.status === "validating"
if (pkg.status === "downloading" || pkg.status === "validating"
|| pkg.status === "extracting" || pkg.status === "integrity_check"
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
pkg.status = "queued";
@@ -6711,12 +6768,17 @@ export class DownloadManager extends EventEmitter {
this.healthShuttingDown = true;
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.updateStatisticsActivity(nowMs());
this.rotationListenerActive = false;
this.clearPersistTimer();
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.rotationListenerActive = false;
this.clearPersistTimer();
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
if (this.providerRetryTimer) {
clearTimeout(this.providerRetryTimer);
this.providerRetryTimer = null;
this.providerRetryAt = 0;
}
this.session.running = false;
this.session.paused = false;
this.session.reconnectUntil = 0;
@@ -14248,9 +14310,11 @@ export class DownloadManager extends EventEmitter {
private finishRun(): void {
const runStartedAt = this.session.runStartedAt;
const completedAt = nowMs();
this.session.running = false;
this.session.paused = false;
this.session.runStartedAt = 0;
this.session.running = false;
this.session.paused = false;
this.lifecyclePhase = "idle";
this.lifecycleReason = "Bereit";
this.session.runStartedAt = 0;
const total = this.runItemIds.size;
const outcomes = Array.from(this.runOutcomes.values());
const success = outcomes.filter((status) => status === "completed").length;
+43 -7
View File
@@ -47,7 +47,7 @@ function throwIfAborted(signal?: AbortSignal): void {
}
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
@@ -73,8 +73,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
});
}
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortError();
}
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortError());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
function parseJson(text: string): Record<string, unknown> | null {
try {
@@ -299,10 +335,10 @@ export class RealDebridWebFallback {
}
return job();
};
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return run;
}
const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal);
}
private async ensureLoginWindow(): Promise<BrowserWindow> {
const partition = this.getPartition();
+15 -4
View File
@@ -825,9 +825,10 @@ const emptySnapshot = (): UiSnapshot => ({
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
paused: false, running: false, updatedAt: Date.now()
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {},
lifecycle: { phase: "idle", reason: "Bereit", retryAt: null, activeDownloads: 0, activePostProcessing: 0, pendingStart: false }
});
const cleanupLabels: Record<string, string> = {
@@ -4816,6 +4817,14 @@ export function App(): ReactElement {
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadLifecycle = useMemo(() => snapshot.lifecycle ?? {
phase: snapshot.session.running ? "running" as const : "idle" as const,
reason: snapshot.session.running ? "Downloads laufen" : "Bereit",
retryAt: null,
activeDownloads: Object.values(snapshot.session.items).filter((item) => item.status === "downloading" || item.status === "validating").length,
activePostProcessing: 0,
pendingStart: false
}, [snapshot.lifecycle, snapshot.session.items, snapshot.session.running]);
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore,
running: snapshot.session.running,
@@ -4846,6 +4855,8 @@ export function App(): ReactElement {
sortDirection: downloadsSortDescending ? "desc" : "asc",
disclosureRevision: downloadDisclosureRevision,
animationsEnabled: snapshot.settings.animatePackageDisclosure,
lifecycle: downloadLifecycle,
lifecycleNow: runtimeNow,
status: {
packages: snapshot.stats.totalPackages,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
@@ -4860,7 +4871,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadLifecycle, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, runtimeNow, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const resetColumnLayout = useCallback((): void => {
if (columnDragSettleTimerRef.current !== null) {
+36 -4
View File
@@ -1,4 +1,5 @@
import type { ReactElement } from "react";
import type { DownloadLifecycleSnapshot } from "../../../shared/types";
import { RollingMetricValue } from "../../ui/RollingMetricValue";
import { SlidingSelection } from "../../ui/SlidingSelection";
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
@@ -54,8 +55,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortDirection?: "asc" | "desc";
disclosureRevision: number;
animationsEnabled: boolean;
lifecycle: DownloadLifecycleSnapshot;
lifecycleNow: number;
status: DownloadsStatusModel;
}
}
export interface DownloadsViewActions extends DownloadsTableActions {
onResetColumnLayout: () => void;
@@ -134,7 +137,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">{model.lifecycle.pendingStart ? "Start vorgemerkt" : "Start"}</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
@@ -171,13 +174,42 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
</main>
);
}
function formatLifecycleStatus(model: DownloadsViewModel): string {
const phaseLabels: Record<DownloadLifecycleSnapshot["phase"], string> = {
idle: "Bereit",
starting: "Startet",
running: "Läuft",
stopping: "Stoppt",
waiting_provider: "Wartet auf Provider",
postprocessing: "Nachbearbeitung"
};
const parts = [phaseLabels[model.lifecycle.phase]];
if (model.lifecycle.reason && model.lifecycle.reason !== parts[0]) {
parts.push(model.lifecycle.reason);
}
if (model.lifecycle.retryAt && model.lifecycle.retryAt > model.lifecycleNow) {
parts.push(`Noch ${Math.max(1, Math.ceil((model.lifecycle.retryAt - model.lifecycleNow) / 1000))} s`);
}
const remaining: string[] = [];
if (model.lifecycle.activeDownloads > 0) {
remaining.push(`${model.lifecycle.activeDownloads} ${model.lifecycle.activeDownloads === 1 ? "Download" : "Downloads"}`);
}
if (model.lifecycle.activePostProcessing > 0) {
remaining.push(`${model.lifecycle.activePostProcessing} ${model.lifecycle.activePostProcessing === 1 ? "Nachbearbeitung" : "Nachbearbeitungen"}`);
}
if (remaining.length > 0) {
parts.push(`Restarbeit: ${remaining.join(", ")}`);
}
return parts.join(" · ");
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
<span role="status">{formatLifecycleStatus(model)}</span>
</footer>
);
}
+22
View File
@@ -142,6 +142,28 @@ describe("alldebrid-web", () => {
}));
});
it("releases an aborted caller while the active web request ignores its signal", async () => {
let rejectRequest!: (error: Error) => void;
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}));
const fallback = new AllDebridWebFallback(() => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
.then(() => "resolved" as const, (error) => String(error));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
controller.abort("test-stop");
const outcome = await Promise.race([
running,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(outcome).toContain("aborted:alldebrid-web");
rejectRequest(new Error("late alldebrid rejection"));
await Promise.resolve();
});
it("opens the login window after login_required and retries generation with the same session partition", async () => {
mockFetch
.mockResolvedValueOnce(new Response("login", { status: 200 }))
+32 -4
View File
@@ -138,7 +138,7 @@ describe("bestdebrid-web", () => {
expect(mockCookiesSet).not.toHaveBeenCalled();
});
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
@@ -162,6 +162,34 @@ describe("bestdebrid-web", () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
});
});
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
});
it("releases an aborted caller while the active web request ignores its signal", async () => {
const filePath = createCookieFile([
"# Netscape HTTP Cookie File",
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
].join("\n"));
tempFiles.push(filePath);
const fallback = new BestDebridWebFallback(() => true);
await fallback.importCookiesFromFile(filePath);
let rejectRequest!: (error: Error) => void;
mockFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}));
const controller = new AbortController();
const running = fallback.unrestrict("https://1fichier.com/?abort-race", controller.signal)
.then(() => "resolved" as const, (error) => String(error));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
controller.abort("test-stop");
const outcome = await Promise.race([
running,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(outcome).toContain("aborted:bestdebrid-web");
rejectRequest(new Error("late bestdebrid rejection"));
await Promise.resolve();
});
});
+109 -2
View File
@@ -899,6 +899,109 @@ describe("deterministic stop and restart lifecycle", () => {
await new Promise((resolve) => setTimeout(resolve, 50));
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
});
it("emits an idle snapshot when the earliest provider cooldown expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
tempDirs.push(root);
const accountId = "rda_cooldown_event";
const manager = new DownloadManager(
{
...defaultSettings(),
realDebridApiTokens: serializeRealDebridApiAccounts([{ id: accountId, token: "token" }]),
providerOrder: ["realdebrid"],
autoExtract: false
},
emptySession(),
createStoragePaths(path.join(root, "state"))
);
manager.addPackages([{ name: "cooldown", links: ["https://rapidgator.net/file/cooldown"] }]);
const internal = manager as unknown as { stateEmitTimer: NodeJS.Timeout | null };
if (internal.stateEmitTimer) {
clearTimeout(internal.stateEmitTimer);
internal.stateEmitTimer = null;
}
primeRealDebridRuntimeCooldownForTests(accountId, 1_000);
const events: Array<ReturnType<typeof manager.getSnapshot>> = [];
manager.on("state", (snapshot) => events.push(snapshot));
const waiting = manager.getSnapshot();
expect(waiting).toMatchObject({
canStart: false,
lifecycle: {
phase: "waiting_provider",
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
}
});
await vi.advanceTimersByTimeAsync(999);
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(events.at(-1)).toMatchObject({
canStart: true,
lifecycle: { phase: "idle", retryAt: null }
});
vi.useRealTimers();
});
it("keeps post-processing drain visible and starts the pending run after its abort settles", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-postprocess-stop-drain-"));
tempDirs.push(root);
const packageId = "postprocess-drain";
const session = emptySession();
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "postprocess-drain",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "extracting",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now()
};
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", autoExtract: true, autoExtractWhenStopped: true },
session,
createStoragePaths(path.join(root, "state"))
);
let postProcessSignal!: AbortSignal;
let finishPostProcessing!: () => void;
const recoverRetryableItems = vi.fn().mockResolvedValue(0);
const internal = manager as unknown as {
handlePackagePostProcessing: (packageId: string, signal: AbortSignal) => Promise<void>;
recoverRetryableItems: () => Promise<number>;
runPackagePostProcessing: (packageId: string) => Promise<void>;
};
internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => {
postProcessSignal = signal;
await new Promise<void>((resolve) => {
finishPostProcessing = resolve;
});
};
internal.recoverRetryableItems = recoverRetryableItems;
const processing = internal.runPackagePostProcessing(packageId);
await waitFor(() => postProcessSignal !== undefined);
manager.stop();
expect(postProcessSignal.aborted).toBe(true);
await manager.start();
expect(manager.getSnapshot().lifecycle).toMatchObject({
phase: "stopping",
activePostProcessing: 1,
pendingStart: true
});
finishPostProcessing();
await processing;
await waitFor(() => manager.getSnapshot().lifecycle?.phase === "idle");
expect(recoverRetryableItems).toHaveBeenCalledTimes(1);
expect(manager.getSnapshot().lifecycle).toMatchObject({ activePostProcessing: 0, pendingStart: false });
});
});
describe("extractArchiveNameFromExtractorLogMessage", () => {
@@ -14146,8 +14249,12 @@ describe("download manager", () => {
manager.start().then(() => "started" as const, (error) => String(error)),
timeout
]);
expect(result).toContain("Kein aktiver Download-Account verfügbar");
expect(manager.getSnapshot().session.running).toBe(false);
expect(result).toBe("started");
await waitFor(() => manager.getSnapshot().lifecycle?.reason.includes("Kein aktiver Download-Account verfügbar") === true);
expect(manager.getSnapshot()).toMatchObject({
session: { running: false },
lifecycle: { phase: "idle", pendingStart: false }
});
} finally {
server.close();
await once(server, "close");
+64
View File
@@ -797,6 +797,15 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsVie
packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0,
animationsEnabled: true,
lifecycle: {
phase: "running",
reason: "Downloads laufen",
retryAt: null,
activeDownloads: 1,
activePostProcessing: 0,
pendingStart: false
},
lifecycleNow: now,
editingPackageId: null,
editingName: "",
columnOrder: ["name", "size", "hoster", "progress"] as const,
@@ -1148,6 +1157,61 @@ describe("downloads view", () => {
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
});
it("accepts a start during stopping and then shows the accepted pending request", () => {
const actions = createActions();
const stopping = {
...withRuntime(createInput(), { running: false, canStart: true, canPause: false }),
lifecycle: {
phase: "stopping",
reason: "Laufende Arbeit wird beendet",
retryAt: null,
activeDownloads: 1,
activePostProcessing: 0,
pendingStart: false
},
lifecycleNow: now
} as DownloadsViewModel;
const pending = {
...stopping,
canStart: false,
lifecycle: { ...stopping.lifecycle, pendingStart: true, reason: "Start vorgemerkt, laufende Arbeit wird beendet" }
} as DownloadsViewModel;
expect(findButton(DownloadsToolbar({ actions, model: stopping }), "Start").props.disabled).toBe(false);
expect(findButton(DownloadsToolbar({ actions, model: pending }), "Start vorgemerkt").props.disabled).toBe(true);
});
it("shows lifecycle phase, reason, retry countdown and remaining drain work", () => {
const stopping = {
...withRuntime(createInput(), { running: false }),
lifecycle: {
phase: "stopping",
reason: "Laufende Arbeit wird beendet",
retryAt: null,
activeDownloads: 2,
activePostProcessing: 1,
pendingStart: false
},
lifecycleNow: now
} as DownloadsViewModel;
const waiting = {
...stopping,
lifecycle: {
phase: "waiting_provider",
reason: "Provider vorübergehend nicht verfügbar",
retryAt: now + 3_200,
activeDownloads: 0,
activePostProcessing: 0,
pendingStart: false
}
} as DownloadsViewModel;
const stoppingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={stopping} />);
const waitingHtml = renderToStaticMarkup(<DownloadsFooter actions={createActions()} model={waiting} />);
expect(stoppingHtml).toContain("Stoppt · Laufende Arbeit wird beendet · Restarbeit: 2 Downloads, 1 Nachbearbeitung");
expect(waitingHtml).toContain("Wartet auf Provider · Provider vorübergehend nicht verfügbar · Noch 4 s");
});
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
const pausedToolbar = DownloadsToolbar({
actions: createActions(),
+22
View File
@@ -191,6 +191,28 @@ describe("realdebrid-web", () => {
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
});
it("releases an aborted caller while the active web request ignores its signal", async () => {
let rejectRequest!: (error: Error) => void;
mockSessionFetch.mockReturnValue(new Promise<Response>((_resolve, reject) => {
rejectRequest = reject;
}));
const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_abort_race", () => true);
const controller = new AbortController();
const running = fallback.unrestrict("https://rapidgator.net/file/abort-race", controller.signal)
.then(() => "resolved" as const, (error) => String(error));
await vi.waitFor(() => expect(mockSessionFetch).toHaveBeenCalledTimes(1));
controller.abort("test-stop");
const outcome = await Promise.race([
running,
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200))
]);
expect(outcome).toContain("aborted:realdebrid-web");
rejectRequest(new Error("late realdebrid rejection"));
await Promise.resolve();
});
it("does not open a login window for an authenticated account with a fair-use error", async () => {
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({