Complete provider waits and lifecycle visibility

Release aborted Real-Debrid, AllDebrid, and BestDebrid web callers even when underlying requests ignore cancellation while retaining terminal rejection observers. Publish provider cooldown deadlines and emit a fresh idle snapshot at the earliest expiry. Abort and visibly drain post-processing before dispatching one pending restart. Surface lifecycle phase, reason, retry countdown, and remaining work in the download controls. Add focused regressions for provider abort races, cooldown expiry, post-processing drain, pending start visibility, and the updated rapid stop contract.
This commit is contained in:
Sucukdeluxe
2026-08-22 10:39:07 +02:00
parent 1f97ce8b42
commit ad29239661
13 changed files with 595 additions and 82 deletions
+15 -4
View File
@@ -825,9 +825,10 @@ const emptySnapshot = (): UiSnapshot => ({
version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0,
totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "",
paused: false, running: false, updatedAt: Date.now()
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {},
lifecycle: { phase: "idle", reason: "Bereit", retryAt: null, activeDownloads: 0, activePostProcessing: 0, pendingStart: false }
});
const cleanupLabels: Record<string, string> = {
@@ -4816,6 +4817,14 @@ export function App(): ReactElement {
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadLifecycle = useMemo(() => snapshot.lifecycle ?? {
phase: snapshot.session.running ? "running" as const : "idle" as const,
reason: snapshot.session.running ? "Downloads laufen" : "Bereit",
retryAt: null,
activeDownloads: Object.values(snapshot.session.items).filter((item) => item.status === "downloading" || item.status === "validating").length,
activePostProcessing: 0,
pendingStart: false
}, [snapshot.lifecycle, snapshot.session.items, snapshot.session.running]);
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore,
running: snapshot.session.running,
@@ -4846,6 +4855,8 @@ export function App(): ReactElement {
sortDirection: downloadsSortDescending ? "desc" : "asc",
disclosureRevision: downloadDisclosureRevision,
animationsEnabled: snapshot.settings.animatePackageDisclosure,
lifecycle: downloadLifecycle,
lifecycleNow: runtimeNow,
status: {
packages: snapshot.stats.totalPackages,
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
@@ -4860,7 +4871,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadLifecycle, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, runtimeNow, scheduleCountdown, schedulePickerOpen, scheduleStartDay, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.dailyStartEnabled, snapshot.settings.dailyStartMinuteOfDay, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const resetColumnLayout = useCallback((): void => {
if (columnDragSettleTimerRef.current !== null) {
+36 -4
View File
@@ -1,4 +1,5 @@
import type { ReactElement } from "react";
import type { DownloadLifecycleSnapshot } from "../../../shared/types";
import { RollingMetricValue } from "../../ui/RollingMetricValue";
import { SlidingSelection } from "../../ui/SlidingSelection";
import type { DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
@@ -54,8 +55,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
sortDirection?: "asc" | "desc";
disclosureRevision: number;
animationsEnabled: boolean;
lifecycle: DownloadLifecycleSnapshot;
lifecycleNow: number;
status: DownloadsStatusModel;
}
}
export interface DownloadsViewActions extends DownloadsTableActions {
onResetColumnLayout: () => void;
@@ -134,7 +137,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const scheduleSlotClass = `downloads-schedule-slot ${scheduleSlotOpen ? "is-open" : "is-closed"}${model.animationsEnabled ? "" : " is-motion-disabled"}`;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">{model.lifecycle.pendingStart ? "Start vorgemerkt" : "Start"}</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
@@ -171,13 +174,42 @@ export function DownloadsContent({ actions, model }: { actions: DownloadsViewAct
</main>
);
}
function formatLifecycleStatus(model: DownloadsViewModel): string {
const phaseLabels: Record<DownloadLifecycleSnapshot["phase"], string> = {
idle: "Bereit",
starting: "Startet",
running: "Läuft",
stopping: "Stoppt",
waiting_provider: "Wartet auf Provider",
postprocessing: "Nachbearbeitung"
};
const parts = [phaseLabels[model.lifecycle.phase]];
if (model.lifecycle.reason && model.lifecycle.reason !== parts[0]) {
parts.push(model.lifecycle.reason);
}
if (model.lifecycle.retryAt && model.lifecycle.retryAt > model.lifecycleNow) {
parts.push(`Noch ${Math.max(1, Math.ceil((model.lifecycle.retryAt - model.lifecycleNow) / 1000))} s`);
}
const remaining: string[] = [];
if (model.lifecycle.activeDownloads > 0) {
remaining.push(`${model.lifecycle.activeDownloads} ${model.lifecycle.activeDownloads === 1 ? "Download" : "Downloads"}`);
}
if (model.lifecycle.activePostProcessing > 0) {
remaining.push(`${model.lifecycle.activePostProcessing} ${model.lifecycle.activePostProcessing === 1 ? "Nachbearbeitung" : "Nachbearbeitungen"}`);
}
if (remaining.length > 0) {
parts.push(`Restarbeit: ${remaining.join(", ")}`);
}
return parts.join(" · ");
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
<span role="status">{formatLifecycleStatus(model)}</span>
</footer>
);
}