import type { 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; items: Record; displayMode: DownloadDisplayMode; filter: DownloadSidebarFilter; providerFilter: string; query: string; collapsedPackageIds: Iterable; selectedIds: Iterable; 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; 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(["downloading", "validating", "extracting", "integrity_check"]); const queuedStatuses = new Set(["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): 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): 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): number { let count = 0; for (const item of items) { if (isPendingDownloadItem(item)) count += 1; } return count; } export function getRemainingDownloadBytes(items: Iterable): { 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 }; } export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string { const value = summary.bytes >= 1024 ** 4 ? `${(summary.bytes / 1024 ** 4).toFixed(4)} TB` : 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): 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, items: Record ): string[] { const errorItemIds = new Set(); 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, 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): 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(); 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 => 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 }; }