fix(downloads): stabilize queue and disk recovery

Keep package rows in their existing visible order across runtime state changes and remove obsolete automatic progress grouping.

Require an active usable provider before starting or resuming downloads, recover temporary disk-write blocks without consuming normal retries, and surface disk waits and extraction failures at package level.

Add focused regression coverage and prepare the v2.0.24 release metadata and changelog.
This commit is contained in:
Sucukdeluxe
2026-08-11 20:08:06 +02:00
parent a6e6d2d439
commit 2861fd95f7
14 changed files with 485 additions and 264 deletions
+5 -16
View File
@@ -36,7 +36,7 @@ import {
getProviderDailyUsageBytes,
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { preservePackageOrderForDisplay, 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";
@@ -1100,7 +1100,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@@ -1131,7 +1131,7 @@ const emptySnapshot = (): UiSnapshot => ({
paused: false, running: false, updatedAt: Date.now()
},
summary: null, stats: emptyStats(), speedText: "Geschwindigkeit: 0 B/s", etaText: "ETA: --",
canStart: true, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
canStart: false, canStop: false, canPause: false, clipboardActive: false, reconnectSeconds: 0, packageSpeedBps: {}
});
const cleanupLabels: Record<string, string> = {
@@ -2340,20 +2340,9 @@ export function App(): ReactElement {
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
}, [snapshot.session.packages, snapshot.session.items]);
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
? snapshot.session.items
: null;
const visiblePackages = useMemo(() => {
if (!sortRelevantItems) {
return packages;
}
return sortPackagesForDisplay(
packages,
sortRelevantItems,
true,
true
);
}, [packages, sortRelevantItems]);
return preservePackageOrderForDisplay(packages);
}, [packages]);
const downloadsViewCore = useMemo(() => buildDownloadsViewModel({
packageOrder: visiblePackages.map((entry) => entry.id),
+1 -1
View File
@@ -13,7 +13,7 @@ const pairs = [
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
+4 -43
View File
@@ -1,13 +1,4 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
function isPackageActive(pkg: PackageEntry, itemsById: Record<string, DownloadItem>): boolean {
return pkg.itemIds.some((id) => {
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
}
import type { PackageEntry } from "../shared/types";
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
const fromIndex = order.indexOf(draggedPackageId);
@@ -33,36 +24,6 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
return sorted;
}
export function sortPackagesForDisplay(
packages: PackageEntry[],
itemsById: Record<string, DownloadItem>,
running: boolean,
autoSortPackagesByProgress: boolean
): PackageEntry[] {
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
return packages;
}
const active = packages
.map((pkg, index) => ({ pkg, index }))
.filter(({ pkg }) => isPackageActive(pkg, itemsById))
.sort((left, right) => {
const leftStartedAt = left.pkg.downloadStartedAt || 0;
const rightStartedAt = right.pkg.downloadStartedAt || 0;
if (leftStartedAt > 0 && rightStartedAt > 0 && leftStartedAt !== rightStartedAt) {
return leftStartedAt - rightStartedAt;
}
if (leftStartedAt > 0 && rightStartedAt <= 0) return -1;
if (leftStartedAt <= 0 && rightStartedAt > 0) return 1;
return left.index - right.index;
})
.map(({ pkg }) => pkg);
const activeSet = new Set(active.map((pkg) => pkg.id));
const rest = packages.filter((pkg) => !activeSet.has(pkg.id));
if (active.length === 0) {
return packages;
}
return [...active, ...rest];
}
export function preservePackageOrderForDisplay(packages: PackageEntry[]): PackageEntry[] {
return packages;
}
@@ -134,6 +134,8 @@ function progress(value: number): number {
export function compactDownloadStatus(value: string): string {
const status = value.trim();
const runtimeWait = status.match(/^(Starte\.\.\.|Starting\.\.\.|Warte auf Daten|Waiting for data|Warte auf Festplatte|Waiting for disk)(?:\s+\([^)]*\))?$/i);
if (runtimeWait) return runtimeWait[1];
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
if (/Download läuft\b/i.test(status)) return "Download läuft";
if (/Download running\b/i.test(status)) return "Download running";
@@ -448,12 +450,17 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
? "Entpacken - Ausstehend"
: compactDownloadStatus(rawPostProcessLabel);
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || ""));
const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte");
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`;
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
? postProcessLabel
: downloading ? "Download läuft" : details;
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
: extractFailure ? "Entpack-Fehler"
: waitsForDisk ? "Warte auf Festplatte"
: downloading ? "Download läuft" : details;
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
return <DownloadStatusCell status={status} title={title} />;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
@@ -118,7 +118,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">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
@@ -507,7 +507,6 @@ export function buildSettingsFormViewModel({
title: "Oberfläche und Bedienung",
fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "autoSortPackagesByProgress", kind: "switch", label: "Nach Fortschritt sortieren", value: settings.autoSortPackagesByProgress },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },