From d47ed3f5fece3dbe633a9e4db4055b55a06219a0 Mon Sep 17 00:00:00 2001 From: jarvis2f <137974272+jarvis2f@users.noreply.github.com> Date: Wed, 25 Dec 2024 14:27:30 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Added=20AvgSpeed=20class=20?= =?UTF-8?q?to=20handle=20download=20speed=20calculations=20and=20statistic?= =?UTF-8?q?s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/telegram/files/AvgSpeed.java | 187 ++++++++++++++++++ .../java/telegram/files/TelegramVerticle.java | 5 + .../java/telegram/files/AvgSpeedTest.java | 164 +++++++++++++++ web/src/components/file-statistics.tsx | 72 ++++++- 4 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/telegram/files/AvgSpeed.java create mode 100644 api/src/test/java/telegram/files/AvgSpeedTest.java diff --git a/api/src/main/java/telegram/files/AvgSpeed.java b/api/src/main/java/telegram/files/AvgSpeed.java new file mode 100644 index 0000000..331df2e --- /dev/null +++ b/api/src/main/java/telegram/files/AvgSpeed.java @@ -0,0 +1,187 @@ +package telegram.files; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class AvgSpeed { + private final int interval; + + private final TreeMap speedPoints; + + private long maxSpeed; + + private long minSpeed; + + private final int smoothingWindowSize; + + /** + * @param speed Speed since last point + */ + record SpeedPoint(long totalSize, long downloadedSize, long speed) { + } + + public AvgSpeed() { + this(15 * 60); // Default interval is 15 minutes + } + + public AvgSpeed(int interval) { + this(interval, 5); + } + + public AvgSpeed(int interval, int smoothingWindowSize) { + this.interval = interval; + this.speedPoints = new TreeMap<>(); + this.maxSpeed = 0; + this.minSpeed = Long.MAX_VALUE; + this.smoothingWindowSize = smoothingWindowSize; + } + + /** + * Update download progress + */ + public void update(long totalSize, long downloadedSize, long timestamp) { + // Calculate speed since last point + long speed = calculateSpeed(downloadedSize, timestamp); + + // Apply smoothing if we have enough points + if (speedPoints.size() >= smoothingWindowSize) { + speed = smoothSpeed(speed); + } + + // Update max/min speeds only for non-zero speeds + if (speed > 0) { + maxSpeed = Math.max(maxSpeed, speed); + minSpeed = Math.min(minSpeed, speed); + } + + // Add new speed point + speedPoints.put(timestamp, new SpeedPoint(totalSize, downloadedSize, speed)); + + // Remove old points outside the interval + long cutoffTime = timestamp - interval * 1000L; // Convert interval to milliseconds + speedPoints.headMap(cutoffTime).clear(); + } + + private long calculateSpeed(long downloadedSize, long timestamp) { + if (speedPoints.isEmpty()) { + return 0; + } + + Map.Entry lastEntry = speedPoints.lastEntry(); + long timeDiff = timestamp - lastEntry.getKey(); + if (timeDiff <= 0) { + return 0; + } + + long bytesDiff = downloadedSize - lastEntry.getValue().downloadedSize; + if (bytesDiff < 0) { + // Handle download restart + bytesDiff = downloadedSize; + } + + return (bytesDiff * 1000L) / timeDiff; // Speed in bytes per second + } + + private long smoothSpeed(long currentSpeed) { + List recentSpeeds = speedPoints.values().stream() + .skip(Math.max(0, speedPoints.size() - smoothingWindowSize)) + .map(point -> point.speed) + .collect(Collectors.toList()); + + recentSpeeds.add(currentSpeed); + + // Sort speeds to find median + Collections.sort(recentSpeeds); + long median = recentSpeeds.get(recentSpeeds.size() / 2); + + // Filter out speeds that deviate too much from median + double threshold = median * 2.0; + List filteredSpeeds = recentSpeeds.stream() + .filter(speed -> speed <= threshold) + .toList(); + + // Calculate average of remaining speeds + return (long) filteredSpeeds.stream() + .mapToLong(Long::longValue) + .average() + .orElse(currentSpeed); + } + + /** + * Get average speed in bytes per second for last interval + */ + public long getSpeed() { + if (speedPoints.size() < 2) { + return 0; + } + + Map.Entry firstEntry = speedPoints.firstEntry(); + Map.Entry lastEntry = speedPoints.lastEntry(); + + long timeDiff = lastEntry.getKey() - firstEntry.getKey(); + if (timeDiff <= 0) { + return 0; + } + + long bytesDownloaded = lastEntry.getValue().downloadedSize - firstEntry.getValue().downloadedSize; + if (bytesDownloaded < 0) { + bytesDownloaded = lastEntry.getValue().downloadedSize; + } + + return (bytesDownloaded * 1000L) / timeDiff; + } + + /** + * Get median speed from all recorded points + */ + public long getMedianSpeed() { + if (speedPoints.size() < 2) { + return 0; + } + + List speeds = speedPoints.values().stream() + .map(point -> point.speed) + .filter(speed -> speed > 0) + .sorted() + .toList(); + + if (speeds.isEmpty()) { + return 0; + } + + return speeds.get(speeds.size() / 2); + } + + /** + * Get maximum recorded speed + */ + public long getMaxSpeed() { + return maxSpeed; + } + + /** + * Get minimum recorded speed + */ + public long getMinSpeed() { + return minSpeed == Long.MAX_VALUE ? 0 : minSpeed; + } + + /** + * Get speed statistics summary + */ + public SpeedStats getSpeedStats() { + return new SpeedStats( + interval, + getSpeed(), + getMedianSpeed(), + getMaxSpeed(), + getMinSpeed() + ); + } + + public record SpeedStats(int interval, long avgSpeed, long medianSpeed, long maxSpeed, long minSpeed) { + } +} diff --git a/api/src/main/java/telegram/files/TelegramVerticle.java b/api/src/main/java/telegram/files/TelegramVerticle.java index 7c189c3..d48e4dc 100644 --- a/api/src/main/java/telegram/files/TelegramVerticle.java +++ b/api/src/main/java/telegram/files/TelegramVerticle.java @@ -52,6 +52,8 @@ public class TelegramVerticle extends AbstractVerticle { public TelegramRecord telegramRecord; + private final AvgSpeed avgSpeed = new AvgSpeed(); + static { Client.setLogMessageHandler(0, new LogMessageHandler()); @@ -429,6 +431,8 @@ public class TelegramVerticle extends AbstractVerticle { .put("sentBytes", bytes.v1) .put("receivedBytes", bytes.v2) ); + + jsonObject.put("speedStats", avgSpeed.getSpeedStats()); return jsonObject; }); } @@ -676,6 +680,7 @@ public class TelegramVerticle extends AbstractVerticle { private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) { log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads)); + avgSpeed.update(updateFileDownloads.totalSize, updateFileDownloads.downloadedSize, System.currentTimeMillis()); sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads)); } diff --git a/api/src/test/java/telegram/files/AvgSpeedTest.java b/api/src/test/java/telegram/files/AvgSpeedTest.java new file mode 100644 index 0000000..a930f2d --- /dev/null +++ b/api/src/test/java/telegram/files/AvgSpeedTest.java @@ -0,0 +1,164 @@ +package telegram.files; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AvgSpeedTest { + private AvgSpeed avgSpeed; + + private static final int TEST_INTERVAL = 100; // 100 seconds for testing + + private static final int SMOOTHING_WINDOW = 3; + + @BeforeEach + void setUp() { + avgSpeed = new AvgSpeed(TEST_INTERVAL, SMOOTHING_WINDOW); + } + + @Test + void testInitialState() { + assertEquals(0, avgSpeed.getSpeed()); + assertEquals(0, avgSpeed.getMedianSpeed()); + assertEquals(0, avgSpeed.getMaxSpeed()); + assertEquals(0, avgSpeed.getMinSpeed()); + } + + @Test + void testConstantSpeed() { + // Simulate constant download speed of 100 bytes per second + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec for 10 seconds + avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec for another 10 seconds + + AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats(); + assertEquals(100, stats.avgSpeed(), 1.0, "Average speed should be 100 bytes/sec"); + } + + @Test + void testSpeedWithPause() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + // Download at 100 bytes/sec + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec + + // Pause for 10 seconds + avgSpeed.update(totalSize, 1000L, baseTime + 20000); + + // Resume at 100 bytes/sec + avgSpeed.update(totalSize, 2000L, baseTime + 30000); + + // Average speed should be: 2000 bytes / 30 seconds = 66.67 bytes/sec + assertEquals(67, avgSpeed.getSpeed(), 1.0, + "Speed should account for pause period"); + } + + @Test + void testDownloadRestart() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + // Initial download + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec + + // Restart download + avgSpeed.update(totalSize, 0L, baseTime + 15000); + avgSpeed.update(totalSize, 500L, baseTime + 20000); // 100 bytes/sec after restart + + AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats(); + assertTrue(stats.maxSpeed() >= 100, + "Max speed should capture the highest speed segment"); + } + + @Test + void testSpeedFluctuation() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 500L, baseTime + 5000); // 100 bytes/sec + avgSpeed.update(totalSize, 2500L, baseTime + 10000); // 400 bytes/sec + avgSpeed.update(totalSize, 3000L, baseTime + 15000); // 100 bytes/sec + + AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats(); + assertTrue(stats.maxSpeed() >= 300, + "Max speed should capture high speed period"); + assertTrue(stats.minSpeed() <= 100, + "Min speed should capture low speed period"); + } + + @Test + void testDataSmoothing() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec + avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec + avgSpeed.update(totalSize, 7000L, baseTime + 30000); // 500 bytes/sec (spike) + avgSpeed.update(totalSize, 8000L, baseTime + 40000); // 100 bytes/sec + + AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats(); + assertTrue(stats.avgSpeed() < 300, + "Smoothed speed should be less than the spike value"); + } + + @Test + void testOldDataRemoval() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + // Add initial points + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec + + // Add point beyond interval + // Note: Need to move forward by interval milliseconds plus a buffer + avgSpeed.update(totalSize, 2000L, baseTime + (TEST_INTERVAL * 1000L) + 20000); + + long speed = avgSpeed.getSpeed(); + assertTrue(speed < 50, + "Speed should be very low when most points are outside the interval"); + } + + @Test + void testMedianCalculation() { + long baseTime = System.currentTimeMillis(); + long totalSize = 1000; + + // Create a sequence of very distinct speeds for clear median + avgSpeed.update(totalSize, 0L, baseTime); + avgSpeed.update(totalSize, 1000L, baseTime + 5000); // 200 bytes/sec + avgSpeed.update(totalSize, 1500L, baseTime + 10000); // 100 bytes/sec + avgSpeed.update(totalSize, 3500L, baseTime + 15000); // 400 bytes/sec + + long medianSpeed = avgSpeed.getMedianSpeed(); + assertTrue(medianSpeed >= 100 && medianSpeed <= 400, + "Median speed should be between the lowest and highest speeds"); + } + + @Test + void testExtremeCases() { + long baseTime = System.currentTimeMillis(); + + // Test with zero time difference + avgSpeed.update(1000L, 0L, baseTime); + avgSpeed.update(1000L, 100L, baseTime); + assertEquals(0, avgSpeed.getSpeed(), + "Speed should be 0 when time difference is 0"); + + // Test with large downloads + avgSpeed.update(1000000L, 0L, baseTime + 1000); + avgSpeed.update(1000000L, 500000L, baseTime + 6000); // 100,000 bytes/sec + assertTrue(avgSpeed.getSpeed() > 0, + "Speed should be calculated correctly with large numbers"); + } +} diff --git a/web/src/components/file-statistics.tsx b/web/src/components/file-statistics.tsx index a560d86..ac1b775 100644 --- a/web/src/components/file-statistics.tsx +++ b/web/src/components/file-statistics.tsx @@ -2,12 +2,16 @@ import React from "react"; import useSWR from "swr"; import { AlertTriangle, + ArrowDown, + ArrowUp, CheckCircle, + Clock, CloudDownload, Download, File, FileText, Image, + LineChart, LoaderPinwheel, Music, Network, @@ -16,11 +20,11 @@ import { Video, } from "lucide-react"; import { request, telegramApi, type TelegramApiArg } from "@/lib/api"; -import prettyBytes from "pretty-bytes"; import { formatDistanceToNow } from "date-fns"; import { Button } from "@/components/ui/button"; import useSWRMutation from "swr/mutation"; import type { TelegramApiResult } from "@/lib/types"; // Define a fetcher function to handle the API request +import prettyBytes from "pretty-bytes"; // Interface defining the structure of the data returned from the API interface StatisticsData { @@ -38,6 +42,13 @@ interface StatisticsData { sentBytes: number; receivedBytes: number; }; + speedStats: { + interval: number; + avgSpeed: number; + maxSpeed: number; + medianSpeed: number; + minSpeed: number; + }; } // Props interface for the component, expecting a telegramId as input @@ -124,6 +135,37 @@ const FileStatistics: React.FC = ({ telegramId }) => { }, ]; + const avgStatFields = [ + { + label: "Avg", + value: prettyBytes(data.speedStats.avgSpeed, { bits: true }) + "/s", + icon: PauseCircle, + color: "text-blue-500", + bgColor: "bg-blue-100", + }, + { + label: "Max", + value: prettyBytes(data.speedStats.maxSpeed, { bits: true }) + "/s", + icon: ArrowUp, + color: "text-green-500", + bgColor: "bg-green-100", + }, + { + label: "Median", + value: prettyBytes(data.speedStats.medianSpeed, { bits: true }) + "/s", + icon: LineChart, + color: "text-purple-500", + bgColor: "bg-purple-100", + }, + { + label: "Min", + value: prettyBytes(data.speedStats.minSpeed, { bits: true }) + "/s", + icon: ArrowDown, + color: "text-red-500", + bgColor: "bg-red-100", + }, + ]; + return (
@@ -182,6 +224,34 @@ const FileStatistics: React.FC = ({ telegramId }) => {
+
+
+

+ + Speed Statistics + ({data.speedStats.interval / 60} minute interval) +

+
+
+ {avgStatFields.map((stat, index) => ( +
+
+
+ +
+ + {stat.label} + +
+
{stat.value}
+
+ ))} +
+
+