diff --git a/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md new file mode 100644 index 0000000..c6d23f7 --- /dev/null +++ b/.superpowers/sdd/2026-08-22-daily-schedule-stop-ui-polish/task-3-report.md @@ -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. diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 2e41bc6..2bac815 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -42,7 +42,7 @@ function throwIfAborted(signal?: AbortSignal): void { } } -async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { if (!signal) { await sleep(ms); return; @@ -68,8 +68,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise }; signal.addEventListener("abort", onAbort, { once: true }); - }); -} + }); +} + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortError(); + } + return new Promise((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 | 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 { const partition = this.getPartition(); diff --git a/src/main/bestdebrid-web.ts b/src/main/bestdebrid-web.ts index 52eeb89..1742dfb 100644 --- a/src/main/bestdebrid-web.ts +++ b/src/main/bestdebrid-web.ts @@ -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(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortError(); + } + return new Promise((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 | 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); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index a3f7253..6c6960e 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -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 = { realdebrid: "Real-Debrid", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 0ca1223..01fbd05 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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; diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index edbab9f..d36a597 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -47,7 +47,7 @@ function throwIfAborted(signal?: AbortSignal): void { } } -async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { if (!signal) { await sleep(ms); return; @@ -73,8 +73,44 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise }; signal.addEventListener("abort", onAbort, { once: true }); - }); -} + }); +} + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw abortError(); + } + return new Promise((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 | 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 { const partition = this.getPartition(); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 137c22c..35bd6c3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 = { @@ -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(() => ({ ...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) { diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 0a28093..2a1e578 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -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 (
- + {!model.scheduleActive ? : null} @@ -171,13 +174,42 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct ); } + +function formatLifecycleStatus(model: DownloadsViewModel): string { + const phaseLabels: Record = { + 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 (
{model.paginationLabel} {model.limited ? : null} - {model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"} + {formatLifecycleStatus(model)}
); } diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index c743e1a..b4a749e 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -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((_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 })) diff --git a/tests/bestdebrid-web.test.ts b/tests/bestdebrid-web.test.ts index 850c754..94b487d 100644 --- a/tests/bestdebrid-web.test.ts +++ b/tests/bestdebrid-web.test.ts @@ -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((_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(); + }); +}); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 35df7ec..e43cc26 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -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> = []; + 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; + recoverRetryableItems: () => Promise; + runPackagePostProcessing: (packageId: string) => Promise; + }; + internal.handlePackagePostProcessing = async (_requestedPackageId, signal) => { + postProcessSignal = signal; + await new Promise((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"); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 396f240..9db5939 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -797,6 +797,15 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial { 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(); + const waitingHtml = renderToStaticMarkup(); + 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(), diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index 49744ad..90fc324 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -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((_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("", { status: 200 })); vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({