mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 21:18:31 +01:00
fix: speed calculation
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
@@ -62,16 +63,23 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
const eta = useMemo(() => {
|
||||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
const secondsRemaining = calculateSmoothedETA(
|
||||||
if (bytesRemaining <= 0) return null;
|
process.id,
|
||||||
|
process.bytesDownloaded,
|
||||||
|
process.estimatedTotalSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
const secondsRemaining = bytesRemaining / p.speed;
|
if (!secondsRemaining || secondsRemaining <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
return formatTimeString(secondsRemaining, "s");
|
||||||
};
|
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.Id!);
|
return storage.getString(process.item.Id!);
|
||||||
@@ -161,9 +169,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{eta && (
|
||||||
<Text className='text-xs'>
|
<Text className='text-xs'>
|
||||||
{t("home.downloads.eta", { eta: eta(process) })}
|
{t("home.downloads.eta", { eta: eta })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
|
|||||||
init(module: BackgroundDownloaderModule) {
|
init(module: BackgroundDownloaderModule) {
|
||||||
self.module = module
|
self.module = module
|
||||||
super.init()
|
super.init()
|
||||||
|
print("[DownloadSessionDelegate] Delegate initialized with module: \(String(describing: module))")
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
@@ -141,10 +142,73 @@ public class BackgroundDownloaderModule: Module {
|
|||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
session.getAllTasks { tasks in
|
session.getAllTasks { tasks in
|
||||||
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||||
print("[BackgroundDownloader] Task state after 0.5s: \(self.taskStateString(downloadTask.state))")
|
print("[BackgroundDownloader] === 0.5s CHECK ===")
|
||||||
|
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||||
if let response = downloadTask.response as? HTTPURLResponse {
|
if let response = downloadTask.response as? HTTPURLResponse {
|
||||||
print("[BackgroundDownloader] Response status: \(response.statusCode)")
|
print("[BackgroundDownloader] Response status: \(response.statusCode)")
|
||||||
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
|
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] No HTTP response yet after 0.5s")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] Task not found after 0.5s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional diagnostics at 1s, 2s, and 3s
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
session.getAllTasks { tasks in
|
||||||
|
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||||
|
print("[BackgroundDownloader] === 1s CHECK ===")
|
||||||
|
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||||
|
print("[BackgroundDownloader] Task error: \(String(describing: downloadTask.error))")
|
||||||
|
print("[BackgroundDownloader] Current request URL: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil")")
|
||||||
|
print("[BackgroundDownloader] Original request URL: \(downloadTask.originalRequest?.url?.absoluteString ?? "nil")")
|
||||||
|
|
||||||
|
if let response = downloadTask.response as? HTTPURLResponse {
|
||||||
|
print("[BackgroundDownloader] HTTP Status: \(response.statusCode)")
|
||||||
|
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
|
||||||
|
print("[BackgroundDownloader] All headers: \(response.allHeaderFields)")
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] ⚠️ STILL NO HTTP RESPONSE after 1s")
|
||||||
|
}
|
||||||
|
|
||||||
|
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||||
|
if countOfBytesReceived > 0 {
|
||||||
|
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] ⚠️ NO BYTES RECEIVED YET")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] ⚠️ Task disappeared after 1s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
|
session.getAllTasks { tasks in
|
||||||
|
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||||
|
print("[BackgroundDownloader] === 2s CHECK ===")
|
||||||
|
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||||
|
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||||
|
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||||
|
if downloadTask.error != nil {
|
||||||
|
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||||
|
session.getAllTasks { tasks in
|
||||||
|
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||||
|
print("[BackgroundDownloader] === 3s CHECK ===")
|
||||||
|
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||||
|
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||||
|
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||||
|
if downloadTask.error != nil {
|
||||||
|
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,17 +237,20 @@ public class BackgroundDownloaderModule: Module {
|
|||||||
|
|
||||||
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
|
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let downloadTasks = self.downloadTasks
|
||||||
|
let taskStateString = self.taskStateString
|
||||||
|
|
||||||
self.session?.getAllTasks { tasks in
|
self.session?.getAllTasks { tasks in
|
||||||
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
|
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
|
||||||
guard task is URLSessionDownloadTask,
|
guard task is URLSessionDownloadTask,
|
||||||
let info = self.downloadTasks[task.taskIdentifier] else {
|
let info = downloadTasks[task.taskIdentifier] else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"taskId": task.taskIdentifier,
|
"taskId": task.taskIdentifier,
|
||||||
"url": info.url,
|
"url": info.url,
|
||||||
"state": self.taskStateString(task.state)
|
"state": taskStateString(task.state)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
continuation.resume(returning: activeDownloads)
|
continuation.resume(returning: activeDownloads)
|
||||||
@@ -210,6 +277,15 @@ public class BackgroundDownloaderModule: Module {
|
|||||||
)
|
)
|
||||||
|
|
||||||
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
|
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
|
||||||
|
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
|
||||||
|
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
|
||||||
|
|
||||||
|
// Verify delegate is connected
|
||||||
|
if let session = self.session, session.delegate != nil {
|
||||||
|
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
|
||||||
|
} else {
|
||||||
|
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func taskStateString(_ state: URLSessionTask.State) -> String {
|
private func taskStateString(_ state: URLSessionTask.State) -> String {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ interface SpeedDataPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WINDOW_DURATION = 60000; // 1 minute in ms
|
const WINDOW_DURATION = 60000; // 1 minute in ms
|
||||||
const MIN_DATA_POINTS = 3; // Need at least 3 points for accurate speed
|
const MIN_DATA_POINTS = 5; // Need at least 5 points for accurate speed
|
||||||
const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check
|
const MAX_REASONABLE_SPEED = 1024 * 1024 * 1024; // 1 GB/s sanity check
|
||||||
|
const EMA_ALPHA = 0.2; // Smoothing factor for EMA (lower = smoother, 0-1 range)
|
||||||
|
|
||||||
// Private state
|
// Private state
|
||||||
const dataPoints = new Map<string, SpeedDataPoint[]>();
|
const dataPoints = new Map<string, SpeedDataPoint[]>();
|
||||||
|
const emaSpeed = new Map<string, number>(); // Store EMA speed for each process
|
||||||
|
|
||||||
function isValidBytes(bytes: number): boolean {
|
function isValidBytes(bytes: number): boolean {
|
||||||
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0;
|
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0;
|
||||||
@@ -161,7 +163,8 @@ export function calculateWeightedSpeed(processId: string): number | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// More recent points get exponentially higher weight
|
// More recent points get exponentially higher weight
|
||||||
const weight = 2 ** i;
|
// Using 1.3 instead of 2 for gentler weighting (less sensitive to recent changes)
|
||||||
|
const weight = 1.3 ** i;
|
||||||
totalWeightedSpeed += speed * weight;
|
totalWeightedSpeed += speed * weight;
|
||||||
totalWeight += weight;
|
totalWeight += weight;
|
||||||
}
|
}
|
||||||
@@ -180,12 +183,74 @@ export function calculateWeightedSpeed(processId: string): number | undefined {
|
|||||||
return weightedSpeed;
|
return weightedSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate ETA in seconds
|
||||||
|
export function calculateETA(
|
||||||
|
processId: string,
|
||||||
|
bytesDownloaded: number,
|
||||||
|
totalBytes: number,
|
||||||
|
): number | undefined {
|
||||||
|
const speed = calculateWeightedSpeed(processId);
|
||||||
|
|
||||||
|
if (!speed || speed <= 0 || !totalBytes || totalBytes <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesRemaining = totalBytes - bytesDownloaded;
|
||||||
|
if (bytesRemaining <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsRemaining = bytesRemaining / speed;
|
||||||
|
|
||||||
|
// Sanity check
|
||||||
|
if (!Number.isFinite(secondsRemaining) || secondsRemaining < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return secondsRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate smoothed ETA using Exponential Moving Average (EMA)
|
||||||
|
// This provides much smoother ETA estimates, reducing jumpy time estimates
|
||||||
|
const emaETA = new Map<string, number>();
|
||||||
|
|
||||||
|
export function calculateSmoothedETA(
|
||||||
|
processId: string,
|
||||||
|
bytesDownloaded: number,
|
||||||
|
totalBytes: number,
|
||||||
|
): number | undefined {
|
||||||
|
const currentETA = calculateETA(processId, bytesDownloaded, totalBytes);
|
||||||
|
|
||||||
|
if (currentETA === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousEma = emaETA.get(processId);
|
||||||
|
|
||||||
|
if (previousEma === undefined) {
|
||||||
|
// First calculation, initialize with current ETA
|
||||||
|
emaETA.set(processId, currentETA);
|
||||||
|
return currentETA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EMA formula: EMA(t) = α * current + (1 - α) * EMA(t-1)
|
||||||
|
// Lower alpha = smoother but slower to respond
|
||||||
|
const smoothed = EMA_ALPHA * currentETA + (1 - EMA_ALPHA) * previousEma;
|
||||||
|
|
||||||
|
emaETA.set(processId, smoothed);
|
||||||
|
return smoothed;
|
||||||
|
}
|
||||||
|
|
||||||
export function clearSpeedData(processId: string): void {
|
export function clearSpeedData(processId: string): void {
|
||||||
dataPoints.delete(processId);
|
dataPoints.delete(processId);
|
||||||
|
emaSpeed.delete(processId);
|
||||||
|
emaETA.delete(processId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAllSpeedData(): void {
|
export function resetAllSpeedData(): void {
|
||||||
dataPoints.clear();
|
dataPoints.clear();
|
||||||
|
emaSpeed.clear();
|
||||||
|
emaETA.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug function to inspect current state
|
// Debug function to inspect current state
|
||||||
|
|||||||
Reference in New Issue
Block a user