fix: strengthen live recovery and support diagnostics

Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-13 11:36:51 +02:00
parent 88399c5dd0
commit 25ebc55f4f
44 changed files with 5223 additions and 959 deletions
+172 -113
View File
@@ -38,7 +38,12 @@ import {
getProviderDailyUsageBytes,
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
import {
preservePackageOrderForDisplay,
reconcileCollapsedPackageState,
reconcileOptimisticPackageOrder,
sortPackageOrderByName
} from "./package-order";
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
import type { AccountModeFilter } from "./account-ui";
@@ -869,11 +874,56 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
if (!running) delay = Math.min(delay, 200);
if (!running && activeTab !== "downloads") delay = Math.max(delay, 800);
return delay;
export function getSnapshotRenderDelay(_itemCount: number, _running: boolean, _activeTab: MainView): number {
return 0;
}
export interface ResetUiActionGate {
busy: boolean;
}
interface ResetUiActionOptions {
gate: ResetUiActionGate;
reset: () => Promise<void>;
reconcile: () => Promise<void>;
setBusy: (busy: boolean) => void;
onError: (error: unknown) => void;
onBusy?: () => void;
}
export async function runResetUiAction(options: ResetUiActionOptions): Promise<"completed" | "failed" | "busy"> {
if (options.gate.busy) {
options.onBusy?.();
return "busy";
}
options.gate.busy = true;
options.setBusy(true);
let failed = false;
let failure: unknown;
try {
await options.reset();
} catch (error) {
failed = true;
failure = error;
}
try {
await options.reconcile();
} catch (error) {
if (!failed) {
failure = error;
}
failed = true;
}
try {
if (failed) {
options.onError(failure);
return "failed";
}
return "completed";
} finally {
options.gate.busy = false;
options.setBusy(false);
}
}
interface SupportBundleExportUiOptions {
@@ -1466,19 +1516,7 @@ const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
const COLUMN_DEFS = downloadColumnDefinitions;
function sameStringArray(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false;
}
}
return true;
}
function formatMbpsInputFromKbps(kbps: number): string {
function formatMbpsInputFromKbps(kbps: number): string {
const mbps = Math.max(0, Number(kbps) || 0) / 1024;
return String(Number(mbps.toFixed(2)));
}
@@ -1567,14 +1605,54 @@ export function App(): ReactElement {
return () => localizer.disconnect();
}, [settingsDraft.language]);
const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null);
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const tabRef = useRef(tab);
tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestStateRef = useRef<UiSnapshot | null>(null);
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]);
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const tabRef = useRef(tab);
tabRef.current = tab;
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stageAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): UiSnapshot => {
masterSnapshotRef.current = fresh;
serverPackageOrderRef.current = fresh.session.packageOrder;
const order = reconcileOptimisticPackageOrder(
fresh.session.packageOrder,
pendingPackageOrderRef.current,
pendingPackageOrderAtRef.current,
Date.now()
);
pendingPackageOrderRef.current = order.pendingOrder;
pendingPackageOrderAtRef.current = order.pendingAt;
packageOrderRef.current = order.displayOrder;
if (order.displayOrder === fresh.session.packageOrder) {
return fresh;
}
return {
...fresh,
session: {
...fresh.session,
packageOrder: order.displayOrder
}
};
}, []);
const applyAuthoritativeSnapshot = useCallback((fresh: UiSnapshot): void => {
if (stateFlushTimerRef.current) {
clearTimeout(stateFlushTimerRef.current);
stateFlushTimerRef.current = null;
}
const displaySnapshot = stageAuthoritativeSnapshot(fresh);
latestStateRef.current = null;
snapshotRef.current = displaySnapshot;
setSnapshot(displaySnapshot);
}, [stageAuthoritativeSnapshot]);
const reconcileAuthoritativeSnapshot = useCallback(async (): Promise<void> => {
applyAuthoritativeSnapshot(await window.rd.getSnapshot());
}, [applyAuthoritativeSnapshot]);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
const [dragOver, setDragOver] = useState(false);
const [draggedProvider, setDraggedProvider] = useState<DebridProvider | null>(null);
@@ -1590,12 +1668,8 @@ export function App(): ReactElement {
const [collectorError, setCollectorError] = useState("");
const [collectorInput, setCollectorInput] = useState<CollectorInputState | null>(null);
const collectorTabsRef = useRef<CollectorTab[]>(collectorTabs);
const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]);
const serverPackageOrderRef = useRef<string[]>([]);
const pendingPackageOrderRef = useRef<string[] | null>(null);
const pendingPackageOrderAtRef = useRef(0);
const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState("");
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
@@ -1605,6 +1679,7 @@ export function App(): ReactElement {
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [resetBusy, setResetBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
@@ -1612,6 +1687,7 @@ export function App(): ReactElement {
const accountToggleRevisionRef = useRef(0);
const accountTogglePendingRef = useRef(0);
const actionBusyRef = useRef(false);
const resetUiActionGateRef = useRef<ResetUiActionGate>({ busy: false });
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
@@ -1734,35 +1810,7 @@ export function App(): ReactElement {
activeTabRef.current = tab;
}, [tab]);
useEffect(() => {
const incoming = snapshot.session.packageOrder;
serverPackageOrderRef.current = incoming;
const pending = pendingPackageOrderRef.current;
if (!pending) {
packageOrderRef.current = incoming;
return;
}
if (sameStringArray(pending, incoming)) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
const maxOptimisticHoldMs = 1500;
if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = incoming;
return;
}
packageOrderRef.current = pending;
}, [snapshot.session.packageOrder]);
useEffect(() => {
useEffect(() => {
setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps));
}, [settingsDraft.speedLimitKbps]);
@@ -1805,6 +1853,23 @@ export function App(): ReactElement {
}
}, []);
const performReset = useCallback(async (reset: () => Promise<void>): Promise<void> => {
if (!resetUiActionGateRef.current.busy) {
showToast("Zurücksetzen läuft …", 60_000);
}
const result = await runResetUiAction({
gate: resetUiActionGateRef.current,
reset,
reconcile: reconcileAuthoritativeSnapshot,
setBusy: setResetBusy,
onError: (error) => { showToast(`Zurücksetzen fehlgeschlagen: ${String(error)}`, 3200); },
onBusy: () => { showToast("Zurücksetzen läuft bereits …", 2200); }
});
if (result === "completed") {
showToast("Zurücksetzen abgeschlossen", 1800);
}
}, [reconcileAuthoritativeSnapshot, showToast]);
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
const availableIds = entries.map((entry) => entry.id);
const availableSet = new Set(availableIds);
@@ -1943,8 +2008,7 @@ export function App(): ReactElement {
if (!mountedRef.current) {
return;
}
masterSnapshotRef.current = state;
setSnapshot(state);
applyAuthoritativeSnapshot(state);
if (state.settings.columnOrder?.length > 0) {
setColumnOrder(state.settings.columnOrder);
}
@@ -1989,8 +2053,7 @@ export function App(): ReactElement {
} else {
merged = wireState;
}
masterSnapshotRef.current = merged;
latestStateRef.current = merged;
latestStateRef.current = stageAuthoritativeSnapshot(merged);
if (stateFlushTimerRef.current) { return; }
const itemCount = Object.keys(merged.session.items).length;
@@ -1999,8 +2062,9 @@ export function App(): ReactElement {
stateFlushTimerRef.current = setTimeout(() => {
stateFlushTimerRef.current = null;
if (latestStateRef.current) {
const next = latestStateRef.current;
setSnapshot(next);
const next = latestStateRef.current;
snapshotRef.current = next;
setSnapshot(next);
if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder);
}
@@ -2055,7 +2119,7 @@ export function App(): ReactElement {
if (unsubClipboard) { unsubClipboard(); }
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
};
}, [clearImportQueueFocusListener]);
}, [applyAuthoritativeSnapshot, clearImportQueueFocusListener, stageAuthoritativeSnapshot]);
const downloadsTabActive = tab === "downloads";
const deferredDownloadSearch = useDeferredValue(downloadSearch);
@@ -2090,28 +2154,16 @@ export function App(): ReactElement {
}, [downloadsTabActive, snapshot.session.packageOrder]);
useEffect(() => {
if (!downloadsTabActive) {
return;
}
setCollapsedPackages((prev) => {
let changed = false;
const next: Record<string, boolean> = { ...prev };
const defaultCollapsed = totalPackageCount >= 24;
for (const packageId of snapshot.session.packageOrder) {
if (!(packageId in prev)) {
next[packageId] = defaultCollapsed;
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!snapshot.session.packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return changed ? next : prev;
});
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
if (!downloadsTabActive) {
return;
}
setCollapsedPackages((prev) => reconcileCollapsedPackageState(
prev,
snapshot.session.packageOrder,
snapshot.session.packages,
totalPackageCount >= 24
));
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
// Prune selection when its packages/items disappear (e.g. via delta-removal or
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
@@ -3316,7 +3368,11 @@ export function App(): ReactElement {
showToast(`Konflikte gelöst: ${overwritten} überschrieben, ${skipped} übersprungen`, 2800);
}
await window.rd.start();
try {
await window.rd.start();
} finally {
await reconcileAuthoritativeSnapshot().catch(() => undefined);
}
});
};
@@ -4686,7 +4742,7 @@ export function App(): ReactElement {
canStart: snapshot.canStart,
canPause: snapshot.canPause,
canStop: snapshot.canStop,
actionBusy,
actionBusy: actionBusy || resetBusy,
reconnectSeconds: snapshot.reconnectSeconds,
reconnectReason: snapshot.session.reconnectReason,
clipboardWatcher: snapshot.clipboardActive,
@@ -4712,7 +4768,7 @@ export function App(): ReactElement {
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, 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.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, resetBusy, scheduleCountdown, schedulePickerOpen, 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.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode,
@@ -4732,10 +4788,7 @@ export function App(): ReactElement {
showToast(`Fortsetzen fehlgeschlagen: ${String(error)}`, 3200);
} finally {
try {
const fresh = await window.rd.getSnapshot();
masterSnapshotRef.current = fresh;
latestStateRef.current = null;
setSnapshot(fresh);
await reconcileAuthoritativeSnapshot();
} catch {
}
}
@@ -4746,11 +4799,11 @@ export function App(): ReactElement {
},
onPauseDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
void window.rd.togglePause().then((paused) => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
void window.rd.togglePause().then(async () => {
await reconcileAuthoritativeSnapshot();
}).catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
await reconcileAuthoritativeSnapshot();
} catch {
}
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
@@ -4758,9 +4811,11 @@ export function App(): ReactElement {
},
onStopDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
void window.rd.stop().catch(async (error) => {
void window.rd.stop().then(async () => {
await reconcileAuthoritativeSnapshot();
}).catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
await reconcileAuthoritativeSnapshot();
} catch {
}
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
@@ -4920,7 +4975,7 @@ export function App(): ReactElement {
if (failedIds.length === 0) {
return;
}
void window.rd.resetItems(failedIds).catch(() => {});
void performReset(() => window.rd.resetItems(failedIds));
}
};
const collectorActions: CollectorViewActions = {
@@ -6097,17 +6152,21 @@ export function App(): ReactElement {
}}>Ausgewählte Dateien entfernen ({selectedItemIds.length})</button>
)}
{hasPackages && !contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
for (const id of selectedPackageIds) void window.rd.resetPackage(id).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedPackageIds.length})` : ""}</button>
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
void performReset(async () => {
for (const id of selectedPackageIds) {
await window.rd.resetPackage(id);
}
});
setContextMenu(null);
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedPackageIds.length})` : ""}`}</button>
)}
{contextMenu.itemId && (
<button className="ctx-menu-item" onClick={() => {
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
void window.rd.resetItems(itemIds).catch(() => {});
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
<button className="ctx-menu-item" disabled={resetBusy} onClick={() => {
const itemIds = multi ? selectedItemIds : [contextMenu.itemId!];
void performReset(() => window.rd.resetItems(itemIds));
setContextMenu(null);
}}>{resetBusy ? "Zurücksetzen läuft …" : `Zurücksetzen${multi ? ` (${selectedItemIds.length})` : ""}`}</button>
)}
{hasPackages && !multi && (() => {
const pkg = snapshot.session.packages[contextMenu.packageId];
+6
View File
@@ -9,6 +9,8 @@ export type AccountToggleTarget =
export interface AccountToggleSettings {
disabledProviders: DebridProvider[];
debridLinkDisabledKeyIds: string[];
megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean;
megaDebridDisabledAccountIds: string[];
megaDebridApiDisabledAccountIds: string[];
megaDebridWebDisabledAccountIds: string[];
@@ -45,6 +47,8 @@ export function setAccountTargetEnabled<T extends AccountToggleSettings>(
: settings.megaDebridWebDisabledAccountIds;
return {
...settings,
megaDebridApiEnabled: target.kind === "mega-api" && enabled ? true : settings.megaDebridApiEnabled,
megaDebridWebEnabled: target.kind === "mega-web" && enabled ? true : settings.megaDebridWebEnabled,
megaDebridApiDisabledAccountIds: apiDisabled,
megaDebridWebDisabledAccountIds: webDisabled,
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
@@ -55,6 +59,8 @@ export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings
return {
disabledProviders: settings.disabledProviders,
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
megaDebridApiEnabled: settings.megaDebridApiEnabled,
megaDebridWebEnabled: settings.megaDebridWebEnabled,
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
+75
View File
@@ -27,3 +27,78 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
return packages;
}
export type OptimisticPackageOrderStatus = "idle" | "pending" | "acknowledged" | "timed-out";
export interface OptimisticPackageOrderReconciliation {
displayOrder: string[];
pendingOrder: string[] | null;
pendingAt: number;
status: OptimisticPackageOrderStatus;
}
function samePackageOrder(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((packageId, index) => packageId === right[index]);
}
export function reconcileOptimisticPackageOrder(
authoritativeOrder: string[],
pendingOrder: string[] | null,
pendingAt: number,
now: number,
holdMs = 1_500
): OptimisticPackageOrderReconciliation {
if (!pendingOrder) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "idle"
};
}
if (samePackageOrder(authoritativeOrder, pendingOrder)) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "acknowledged"
};
}
if (now - pendingAt >= holdMs) {
return {
displayOrder: authoritativeOrder,
pendingOrder: null,
pendingAt: 0,
status: "timed-out"
};
}
return {
displayOrder: pendingOrder,
pendingOrder,
pendingAt,
status: "pending"
};
}
export function reconcileCollapsedPackageState(
previous: Record<string, boolean>,
packageOrder: string[],
packages: Record<string, PackageEntry>,
defaultCollapsed: boolean
): Record<string, boolean> {
let changed = false;
const next = { ...previous };
for (const packageId of packageOrder) {
if (!(packageId in previous)) {
next[packageId] = defaultCollapsed;
changed = true;
}
}
for (const packageId of Object.keys(next)) {
if (!packages[packageId]) {
delete next[packageId];
changed = true;
}
}
return changed ? next : previous;
}
+11 -1
View File
@@ -22,6 +22,13 @@ export function getRollingMetricDirection(previous: number, next: number): Rolli
return "none";
}
export function shouldAnimateRollingMetric(
direction: RollingMetricDirection,
reducedMotion: boolean
): direction is Exclude<RollingMetricDirection, "none"> {
return direction !== "none" && !reducedMotion;
}
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
const previousRef = useRef({ numericValue, value });
const sequenceRef = useRef(0);
@@ -34,7 +41,10 @@ export function RollingMetricValue({ numericValue, value }: RollingMetricValuePr
if (previous.value === value && previous.numericValue === numericValue) return;
previousRef.current = { numericValue, value };
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
if (direction === "none") {
const reducedMotion = typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!shouldAnimateRollingMetric(direction, reducedMotion)) {
setTransition(null);
return;
}