Files
Multi-Debrid-Downloader/src/renderer/views/downloads/downloads-model.ts
T
SucukdeluxeandClaude Fable 5.1 558d6ca4f4 Sort packages by availability and tidy availability-related displays
- Make the Verfügbarkeit column sortable from fully online through partial and unchecked down to fully offline, reversing on the second click
- Show partially available packages in the warning color as soon as one link is online and one is offline
- Switch the package order instantly on column sorts instead of sliding rows
- Keep offline links out of the package status error count
- Show queue volumes of one terabyte or more as grouped gigabytes with one decimal per language

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MxAH7rSv8MgfKEPwMkR8N2
2026-09-04 00:19:37 +02:00

363 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AppLanguage, DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
import { extractHoster, humanSize } from "../../download-format";
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
export type DownloadDisplayMode = "packages" | "files";
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
export interface DownloadsModelInput {
packageOrder: string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
query: string;
collapsedPackageIds: Iterable<string>;
selectedIds: Iterable<string>;
hideExtractedItems: boolean;
showAllPackages: boolean;
renderLimit: number;
}
export interface DownloadFilterCounts {
all: number;
active: number;
queued: number;
paused: number;
completed: number;
failed: number;
}
export interface DownloadPackageRow {
package: PackageEntry;
items: DownloadItem[];
allItems: DownloadItem[];
collapsed: boolean;
}
export interface DownloadsViewModelCore {
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
eligibleItems: DownloadItem[];
eligiblePackageCount: number;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
visibleRowIds: string[];
actionableSelectedIds: string[];
actionableSelectedPackageIds: string[];
selectedIds: Set<string>;
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
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 });
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
if (activeStatuses.has(status)) return "active";
if (queuedStatuses.has(status)) return "queued";
if (status === "paused" || status === "completed" || status === "failed") return status;
return "all";
}
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const item of items) {
counts.all += 1;
const category = classifyDownloadStatus(item.status);
if (category !== "all") counts[category] += 1;
}
return counts;
}
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): DownloadByteSummary {
let bytes = 0;
let unknownItems = 0;
for (const item of items) {
if (item.totalBytes && item.totalBytes > 0) {
bytes += item.totalBytes;
} else {
bytes += Math.max(0, item.downloadedBytes || 0);
unknownItems += 1;
}
}
return { bytes, unknownItems };
}
function isPendingDownloadItem(item: DownloadItem): boolean {
return item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed";
}
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): number {
let count = 0;
for (const item of items) {
if (isPendingDownloadItem(item)) count += 1;
}
return count;
}
export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { bytes: number; unknownItems: number } {
let bytes = 0;
let unknownItems = 0;
for (const item of items) {
if (!isPendingDownloadItem(item)) continue;
if (!item.totalBytes || item.totalBytes <= 0) {
unknownItems += 1;
continue;
}
bytes += Math.max(0, item.totalBytes - Math.max(0, item.downloadedBytes));
}
return { bytes, unknownItems };
}
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
packageCount: number;
pendingItemCount: number;
total: DownloadByteSummary;
remaining: { bytes: number; unknownItems: number };
hosterCount: number;
} {
return {
packageCount: new Set(items.map((item) => item.packageId)).size,
pendingItemCount: getPendingDownloadItemCount(items),
total: getDownloadQueueTotalBytes(items),
remaining: getRemainingDownloadBytes(items),
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
};
}
const largeQueueGigabyteFormatters: Record<AppLanguage, Intl.NumberFormat> = {
de: new Intl.NumberFormat("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
en: new Intl.NumberFormat("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
};
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }, language: AppLanguage = "de"): string {
const value = summary.bytes >= 1024 ** 4
? `${largeQueueGigabyteFormatters[language].format(summary.bytes / 1024 ** 3)} GB`
: humanSize(summary.bytes);
if (summary.unknownItems <= 0) return value;
return summary.bytes > 0 ? `≥ ${value}` : "Unbekannt";
}
export function formatRemainingDownloadTooltip(summary: { bytes: number; unknownItems: number }): string {
if (summary.unknownItems <= 0) return "";
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)) {
if (Number.isFinite(speed) && speed > 0) total += speed;
}
return total;
}
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));
}
export function isDownloadItemError(item: DownloadItem): boolean {
const status = item.fullStatus.trim();
return item.status === "failed"
|| /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
}
export function getPackageErrorItemIds(
packageIds: readonly string[],
packages: Record<string, PackageEntry>,
items: Record<string, DownloadItem>
): string[] {
const errorItemIds = new Set<string>();
for (const packageId of packageIds) {
const pkg = packages[packageId];
if (!pkg) continue;
for (const itemId of pkg.itemIds) {
const item = items[itemId];
if (item && isDownloadItemError(item)) errorItemIds.add(itemId);
}
}
return [...errorItemIds];
}
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
if (isDownloadItemError(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;
}
function paginationLabel(visible: number, total: number): string {
if (visible === 0 || total === 0) return "0 von 0";
return `1${visible} von ${total}`;
}
export function buildDownloadLogicalRows(model: Pick<DownloadsViewModelCore, "displayMode" | "packageRows" | "fileRows">): DownloadLogicalRow[] {
if (model.displayMode === "files") {
return model.fileRows.map((item) => ({ type: "item", id: item.id, packageId: item.packageId, item, height: DOWNLOAD_FILE_ROW_HEIGHT }));
}
return model.packageRows.flatMap((row): DownloadLogicalRow[] => {
const packageRow: DownloadLogicalRow = { type: "package", id: row.package.id, packageId: row.package.id, packageRow: row, height: DOWNLOAD_PACKAGE_ROW_HEIGHT };
if (row.collapsed) return [packageRow];
return [packageRow, ...row.items.map((item): DownloadLogicalRow => ({ type: "item", id: item.id, packageId: item.packageId, item, height: DOWNLOAD_FILE_ROW_HEIGHT }))];
});
}
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
const allPackages = input.packageOrder
.map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry));
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 = 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);
}
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);
const selectedIds = new Set(input.selectedIds);
const packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
const allPackageItems = entry.itemIds
.map((id) => input.items[id])
.filter((item): item is DownloadItem => Boolean(item));
const items = allPackageItems
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
const matchingItems = items.filter((item) => {
const itemMatchesQuery = query === ""
|| matchesQuery(item.fileName, query)
|| matchesQuery(item.targetPath, query)
|| matchesQuery(item.providerLabel, query)
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
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, entry, input.filter) && matchesProvider(item, providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
const logicalRows = buildDownloadLogicalRows({
displayMode: input.displayMode,
packageRows: displayedPackages,
fileRows
});
const visibleItemIds = logicalRows.filter((row): row is Extract<DownloadLogicalRow, { type: "item" }> => row.type === "item").map((row) => row.item.id);
const visibleRowIds = logicalRows.map((row) => row.id);
const visibleRowSet = new Set(visibleRowIds);
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
const totalMainRowCount = input.displayMode === "files"
? fileRows.length
: totalPackageRows;
const sourceEmpty = allItems.length === 0;
const presentationEmpty = eligibleItems.length === 0;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
eligibleItems,
eligiblePackageCount,
packageRows: displayedPackages,
fileRows,
visibleItemIds,
visibleRowIds,
actionableSelectedIds,
actionableSelectedPackageIds,
selectedIds,
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: false,
sourceEmpty,
presentationEmpty,
empty: sourceEmpty,
filteredEmpty: !sourceEmpty && mainRowCount === 0
};
}