Prepare v2.0.74 sidepanel reliability release

Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization.

Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-26 20:35:06 +02:00
parent bdcf9e3754
commit 2ea494cd5c
69 changed files with 7660 additions and 883 deletions
+92 -26
View File
@@ -55,11 +55,18 @@ export interface DownloadsViewModelCore {
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
limited: boolean;
sourceEmpty: boolean;
presentationEmpty: boolean;
empty: boolean;
filteredEmpty: boolean;
}
export interface DownloadByteSummary {
bytes: number;
unknownItems: number;
}
export type DownloadLogicalRow =
| (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow })
| (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem });
@@ -84,12 +91,18 @@ export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): Downl
return counts;
}
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
let total = 0;
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): DownloadByteSummary {
let bytes = 0;
let unknownItems = 0;
for (const item of items) {
total += item.totalBytes || item.downloadedBytes || 0;
if (item.totalBytes && item.totalBytes > 0) {
bytes += item.totalBytes;
} else {
bytes += Math.max(0, item.downloadedBytes || 0);
unknownItems += 1;
}
}
return total;
return { bytes, unknownItems };
}
function isPendingDownloadItem(item: DownloadItem): boolean {
@@ -121,14 +134,14 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
packageCount: number;
pendingItemCount: number;
totalBytes: number;
total: DownloadByteSummary;
remaining: { bytes: number; unknownItems: number };
hosterCount: number;
} {
return {
packageCount: new Set(items.map((item) => item.packageId)).size,
pendingItemCount: getPendingDownloadItemCount(items),
totalBytes: getDownloadQueueTotalBytes(items),
total: getDownloadQueueTotalBytes(items),
remaining: getRemainingDownloadBytes(items),
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
};
@@ -147,6 +160,23 @@ export function formatRemainingDownloadTooltip(summary: { bytes: number; unknown
return `Noch unbekannte Dateigrößen: ${summary.unknownItems}. Die tatsächliche Restmenge kann höher sein.`;
}
export function formatDownloadEta(
remaining: DownloadByteSummary,
speedBps: number,
running: boolean,
paused: boolean
): string {
if (!running || paused || speedBps <= 0 || remaining.unknownItems > 0 || remaining.bytes <= 0) return "--";
const totalSeconds = Math.ceil(remaining.bytes / speedBps);
const seconds = totalSeconds % 60;
const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
return hours > 0
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
let total = 0;
for (const speed of Object.values(packageSpeeds)) {
@@ -159,13 +189,43 @@ function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function isExtractFailure(item: DownloadItem): boolean {
const status = item.fullStatus.trim();
return /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
}
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
if (item.status === "failed" || isExtractFailure(item)) return "failed";
if (item.status === "cancelled") return "all";
if (isExtracted(item)) return "completed";
if ((pkg.status === "extracting" || pkg.status === "integrity_check") && item.status === "completed") return "active";
if (item.status === "completed") return "completed";
if (pkg.status === "failed") return "failed";
if (pkg.status === "paused") return "paused";
return classifyDownloadStatus(item.status);
}
function buildDownloadLifecycleCounts(packages: readonly PackageEntry[], items: Record<string, DownloadItem>, hideExtractedItems: boolean): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const pkg of packages) {
for (const itemId of pkg.itemIds) {
const item = items[itemId];
if (!item || (hideExtractedItems && isExtracted(item))) continue;
counts.all += 1;
const category = classifyDownloadItem(item, pkg);
if (category !== "all") counts[category] += 1;
}
}
return counts;
}
function matchesFilter(item: DownloadItem, pkg: PackageEntry, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadItem(item, pkg) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
@@ -194,11 +254,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const eligibleItems = input.hideExtractedItems ? allItems.filter((item) => !isExtracted(item)) : allItems;
const eligiblePackageCount = new Set(eligibleItems.map((item) => item.packageId)).size;
const counts = buildDownloadSidebarCounts(eligibleItems);
const counts = buildDownloadLifecycleCounts(allPackages, input.items, input.hideExtractedItems);
const providerMap = new Map<string, string>();
for (const entry of eligibleItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
const providerFilter = input.providerFilter === "all" || providerMap.has(input.providerFilter) ? input.providerFilter : "all";
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
@@ -218,13 +279,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
return matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
? items.filter((item) => matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }];
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
@@ -247,10 +308,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
? fileRows.length
: totalPackageRows;
return {
const sourceEmpty = allItems.length === 0;
const presentationEmpty = eligibleItems.length === 0;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
@@ -267,7 +331,9 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: false,
empty: eligibleItems.length === 0,
filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
sourceEmpty,
presentationEmpty,
empty: sourceEmpty,
filteredEmpty: !sourceEmpty && mainRowCount === 0
};
}