From 1363c3137e847263b969069f864f4118b902e68d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 3 Oct 2025 07:45:18 +0200 Subject: [PATCH] fix: download speed --- .../hooks/useDownloadEventHandlers.ts | 48 ++++- .../hooks/useDownloadSpeedCalculator.ts | 196 ++++++++++++++++++ utils/downloadStats.ts | 51 +++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 providers/Downloads/hooks/useDownloadSpeedCalculator.ts create mode 100644 utils/downloadStats.ts diff --git a/providers/Downloads/hooks/useDownloadEventHandlers.ts b/providers/Downloads/hooks/useDownloadEventHandlers.ts index c3399b02..9c89fe58 100644 --- a/providers/Downloads/hooks/useDownloadEventHandlers.ts +++ b/providers/Downloads/hooks/useDownloadEventHandlers.ts @@ -26,6 +26,11 @@ import type { TrickPlayData, } from "../types"; import { generateFilename } from "../utils"; +import { + addSpeedDataPoint, + calculateWeightedSpeed, + clearSpeedData, +} from "./useDownloadSpeedCalculator"; interface UseDownloadEventHandlersProps { taskMapRef: MutableRefObject>; @@ -80,6 +85,7 @@ export function useDownloadEventHandlers({ taskId: event.taskId, progress: event.progress, bytesWritten: event.bytesWritten, + totalBytes: event.totalBytes, taskMapSize: taskMapRef.current.size, taskMapKeys: Array.from(taskMapRef.current.keys()), }); @@ -93,19 +99,52 @@ export function useDownloadEventHandlers({ return; } + // Validate event data before processing + if ( + typeof event.bytesWritten !== "number" || + event.bytesWritten < 0 || + !Number.isFinite(event.bytesWritten) + ) { + console.warn( + `[DPL] Invalid bytesWritten for taskId ${event.taskId}: ${event.bytesWritten}`, + ); + return; + } + + if ( + typeof event.progress !== "number" || + event.progress < 0 || + event.progress > 1 || + !Number.isFinite(event.progress) + ) { + console.warn( + `[DPL] Invalid progress for taskId ${event.taskId}: ${event.progress}`, + ); + return; + } + const progress = Math.min( Math.floor(event.progress * 100), 99, // Cap at 99% until completion ); + // Add data point and calculate speed (validation happens inside) + addSpeedDataPoint(processId, event.bytesWritten); + const speed = calculateWeightedSpeed(processId); + console.log( - `[DPL] Progress update for processId: ${processId}, taskId: ${event.taskId}, progress: ${progress}%, bytesWritten: ${event.bytesWritten}`, + `[DPL] Progress update for processId: ${processId}, taskId: ${event.taskId}, progress: ${progress}%, bytesWritten: ${event.bytesWritten}, speed: ${speed ? (speed / 1024 / 1024).toFixed(2) : "N/A"} MB/s`, ); updateProcess(processId, { progress, bytesDownloaded: event.bytesWritten, lastProgressUpdateTime: new Date(), + speed, + estimatedTotalSizeBytes: + event.totalBytes > 0 && Number.isFinite(event.totalBytes) + ? event.totalBytes + : undefined, }); }, ); @@ -197,6 +236,9 @@ export function useDownloadEventHandlers({ onSuccess?.(); + // Clean up speed data when download completes + clearSpeedData(processId); + // Remove process after short delay setTimeout(() => { removeProcess(processId); @@ -204,6 +246,7 @@ export function useDownloadEventHandlers({ } catch (error) { console.error("Error handling download completion:", error); updateProcess(processId, { status: "error" }); + clearSpeedData(processId); removeProcess(processId); } }, @@ -236,6 +279,9 @@ export function useDownloadEventHandlers({ updateProcess(processId, { status: "error" }); + // Clean up speed data + clearSpeedData(processId); + const notificationContent = getNotificationContent( process.item, false, diff --git a/providers/Downloads/hooks/useDownloadSpeedCalculator.ts b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts new file mode 100644 index 00000000..1881c9d3 --- /dev/null +++ b/providers/Downloads/hooks/useDownloadSpeedCalculator.ts @@ -0,0 +1,196 @@ +interface SpeedDataPoint { + timestamp: number; + bytesDownloaded: number; +} + +const WINDOW_DURATION = 60000; // 1 minute in ms +const MIN_DATA_POINTS = 3; // Need at least 3 points for accurate speed +const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check + +// Private state +const dataPoints = new Map(); + +function isValidBytes(bytes: number): boolean { + return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0; +} + +function isValidTimestamp(timestamp: number): boolean { + return ( + typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0 + ); +} + +export function addSpeedDataPoint( + processId: string, + bytesDownloaded: number, +): void { + // Validate input + if (!isValidBytes(bytesDownloaded)) { + console.warn( + `[SpeedCalc] Invalid bytes value for ${processId}: ${bytesDownloaded}`, + ); + return; + } + + const now = Date.now(); + + if (!isValidTimestamp(now)) { + console.warn(`[SpeedCalc] Invalid timestamp: ${now}`); + return; + } + + if (!dataPoints.has(processId)) { + dataPoints.set(processId, []); + } + + const points = dataPoints.get(processId)!; + + // Validate that bytes are increasing (or at least not decreasing) + if (points.length > 0) { + const lastPoint = points[points.length - 1]; + if (bytesDownloaded < lastPoint.bytesDownloaded) { + console.warn( + `[SpeedCalc] Bytes decreased for ${processId}: ${lastPoint.bytesDownloaded} -> ${bytesDownloaded}. Resetting.`, + ); + // Reset the data for this process + dataPoints.set(processId, []); + } + } + + // Add new data point + points.push({ + timestamp: now, + bytesDownloaded, + }); + + // Remove data points older than 1 minute + const cutoffTime = now - WINDOW_DURATION; + while (points.length > 0 && points[0].timestamp < cutoffTime) { + points.shift(); + } +} + +export function calculateSpeed(processId: string): number | undefined { + const points = dataPoints.get(processId); + + if (!points || points.length < MIN_DATA_POINTS) { + return undefined; + } + + const oldest = points[0]; + const newest = points[points.length - 1]; + + // Validate data points + if ( + !isValidBytes(oldest.bytesDownloaded) || + !isValidBytes(newest.bytesDownloaded) || + !isValidTimestamp(oldest.timestamp) || + !isValidTimestamp(newest.timestamp) + ) { + console.warn(`[SpeedCalc] Invalid data points for ${processId}`); + return undefined; + } + + const timeDelta = (newest.timestamp - oldest.timestamp) / 1000; // seconds + const bytesDelta = newest.bytesDownloaded - oldest.bytesDownloaded; + + // Validate calculations + if (timeDelta < 0.5) { + // Not enough time has passed + return undefined; + } + + if (bytesDelta < 0) { + console.warn( + `[SpeedCalc] Negative bytes delta for ${processId}: ${bytesDelta}`, + ); + return undefined; + } + + const speed = bytesDelta / timeDelta; // bytes per second + + // Sanity check: if speed is unrealistically high, something is wrong + if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) { + console.warn(`[SpeedCalc] Unrealistic speed for ${processId}: ${speed}`); + return undefined; + } + + return speed; +} + +// Calculate weighted average speed (more recent data has higher weight) +export function calculateWeightedSpeed(processId: string): number | undefined { + const points = dataPoints.get(processId); + + if (!points || points.length < MIN_DATA_POINTS) { + return undefined; + } + + let totalWeightedSpeed = 0; + let totalWeight = 0; + + // Calculate speed between consecutive points with exponential weighting + for (let i = 1; i < points.length; i++) { + const prevPoint = points[i - 1]; + const currPoint = points[i]; + + // Validate both points + if ( + !isValidBytes(prevPoint.bytesDownloaded) || + !isValidBytes(currPoint.bytesDownloaded) || + !isValidTimestamp(prevPoint.timestamp) || + !isValidTimestamp(currPoint.timestamp) + ) { + continue; + } + + const timeDelta = (currPoint.timestamp - prevPoint.timestamp) / 1000; + const bytesDelta = currPoint.bytesDownloaded - prevPoint.bytesDownloaded; + + // Skip invalid deltas + if (timeDelta < 0.1 || bytesDelta < 0) { + continue; + } + + const speed = bytesDelta / timeDelta; + + // Sanity check + if (!Number.isFinite(speed) || speed < 0 || speed > MAX_REASONABLE_SPEED) { + console.warn(`[SpeedCalc] Skipping unrealistic speed point: ${speed}`); + continue; + } + + // More recent points get exponentially higher weight + const weight = 2 ** i; + totalWeightedSpeed += speed * weight; + totalWeight += weight; + } + + if (totalWeight === 0) { + return undefined; + } + + const weightedSpeed = totalWeightedSpeed / totalWeight; + + // Final sanity check + if (!Number.isFinite(weightedSpeed) || weightedSpeed < 0) { + return undefined; + } + + return weightedSpeed; +} + +export function clearSpeedData(processId: string): void { + dataPoints.delete(processId); +} + +export function resetAllSpeedData(): void { + dataPoints.clear(); +} + +// Debug function to inspect current state +export function getSpeedDataDebug( + processId: string, +): SpeedDataPoint[] | undefined { + return dataPoints.get(processId); +} diff --git a/utils/downloadStats.ts b/utils/downloadStats.ts new file mode 100644 index 00000000..474f74aa --- /dev/null +++ b/utils/downloadStats.ts @@ -0,0 +1,51 @@ +export function formatSpeed(bytesPerSecond: number | undefined): string { + if (!bytesPerSecond || bytesPerSecond <= 0) return "N/A"; + + const mbps = bytesPerSecond / (1024 * 1024); + if (mbps >= 1) { + return `${mbps.toFixed(2)} MB/s`; + } + + const kbps = bytesPerSecond / 1024; + return `${kbps.toFixed(0)} KB/s`; +} + +export function formatETA( + bytesDownloaded: number | undefined, + totalBytes: number | undefined, + speed: number | undefined, +): string { + if (!bytesDownloaded || !totalBytes || !speed || speed <= 0) { + return "Calculating..."; + } + + const remainingBytes = totalBytes - bytesDownloaded; + if (remainingBytes <= 0) return "0s"; + + const secondsRemaining = remainingBytes / speed; + + if (secondsRemaining < 60) { + return `${Math.ceil(secondsRemaining)}s`; + } + if (secondsRemaining < 3600) { + const minutes = Math.floor(secondsRemaining / 60); + const seconds = Math.ceil(secondsRemaining % 60); + return `${minutes}m ${seconds}s`; + } + const hours = Math.floor(secondsRemaining / 3600); + const minutes = Math.floor((secondsRemaining % 3600) / 60); + return `${hours}h ${minutes}m`; +} + +export function calculateETA( + bytesDownloaded: number | undefined, + totalBytes: number | undefined, + speed: number | undefined, +): number | undefined { + if (!bytesDownloaded || !totalBytes || !speed || speed <= 0) { + return undefined; + } + + const remainingBytes = totalBytes - bytesDownloaded; + return remainingBytes / speed; // seconds +}