From 77af7509ace9faab8b50f9cb76daab12fcdd9db7 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 17:41:05 +0200 Subject: [PATCH] feat: implement native HTTP downloader option and enhance download management --- src/locales/en/translation.json | 4 +- src/main/main.ts | 4 +- .../services/download/download-manager.ts | 320 +++++++++++++++--- src/main/services/download/index.ts | 2 + .../services/download/js-http-downloader.ts | 261 ++++++++++++++ .../download/js-multi-link-downloader.ts | 201 +++++++++++ .../src/pages/settings/settings-general.tsx | 15 + src/types/level.types.ts | 1 + 8 files changed, 752 insertions(+), 56 deletions(-) create mode 100644 src/main/services/download/js-http-downloader.ts create mode 100644 src/main/services/download/js-multi-link-downloader.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 87ad52b3..c317a52b 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -594,7 +594,9 @@ "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", - "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup" + "hide_to_tray_on_game_start": "Hide Hydra to tray on game startup", + "downloads": "Downloads", + "use_native_http_downloader": "Use native HTTP downloader (experimental)" }, "notifications": { "download_complete": "Download complete", diff --git a/src/main/main.ts b/src/main/main.ts index 82ea7c47..12f5ea26 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -33,7 +33,9 @@ export const loadState = async () => { await import("./events"); - Aria2.spawn(); + if (!userPreferences?.useNativeHttpDownloader) { + Aria2.spawn(); + } if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index bc6746e2..0e739ea5 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -8,7 +8,6 @@ import { DatanodesApi, MediafireApi, PixelDrainApi, - VikingFileApi, } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { @@ -18,17 +17,24 @@ import { } from "./types"; import { calculateETA, getDirSize } from "./helpers"; import { RealDebridClient } from "./real-debrid"; -import path from "path"; +import path from "node:path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { sortBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; -import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters"; +import { + BuzzheavierApi, + FuckingFastApi, + VikingFileApi, +} from "@main/services/hosters"; +import { JsHttpDownloader } from "./js-http-downloader"; export class DownloadManager { private static downloadingGameId: string | null = null; + private static jsDownloader: JsHttpDownloader | null = null; + private static usingJsDownloader = false; private static extractFilename( url: string, @@ -52,7 +58,7 @@ export class DownloadManager { const urlObj = new URL(url); const pathname = urlObj.pathname; const pathParts = pathname.split("/"); - const filename = pathParts[pathParts.length - 1]; + const filename = pathParts.at(-1); if (filename?.includes(".") && filename.length > 0) { return decodeURIComponent(filename); @@ -99,6 +105,18 @@ export class DownloadManager { }; } + private static async shouldUseJsDownloader(): Promise { + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + return userPreferences?.useNativeHttpDownloader ?? false; + } + + private static isHttpDownloader(downloader: Downloader): boolean { + return downloader !== Downloader.Torrent; + } + public static async startRPC( download?: Download, downloadsToSeed?: Download[] @@ -123,7 +141,50 @@ export class DownloadManager { } } - private static async getDownloadStatus() { + private static async getDownloadStatusFromJs(): Promise { + if (!this.jsDownloader || !this.downloadingGameId) return null; + + const status = this.jsDownloader.getDownloadStatus(); + if (!status) return null; + + const downloadId = this.downloadingGameId; + + try { + const download = await downloadsSublevel.get(downloadId); + if (!download) return null; + + const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } = + status; + + if (status.status === "active" || status.status === "complete") { + await downloadsSublevel.put(downloadId, { + ...download, + bytesDownloaded, + fileSize, + progress, + folderName, + status: status.status === "complete" ? "complete" : "active", + }); + } + + return { + numPeers: 0, + numSeeds: 0, + downloadSpeed, + timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), + isDownloadingMetadata: false, + isCheckingFiles: false, + progress, + gameId: downloadId, + download, + }; + } catch (err) { + logger.error("[DownloadManager] Error getting JS download status:", err); + return null; + } + } + + private static async getDownloadStatusFromRpc(): Promise { const response = await PythonRPC.rpc.get( "/status" ); @@ -151,28 +212,14 @@ export class DownloadManager { if (!isDownloadingMetadata && !isCheckingFiles) { if (!download) return null; - const updatedDownload = { + await downloadsSublevel.put(downloadId, { ...download, bytesDownloaded, fileSize, progress, folderName, - status: "active" as const, - }; - - await downloadsSublevel.put(downloadId, updatedDownload); - - return { - numPeers, - numSeeds, - downloadSpeed, - timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), - isDownloadingMetadata, - isCheckingFiles, - progress, - gameId: downloadId, - download: updatedDownload, - } as DownloadProgress; + status: "active", + }); } return { @@ -186,11 +233,18 @@ export class DownloadManager { gameId: downloadId, download, } as DownloadProgress; - } catch (err) { + } catch { return null; } } + private static async getDownloadStatus(): Promise { + if (this.usingJsDownloader) { + return this.getDownloadStatusFromJs(); + } + return this.getDownloadStatusFromRpc(); + } + public static async watchDownloads() { const status = await this.getDownloadStatus(); @@ -213,7 +267,7 @@ export class DownloadManager { WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.webContents.send( "on-download-progress", - JSON.parse(JSON.stringify({ ...status, game })) + structuredClone({ ...status, game }) ); } @@ -283,6 +337,8 @@ export class DownloadManager { this.resumeDownload(nextItemOnQueue); } else { this.downloadingGameId = null; + this.usingJsDownloader = false; + this.jsDownloader = null; } } } @@ -324,12 +380,17 @@ export class DownloadManager { } static async pauseDownload(downloadKey = this.downloadingGameId) { - await PythonRPC.rpc - .post("/action", { - action: "pause", - game_id: downloadKey, - } as PauseDownloadPayload) - .catch(() => {}); + if (this.usingJsDownloader && this.jsDownloader) { + logger.log("[DownloadManager] Pausing JS download"); + this.jsDownloader.pauseDownload(); + } else { + await PythonRPC.rpc + .post("/action", { + action: "pause", + game_id: downloadKey, + } as PauseDownloadPayload) + .catch(() => {}); + } if (downloadKey === this.downloadingGameId) { WindowManager.mainWindow?.setProgressBar(-1); @@ -342,9 +403,16 @@ export class DownloadManager { } static async cancelDownload(downloadKey = this.downloadingGameId) { - await PythonRPC.rpc - .post("/action", { action: "cancel", game_id: downloadKey }) - .catch((err) => logger.error("Failed to cancel game download", err)); + if (this.usingJsDownloader && this.jsDownloader) { + logger.log("[DownloadManager] Cancelling JS download"); + this.jsDownloader.cancelDownload(); + this.jsDownloader = null; + this.usingJsDownloader = false; + } else { + await PythonRPC.rpc + .post("/action", { action: "cancel", game_id: downloadKey }) + .catch((err) => logger.error("Failed to cancel game download", err)); + } if (downloadKey === this.downloadingGameId) { WindowManager.mainWindow?.setProgressBar(-1); @@ -369,6 +437,135 @@ export class DownloadManager { }); } + private static async getJsDownloadOptions(download: Download): Promise<{ + url: string; + savePath: string; + filename?: string; + headers?: Record; + } | null> { + switch (download.downloader) { + case Downloader.Gofile: { + const id = download.uri.split("/").pop(); + const token = await GofileApi.authorize(); + const downloadLink = await GofileApi.getDownloadLink(id!); + await GofileApi.checkDownloadUrl(downloadLink); + + return { + url: downloadLink, + savePath: download.downloadPath, + headers: { Cookie: `accountToken=${token}` }, + }; + } + case Downloader.PixelDrain: { + const id = download.uri.split("/").pop(); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.Qiwi: { + const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.Datanodes: { + const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.Buzzheavier: { + logger.log( + `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` + ); + const directUrl = await BuzzheavierApi.getDirectLink(download.uri); + const filename = + this.extractFilename(download.uri, directUrl) || + this.extractFilename(directUrl); + + return { + url: directUrl, + savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, + }; + } + case Downloader.FuckingFast: { + logger.log( + `[DownloadManager] Processing FuckingFast download for URI: ${download.uri}` + ); + const directUrl = await FuckingFastApi.getDirectLink(download.uri); + const filename = + this.extractFilename(download.uri, directUrl) || + this.extractFilename(directUrl); + + return { + url: directUrl, + savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, + }; + } + case Downloader.Mediafire: { + const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.RealDebrid: { + const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); + + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.TorBox: { + const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); + if (!url) return null; + + return { + url, + savePath: download.downloadPath, + filename: name, + }; + } + case Downloader.Hydra: { + const downloadUrl = await HydraDebridClient.getDownloadUrl( + download.uri + ); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); + + return { + url: downloadUrl, + savePath: download.downloadPath, + }; + } + case Downloader.VikingFile: { + logger.log( + `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` + ); + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + const filename = + this.extractFilename(download.uri, downloadUrl) || + this.extractFilename(downloadUrl); + + return { + url: downloadUrl, + savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, + }; + } + default: + return null; + } + } + private static async getDownloadPayload(download: Download) { const downloadId = levelKeys.game(download.shop, download.objectId); @@ -518,31 +715,46 @@ export class DownloadManager { logger.log( `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` ); - try { - const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); - logger.log(`[DownloadManager] VikingFile direct URL obtained`); - return { - action: "start", - game_id: downloadId, - url: downloadUrl, - save_path: download.downloadPath, - header: - "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - }; - } catch (error) { - logger.error( - `[DownloadManager] Error processing VikingFile download:`, - error - ); - throw error; - } + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + return this.createDownloadPayload( + downloadUrl, + download.uri, + downloadId, + download.downloadPath + ); } + default: + return undefined; } } static async startDownload(download: Download) { - const payload = await this.getDownloadPayload(download); - await PythonRPC.rpc.post("/action", payload); - this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + const useJsDownloader = await this.shouldUseJsDownloader(); + const isHttp = this.isHttpDownloader(download.downloader); + + if (useJsDownloader && isHttp) { + logger.log("[DownloadManager] Using JS HTTP downloader"); + const options = await this.getJsDownloadOptions(download); + + if (!options) { + throw new Error("Failed to get download options for JS downloader"); + } + + this.jsDownloader = new JsHttpDownloader(); + this.usingJsDownloader = true; + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + + this.jsDownloader.startDownload(options).catch((err) => { + logger.error("[DownloadManager] JS download error:", err); + this.usingJsDownloader = false; + this.jsDownloader = null; + }); + } else { + logger.log("[DownloadManager] Using Python RPC downloader"); + const payload = await this.getDownloadPayload(download); + await PythonRPC.rpc.post("/action", payload); + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + this.usingJsDownloader = false; + } } } diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index f4e2eddc..6a5c3236 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -1,3 +1,5 @@ export * from "./download-manager"; export * from "./real-debrid"; export * from "./torbox"; +export * from "./js-http-downloader"; +export * from "./js-multi-link-downloader"; diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts new file mode 100644 index 00000000..454ef197 --- /dev/null +++ b/src/main/services/download/js-http-downloader.ts @@ -0,0 +1,261 @@ +import fs from "node:fs"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { logger } from "../logger"; + +export interface JsHttpDownloaderStatus { + folderName: string; + fileSize: number; + progress: number; + downloadSpeed: number; + numPeers: number; + numSeeds: number; + status: "active" | "paused" | "complete" | "error"; + bytesDownloaded: number; +} + +export interface JsHttpDownloaderOptions { + url: string; + savePath: string; + filename?: string; + headers?: Record; +} + +export class JsHttpDownloader { + private abortController: AbortController | null = null; + private writeStream: fs.WriteStream | null = null; + private currentOptions: JsHttpDownloaderOptions | null = null; + + private bytesDownloaded = 0; + private fileSize = 0; + private downloadSpeed = 0; + private status: "active" | "paused" | "complete" | "error" = "paused"; + private folderName = ""; + private lastSpeedUpdate = Date.now(); + private bytesAtLastSpeedUpdate = 0; + private isDownloading = false; + + async startDownload(options: JsHttpDownloaderOptions): Promise { + if (this.isDownloading) { + logger.log( + "[JsHttpDownloader] Download already in progress, resuming..." + ); + return this.resumeDownload(); + } + + this.currentOptions = options; + this.abortController = new AbortController(); + this.status = "active"; + this.isDownloading = true; + + const { url, savePath, filename, headers = {} } = options; + + const resolvedFilename = + filename || this.extractFilename(url) || "download"; + this.folderName = resolvedFilename; + const filePath = path.join(savePath, resolvedFilename); + + if (!fs.existsSync(savePath)) { + fs.mkdirSync(savePath, { recursive: true }); + } + + let startByte = 0; + if (fs.existsSync(filePath)) { + const stats = fs.statSync(filePath); + startByte = stats.size; + this.bytesDownloaded = startByte; + logger.log(`[JsHttpDownloader] Resuming download from byte ${startByte}`); + } + + // Reset speed tracking to avoid incorrect speed calculation after resume + this.lastSpeedUpdate = Date.now(); + this.bytesAtLastSpeedUpdate = this.bytesDownloaded; + this.downloadSpeed = 0; + + const requestHeaders: Record = { ...headers }; + if (startByte > 0) { + requestHeaders["Range"] = `bytes=${startByte}-`; + } + + try { + const response = await fetch(url, { + headers: requestHeaders, + signal: this.abortController.signal, + }); + + if (!response.ok && response.status !== 206) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const contentLength = response.headers.get("content-length"); + const contentRange = response.headers.get("content-range"); + + if (contentRange) { + const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); + if (match) { + this.fileSize = parseInt(match[1], 10); + } + } else if (contentLength) { + this.fileSize = startByte + parseInt(contentLength, 10); + } + + if (!response.body) { + throw new Error("Response body is null"); + } + + const flags = startByte > 0 ? "a" : "w"; + this.writeStream = fs.createWriteStream(filePath, { flags }); + + const reader = response.body.getReader(); + const self = this; + + const readableStream = new Readable({ + async read() { + try { + const { done, value } = await reader.read(); + + if (done) { + this.push(null); + return; + } + + self.bytesDownloaded += value.length; + self.updateSpeed(); + this.push(Buffer.from(value)); + } catch (err) { + if ((err as Error).name === "AbortError") { + this.push(null); + } else { + this.destroy(err as Error); + } + } + }, + }); + + await pipeline(readableStream, this.writeStream); + + this.status = "complete"; + this.downloadSpeed = 0; + logger.log("[JsHttpDownloader] Download complete"); + } catch (err) { + if ((err as Error).name === "AbortError") { + logger.log("[JsHttpDownloader] Download aborted"); + this.status = "paused"; + } else { + logger.error("[JsHttpDownloader] Download error:", err); + this.status = "error"; + throw err; + } + } finally { + this.isDownloading = false; + this.cleanup(); + } + } + + private async resumeDownload(): Promise { + if (!this.currentOptions) { + throw new Error("No download options available for resume"); + } + this.isDownloading = false; + await this.startDownload(this.currentOptions); + } + + pauseDownload(): void { + if (this.abortController) { + logger.log("[JsHttpDownloader] Pausing download"); + this.abortController.abort(); + this.status = "paused"; + this.downloadSpeed = 0; + } + } + + cancelDownload(): void { + if (this.abortController) { + logger.log("[JsHttpDownloader] Cancelling download"); + this.abortController.abort(); + } + + this.cleanup(); + + if (this.currentOptions) { + const filePath = path.join(this.currentOptions.savePath, this.folderName); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + logger.log("[JsHttpDownloader] Deleted partial file"); + } catch (err) { + logger.error( + "[JsHttpDownloader] Failed to delete partial file:", + err + ); + } + } + } + + this.reset(); + } + + getDownloadStatus(): JsHttpDownloaderStatus | null { + if (!this.currentOptions && this.status !== "active") { + return null; + } + + return { + folderName: this.folderName, + fileSize: this.fileSize, + progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0, + downloadSpeed: this.downloadSpeed, + numPeers: 0, + numSeeds: 0, + status: this.status, + bytesDownloaded: this.bytesDownloaded, + }; + } + + private updateSpeed(): void { + const now = Date.now(); + const elapsed = (now - this.lastSpeedUpdate) / 1000; + + if (elapsed >= 1) { + const bytesDelta = this.bytesDownloaded - this.bytesAtLastSpeedUpdate; + this.downloadSpeed = bytesDelta / elapsed; + this.lastSpeedUpdate = now; + this.bytesAtLastSpeedUpdate = this.bytesDownloaded; + } + } + + private extractFilename(url: string): string | undefined { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const pathParts = pathname.split("/"); + const filename = pathParts[pathParts.length - 1]; + + if (filename?.includes(".") && filename.length > 0) { + return decodeURIComponent(filename); + } + } catch { + // Invalid URL + } + return undefined; + } + + private cleanup(): void { + if (this.writeStream) { + this.writeStream.close(); + this.writeStream = null; + } + this.abortController = null; + } + + private reset(): void { + this.currentOptions = null; + this.bytesDownloaded = 0; + this.fileSize = 0; + this.downloadSpeed = 0; + this.status = "paused"; + this.folderName = ""; + this.isDownloading = false; + } +} diff --git a/src/main/services/download/js-multi-link-downloader.ts b/src/main/services/download/js-multi-link-downloader.ts new file mode 100644 index 00000000..05d80fd6 --- /dev/null +++ b/src/main/services/download/js-multi-link-downloader.ts @@ -0,0 +1,201 @@ +import { JsHttpDownloader, JsHttpDownloaderStatus } from "./js-http-downloader"; +import { logger } from "../logger"; + +export interface JsMultiLinkDownloaderOptions { + urls: string[]; + savePath: string; + headers?: Record; + totalSize?: number; +} + +interface CompletedDownload { + name: string; + size: number; +} + +export class JsMultiLinkDownloader { + private downloader: JsHttpDownloader | null = null; + private currentOptions: JsMultiLinkDownloaderOptions | null = null; + private currentUrlIndex = 0; + private completedDownloads: CompletedDownload[] = []; + private totalSize: number | null = null; + private isDownloading = false; + private isPaused = false; + + async startDownload(options: JsMultiLinkDownloaderOptions): Promise { + this.currentOptions = options; + this.currentUrlIndex = 0; + this.completedDownloads = []; + this.totalSize = options.totalSize ?? null; + this.isDownloading = true; + this.isPaused = false; + + await this.downloadNextUrl(); + } + + private async downloadNextUrl(): Promise { + if (!this.currentOptions || this.isPaused) { + return; + } + + const { urls, savePath, headers } = this.currentOptions; + + if (this.currentUrlIndex >= urls.length) { + logger.log("[JsMultiLinkDownloader] All downloads complete"); + this.isDownloading = false; + return; + } + + const url = urls[this.currentUrlIndex]; + logger.log( + `[JsMultiLinkDownloader] Starting download ${this.currentUrlIndex + 1}/${urls.length}` + ); + + this.downloader = new JsHttpDownloader(); + + try { + await this.downloader.startDownload({ + url, + savePath, + headers, + }); + + const status = this.downloader.getDownloadStatus(); + if (status && status.status === "complete") { + this.completedDownloads.push({ + name: status.folderName, + size: status.fileSize, + }); + } + + this.currentUrlIndex++; + this.downloader = null; + + if (!this.isPaused) { + await this.downloadNextUrl(); + } + } catch (err) { + logger.error("[JsMultiLinkDownloader] Download error:", err); + throw err; + } + } + + pauseDownload(): void { + logger.log("[JsMultiLinkDownloader] Pausing download"); + this.isPaused = true; + if (this.downloader) { + this.downloader.pauseDownload(); + } + } + + async resumeDownload(): Promise { + if (!this.currentOptions) { + throw new Error("No download options available for resume"); + } + + logger.log("[JsMultiLinkDownloader] Resuming download"); + this.isPaused = false; + this.isDownloading = true; + + if (this.downloader) { + await this.downloader.startDownload({ + url: this.currentOptions.urls[this.currentUrlIndex], + savePath: this.currentOptions.savePath, + headers: this.currentOptions.headers, + }); + + const status = this.downloader.getDownloadStatus(); + if (status && status.status === "complete") { + this.completedDownloads.push({ + name: status.folderName, + size: status.fileSize, + }); + this.currentUrlIndex++; + this.downloader = null; + await this.downloadNextUrl(); + } + } else { + await this.downloadNextUrl(); + } + } + + cancelDownload(): void { + logger.log("[JsMultiLinkDownloader] Cancelling download"); + this.isPaused = true; + this.isDownloading = false; + + if (this.downloader) { + this.downloader.cancelDownload(); + this.downloader = null; + } + + this.reset(); + } + + getDownloadStatus(): JsHttpDownloaderStatus | null { + if (!this.currentOptions && this.completedDownloads.length === 0) { + return null; + } + + let totalBytesDownloaded = 0; + let currentDownloadSpeed = 0; + let currentFolderName = ""; + let currentStatus: "active" | "paused" | "complete" | "error" = "active"; + + for (const completed of this.completedDownloads) { + totalBytesDownloaded += completed.size; + } + + if (this.downloader) { + const status = this.downloader.getDownloadStatus(); + if (status) { + totalBytesDownloaded += status.bytesDownloaded; + currentDownloadSpeed = status.downloadSpeed; + currentFolderName = status.folderName; + currentStatus = status.status; + } + } else if (this.completedDownloads.length > 0) { + currentFolderName = this.completedDownloads[0].name; + } + + if (currentFolderName?.includes("/")) { + currentFolderName = currentFolderName.split("/")[0]; + } + + const totalFileSize = + this.totalSize || + this.completedDownloads.reduce((sum, d) => sum + d.size, 0) + + (this.downloader?.getDownloadStatus()?.fileSize || 0); + + const allComplete = + !this.isDownloading && + this.currentOptions && + this.currentUrlIndex >= this.currentOptions.urls.length; + + if (allComplete) { + currentStatus = "complete"; + } else if (this.isPaused) { + currentStatus = "paused"; + } + + return { + folderName: currentFolderName, + fileSize: totalFileSize, + progress: totalFileSize > 0 ? totalBytesDownloaded / totalFileSize : 0, + downloadSpeed: currentDownloadSpeed, + numPeers: 0, + numSeeds: 0, + status: currentStatus, + bytesDownloaded: totalBytesDownloaded, + }; + } + + private reset(): void { + this.currentOptions = null; + this.currentUrlIndex = 0; + this.completedDownloads = []; + this.totalSize = null; + this.isDownloading = false; + this.isPaused = false; + } +} diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index c81ced7d..466cfc98 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -53,6 +53,7 @@ export function SettingsGeneral() { achievementSoundVolume: 15, language: "", customStyles: window.localStorage.getItem("customStyles") || "", + useNativeHttpDownloader: false, }); const [languageOptions, setLanguageOptions] = useState([]); @@ -131,6 +132,8 @@ export function SettingsGeneral() { friendStartGameNotificationsEnabled: userPreferences.friendStartGameNotificationsEnabled ?? true, language: language ?? "en", + useNativeHttpDownloader: + userPreferences.useNativeHttpDownloader ?? false, })); } }, [userPreferences, defaultDownloadsPath]); @@ -248,6 +251,18 @@ export function SettingsGeneral() { }))} /> +

{t("downloads")}

+ + + handleChange({ + useNativeHttpDownloader: !form.useNativeHttpDownloader, + }) + } + /> +

{t("notifications")}