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:
@@ -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.
|
||||||
@@ -71,6 +71,42 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -282,7 +318,7 @@ export class AllDebridWebFallback {
|
|||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
|
|||||||
@@ -29,6 +29,42 @@ function throwIfAborted(signal?: AbortSignal): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
@@ -240,7 +276,7 @@ export class BestDebridWebFallback {
|
|||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> {
|
||||||
|
|||||||
@@ -588,6 +588,16 @@ export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSna
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
const PROVIDER_LABELS: Record<DebridProvider, string> = {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
|
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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
import { classifyDiskError } from "./fs-error";
|
import { classifyDiskError } from "./fs-error";
|
||||||
@@ -1858,6 +1858,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private stateEmitTimer: NodeJS.Timeout | null = null;
|
private stateEmitTimer: NodeJS.Timeout | null = null;
|
||||||
private lastStateEmitAt = 0;
|
private lastStateEmitAt = 0;
|
||||||
|
private providerRetryTimer: NodeJS.Timeout | null = null;
|
||||||
|
private providerRetryAt = 0;
|
||||||
|
|
||||||
private speedBytesLastWindow = 0;
|
private speedBytesLastWindow = 0;
|
||||||
|
|
||||||
@@ -2732,6 +2734,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const now = nowMs();
|
const now = nowMs();
|
||||||
this.ensureProviderDailyUsageFresh(now, true);
|
this.ensureProviderDailyUsageFresh(now, true);
|
||||||
this.pruneSpeedEvents(now);
|
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 paused = this.session.running && this.session.paused;
|
||||||
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
|
const speedBps = !this.session.running || paused ? 0 : this.speedBytesLastWindow / SPEED_WINDOW_SECONDS;
|
||||||
|
|
||||||
@@ -2781,7 +2787,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
rotationEvents: getRecentRotationEvents(40),
|
rotationEvents: getRecentRotationEvents(40),
|
||||||
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
accountRuntime: createAccountRuntimeEntries(rendererState.accounts, Object.values(snapshotSession.items), now),
|
||||||
lifecycle: this.getLifecycleSnapshot(),
|
lifecycle,
|
||||||
settings: rendererState.settings,
|
settings: rendererState.settings,
|
||||||
accounts: rendererState.accounts,
|
accounts: rendererState.accounts,
|
||||||
session: snapshotSession,
|
session: snapshotSession,
|
||||||
@@ -2789,7 +2795,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
stats: this.getStats(now),
|
stats: this.getStats(now),
|
||||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||||
etaText: paused || !this.session.running ? "ETA: --" : `ETA: ${formatEta(eta)}`,
|
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,
|
canStop: this.session.running,
|
||||||
canPause: this.session.running,
|
canPause: this.session.running,
|
||||||
clipboardActive: this.settings.clipboardWatch,
|
clipboardActive: this.settings.clipboardWatch,
|
||||||
@@ -3261,6 +3269,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
clearTimeout(this.stateEmitTimer);
|
clearTimeout(this.stateEmitTimer);
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.providerRetryTimer) {
|
||||||
|
clearTimeout(this.providerRetryTimer);
|
||||||
|
this.providerRetryTimer = null;
|
||||||
|
this.providerRetryAt = 0;
|
||||||
|
}
|
||||||
this.session.packageOrder = [];
|
this.session.packageOrder = [];
|
||||||
this.session.packages = {};
|
this.session.packages = {};
|
||||||
this.session.items = {};
|
this.session.items = {};
|
||||||
@@ -3926,7 +3939,42 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return tasks.size;
|
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 activeDownloads = this.activeTasks.size;
|
||||||
const activePostProcessing = this.getActivePostProcessingCount();
|
const activePostProcessing = this.getActivePostProcessingCount();
|
||||||
const pendingStart = this.pendingStartOptions !== null;
|
const pendingStart = this.pendingStartOptions !== null;
|
||||||
@@ -3934,7 +3982,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
phase: "stopping",
|
phase: "stopping",
|
||||||
reason: pendingStart ? "Start vorgemerkt, laufende Arbeit wird beendet" : "Laufende Arbeit wird beendet",
|
reason: pendingStart ? "Start vorgemerkt, laufende Arbeit wird beendet" : "Laufende Arbeit wird beendet",
|
||||||
retryAt: null,
|
retryAt,
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
activePostProcessing,
|
activePostProcessing,
|
||||||
pendingStart
|
pendingStart
|
||||||
@@ -3944,18 +3992,28 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
phase: "starting",
|
phase: "starting",
|
||||||
reason: this.lifecycleReason,
|
reason: this.lifecycleReason,
|
||||||
retryAt: null,
|
retryAt,
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
activePostProcessing,
|
activePostProcessing,
|
||||||
pendingStart
|
pendingStart
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (this.session.running) {
|
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;
|
const postprocessing = activeDownloads === 0 && activePostProcessing > 0;
|
||||||
return {
|
return {
|
||||||
phase: postprocessing ? "postprocessing" : "running",
|
phase: postprocessing ? "postprocessing" : "running",
|
||||||
reason: postprocessing ? "Nachbearbeitung läuft" : this.session.paused ? "Downloads pausiert" : "Downloads laufen",
|
reason: postprocessing ? "Nachbearbeitung läuft" : this.session.paused ? "Downloads pausiert" : "Downloads laufen",
|
||||||
retryAt: null,
|
retryAt,
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
activePostProcessing,
|
activePostProcessing,
|
||||||
pendingStart
|
pendingStart
|
||||||
@@ -3965,16 +4023,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return {
|
return {
|
||||||
phase: "postprocessing",
|
phase: "postprocessing",
|
||||||
reason: "Nachbearbeitung läuft",
|
reason: "Nachbearbeitung läuft",
|
||||||
retryAt: null,
|
retryAt,
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
activePostProcessing,
|
activePostProcessing,
|
||||||
pendingStart
|
pendingStart
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const waitingForProvider = !hasUsableAccount && retryAt !== null;
|
||||||
return {
|
return {
|
||||||
phase: "idle",
|
phase: waitingForProvider ? "waiting_provider" : "idle",
|
||||||
reason: this.lifecycleReason,
|
reason: waitingForProvider
|
||||||
retryAt: null,
|
? "Provider vorübergehend nicht verfügbar"
|
||||||
|
: hasUsableAccount ? this.lifecycleReason : "Kein aktiver Download-Account verfügbar",
|
||||||
|
retryAt,
|
||||||
activeDownloads,
|
activeDownloads,
|
||||||
activePostProcessing,
|
activePostProcessing,
|
||||||
pendingStart
|
pendingStart
|
||||||
@@ -6451,6 +6512,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
this.beginHealthRun();
|
this.beginHealthRun();
|
||||||
this.ensureUsableDownloadAccount();
|
this.ensureUsableDownloadAccount();
|
||||||
|
this.session.running = true;
|
||||||
|
this.session.paused = false;
|
||||||
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
|
const recoveryRunPackageIds = new Set(this.session.packageOrder.filter((packageId) => {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled && !options?.excludePackageIds?.has(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);
|
const recoveredItems = await this.recoverRetryableItems("start", recoveryRunPackageIds);
|
||||||
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
if (this.lifecycleGeneration !== generation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
if (this.lifecycleGeneration !== generation || this.lifecyclePhase === "stopping") {
|
if (this.lifecycleGeneration !== generation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6631,7 +6694,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.healthManualStop = !parkForRestart;
|
this.healthManualStop = !parkForRestart;
|
||||||
this.healthShuttingDown = parkForRestart;
|
this.healthShuttingDown = parkForRestart;
|
||||||
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
|
||||||
const keepExtraction = this.settings.autoExtractWhenStopped;
|
|
||||||
const wasRunning = this.session.running;
|
const wasRunning = this.session.running;
|
||||||
const stoppedRunContext = wasRunning
|
const stoppedRunContext = wasRunning
|
||||||
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
|
? this.stopActiveRunContext(this.runPackageIds, this.session.runStartedAt)
|
||||||
@@ -6652,12 +6714,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.speedBytesPerPackage.clear();
|
this.speedBytesPerPackage.clear();
|
||||||
this.speedEventsHead = 0;
|
this.speedEventsHead = 0;
|
||||||
if (!keepExtraction) {
|
this.abortPostProcessing("stop");
|
||||||
this.abortPostProcessing("stop");
|
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
||||||
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
this.packagePostProcessWaiters = [];
|
||||||
this.packagePostProcessWaiters = [];
|
this.packagePostProcessActive = 0;
|
||||||
this.packagePostProcessActive = 0;
|
|
||||||
}
|
|
||||||
for (const active of this.activeTasks.values()) {
|
for (const active of this.activeTasks.values()) {
|
||||||
active.abortReason = abortReason;
|
active.abortReason = abortReason;
|
||||||
active.abortController.abort(abortReason);
|
active.abortController.abort(abortReason);
|
||||||
@@ -6672,9 +6732,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const pkg of Object.values(this.session.packages)) {
|
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 === "extracting" || pkg.status === "integrity_check"
|
||||||
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
|
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
|
||||||
@@ -6717,6 +6774,11 @@ export class DownloadManager extends EventEmitter {
|
|||||||
clearTimeout(this.stateEmitTimer);
|
clearTimeout(this.stateEmitTimer);
|
||||||
this.stateEmitTimer = null;
|
this.stateEmitTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.providerRetryTimer) {
|
||||||
|
clearTimeout(this.providerRetryTimer);
|
||||||
|
this.providerRetryTimer = null;
|
||||||
|
this.providerRetryAt = 0;
|
||||||
|
}
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
@@ -14250,6 +14312,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const completedAt = nowMs();
|
const completedAt = nowMs();
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
|
this.lifecyclePhase = "idle";
|
||||||
|
this.lifecycleReason = "Bereit";
|
||||||
this.session.runStartedAt = 0;
|
this.session.runStartedAt = 0;
|
||||||
const total = this.runItemIds.size;
|
const total = this.runItemIds.size;
|
||||||
const outcomes = Array.from(this.runOutcomes.values());
|
const outcomes = Array.from(this.runOutcomes.values());
|
||||||
|
|||||||
@@ -76,6 +76,42 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function parseJson(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
@@ -301,7 +337,7 @@ export class RealDebridWebFallback {
|
|||||||
};
|
};
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
return run;
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
private async ensureLoginWindow(): Promise<BrowserWindow> {
|
||||||
|
|||||||
+13
-2
@@ -827,7 +827,8 @@ const emptySnapshot = (): UiSnapshot => ({
|
|||||||
paused: false, running: false, updatedAt: Date.now()
|
paused: false, running: false, updatedAt: Date.now()
|
||||||
},
|
},
|
||||||
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
|
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
|
||||||
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
|
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> = {
|
const cleanupLabels: Record<string, string> = {
|
||||||
@@ -4816,6 +4817,14 @@ export function App(): ReactElement {
|
|||||||
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
||||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
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 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>(() => ({
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||||
...downloadsViewCore,
|
...downloadsViewCore,
|
||||||
running: snapshot.session.running,
|
running: snapshot.session.running,
|
||||||
@@ -4846,6 +4855,8 @@ export function App(): ReactElement {
|
|||||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||||
disclosureRevision: downloadDisclosureRevision,
|
disclosureRevision: downloadDisclosureRevision,
|
||||||
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
animationsEnabled: snapshot.settings.animatePackageDisclosure,
|
||||||
|
lifecycle: downloadLifecycle,
|
||||||
|
lifecycleNow: runtimeNow,
|
||||||
status: {
|
status: {
|
||||||
packages: snapshot.stats.totalPackages,
|
packages: snapshot.stats.totalPackages,
|
||||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||||
@@ -4860,7 +4871,7 @@ export function App(): ReactElement {
|
|||||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||||
eta: snapshot.etaText
|
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 => {
|
const resetColumnLayout = useCallback((): void => {
|
||||||
if (columnDragSettleTimerRef.current !== null) {
|
if (columnDragSettleTimerRef.current !== null) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
import type { DownloadLifecycleSnapshot } from "../../../shared/types";
|
||||||
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
import { RollingMetricValue } from "../../ui/RollingMetricValue";
|
||||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||||
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
|
||||||
@@ -54,6 +55,8 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
|||||||
sortDirection?: "asc" | "desc";
|
sortDirection?: "asc" | "desc";
|
||||||
disclosureRevision: number;
|
disclosureRevision: number;
|
||||||
animationsEnabled: boolean;
|
animationsEnabled: boolean;
|
||||||
|
lifecycle: DownloadLifecycleSnapshot;
|
||||||
|
lifecycleNow: number;
|
||||||
status: DownloadsStatusModel;
|
status: DownloadsStatusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"}`;
|
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
|
||||||
return (
|
return (
|
||||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
<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.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</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}
|
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
|
||||||
@@ -172,12 +175,41 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
<footer className="downloads-footer" data-visual-region="downloads-pagination">
|
||||||
<span>{model.paginationLabel}</span>
|
<span>{model.paginationLabel}</span>
|
||||||
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
|
{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>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it("opens the login window after login_required and retries generation with the same session partition", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
.mockResolvedValueOnce(new Response("login", { status: 200 }))
|
||||||
|
|||||||
@@ -164,4 +164,32 @@ describe("bestdebrid-web", () => {
|
|||||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -899,6 +899,109 @@ describe("deterministic stop and restart lifecycle", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
expect(internal.activeTasks.get(itemId)).toBe(newOwner);
|
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", () => {
|
describe("extractArchiveNameFromExtractorLogMessage", () => {
|
||||||
@@ -14146,8 +14249,12 @@ describe("download manager", () => {
|
|||||||
manager.start().then(() => "started" as const, (error) => String(error)),
|
manager.start().then(() => "started" as const, (error) => String(error)),
|
||||||
timeout
|
timeout
|
||||||
]);
|
]);
|
||||||
expect(result).toContain("Kein aktiver Download-Account verfügbar");
|
expect(result).toBe("started");
|
||||||
expect(manager.getSnapshot().session.running).toBe(false);
|
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 {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
await once(server, "close");
|
await once(server, "close");
|
||||||
|
|||||||
@@ -797,6 +797,15 @@ function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsVie
|
|||||||
packageSpeedBps: { "package-a": 12_000_000 },
|
packageSpeedBps: { "package-a": 12_000_000 },
|
||||||
disclosureRevision: 0,
|
disclosureRevision: 0,
|
||||||
animationsEnabled: true,
|
animationsEnabled: true,
|
||||||
|
lifecycle: {
|
||||||
|
phase: "running",
|
||||||
|
reason: "Downloads laufen",
|
||||||
|
retryAt: null,
|
||||||
|
activeDownloads: 1,
|
||||||
|
activePostProcessing: 0,
|
||||||
|
pendingStart: false
|
||||||
|
},
|
||||||
|
lifecycleNow: now,
|
||||||
editingPackageId: null,
|
editingPackageId: null,
|
||||||
editingName: "",
|
editingName: "",
|
||||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||||
@@ -1148,6 +1157,61 @@ describe("downloads view", () => {
|
|||||||
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
|
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", () => {
|
it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => {
|
||||||
const pausedToolbar = DownloadsToolbar({
|
const pausedToolbar = DownloadsToolbar({
|
||||||
actions: createActions(),
|
actions: createActions(),
|
||||||
|
|||||||
@@ -191,6 +191,28 @@ describe("realdebrid-web", () => {
|
|||||||
expect(mockBrowserWindowCtor).not.toHaveBeenCalled();
|
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 () => {
|
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 }));
|
mockSessionFetch.mockResolvedValue(new Response("<input name=\"private_token\" value=\"session-token\">", { status: 200 }));
|
||||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => new Response(JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user