From 77af7509ace9faab8b50f9cb76daab12fcdd9db7 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 17:41:05 +0200 Subject: [PATCH 01/20] 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")}

Date: Tue, 6 Jan 2026 17:42:42 +0200 Subject: [PATCH 02/20] refactor: optimize chunk handling in JsHttpDownloader --- src/main/services/download/js-http-downloader.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 454ef197..4122cf34 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -108,7 +108,10 @@ export class JsHttpDownloader { this.writeStream = fs.createWriteStream(filePath, { flags }); const reader = response.body.getReader(); - const self = this; + const onChunk = (length: number) => { + this.bytesDownloaded += length; + this.updateSpeed(); + }; const readableStream = new Readable({ async read() { @@ -120,8 +123,7 @@ export class JsHttpDownloader { return; } - self.bytesDownloaded += value.length; - self.updateSpeed(); + onChunk(value.length); this.push(Buffer.from(value)); } catch (err) { if ((err as Error).name === "AbortError") { From 569700e85c5a16e078ecd1b2c7647ae8612f0d5b Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 17:47:12 +0200 Subject: [PATCH 03/20] refactor: streamline download status updates in DownloadManager --- .../services/download/download-manager.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 0e739ea5..497bc326 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -156,15 +156,20 @@ export class DownloadManager { const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } = status; + const updatedDownload = { + ...download, + bytesDownloaded, + fileSize, + progress, + folderName, + status: + status.status === "complete" + ? ("complete" as const) + : ("active" as const), + }; + if (status.status === "active" || status.status === "complete") { - await downloadsSublevel.put(downloadId, { - ...download, - bytesDownloaded, - fileSize, - progress, - folderName, - status: status.status === "complete" ? "complete" : "active", - }); + await downloadsSublevel.put(downloadId, updatedDownload); } return { @@ -176,7 +181,7 @@ export class DownloadManager { isCheckingFiles: false, progress, gameId: downloadId, - download, + download: updatedDownload, }; } catch (err) { logger.error("[DownloadManager] Error getting JS download status:", err); From 8f477072ba227348e4b76914b3c4bc1982bef522 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 17:56:46 +0200 Subject: [PATCH 04/20] refactor: improve error handling and download path preparation in JsHttpDownloader --- .../services/download/download-manager.ts | 16 +- .../services/download/js-http-downloader.ts | 165 +++++++++++------- 2 files changed, 113 insertions(+), 68 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 497bc326..8573e116 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -450,9 +450,9 @@ export class DownloadManager { } | null> { switch (download.downloader) { case Downloader.Gofile: { - const id = download.uri.split("/").pop(); + const id = download.uri.split("/").pop()!; const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id!); + const downloadLink = await GofileApi.getDownloadLink(id); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -462,8 +462,8 @@ export class DownloadManager { }; } case Downloader.PixelDrain: { - const id = download.uri.split("/").pop(); - const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + const id = download.uri.split("/").pop()!; + const downloadUrl = await PixelDrainApi.getDownloadUrl(id); return { url: downloadUrl, @@ -576,9 +576,9 @@ export class DownloadManager { switch (download.downloader) { case Downloader.Gofile: { - const id = download.uri.split("/").pop(); + const id = download.uri.split("/").pop()!; const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id!); + const downloadLink = await GofileApi.getDownloadLink(id); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -592,8 +592,8 @@ export class DownloadManager { }; } case Downloader.PixelDrain: { - const id = download.uri.split("/").pop(); - const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + const id = download.uri.split("/").pop()!; + const downloadUrl = await PixelDrainApi.getDownloadUrl(id); return { action: "start", diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 4122cf34..f4769e6a 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -50,7 +50,28 @@ export class JsHttpDownloader { this.isDownloading = true; const { url, savePath, filename, headers = {} } = options; + const { filePath, startByte } = this.prepareDownloadPath( + savePath, + filename, + url + ); + const requestHeaders = this.buildRequestHeaders(headers, startByte); + try { + await this.executeDownload(url, requestHeaders, filePath, startByte); + } catch (err) { + this.handleDownloadError(err as Error); + } finally { + this.isDownloading = false; + this.cleanup(); + } + } + + private prepareDownloadPath( + savePath: string, + filename: string | undefined, + url: string + ): { filePath: string; startByte: number } { const resolvedFilename = filename || this.extractFilename(url) || "download"; this.folderName = resolvedFilename; @@ -68,90 +89,114 @@ export class JsHttpDownloader { 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; + this.resetSpeedTracking(); + return { filePath, startByte }; + } + private buildRequestHeaders( + headers: Record, + startByte: number + ): Record { const requestHeaders: Record = { ...headers }; if (startByte > 0) { requestHeaders["Range"] = `bytes=${startByte}-`; } + return requestHeaders; + } - try { - const response = await fetch(url, { - headers: requestHeaders, - signal: this.abortController.signal, - }); + private resetSpeedTracking(): void { + this.lastSpeedUpdate = Date.now(); + this.bytesAtLastSpeedUpdate = this.bytesDownloaded; + this.downloadSpeed = 0; + } - if (!response.ok && response.status !== 206) { - throw new Error(`HTTP error! status: ${response.status}`); + private parseFileSize(response: Response, startByte: number): void { + const contentRange = response.headers.get("content-range"); + if (contentRange) { + const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange); + if (match) { + this.fileSize = Number.parseInt(match[1], 10); } + return; + } - const contentLength = response.headers.get("content-length"); - const contentRange = response.headers.get("content-range"); + const contentLength = response.headers.get("content-length"); + if (contentLength) { + this.fileSize = startByte + Number.parseInt(contentLength, 10); + } + } - 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); - } + private async executeDownload( + url: string, + requestHeaders: Record, + filePath: string, + startByte: number + ): Promise { + const response = await fetch(url, { + headers: requestHeaders, + signal: this.abortController!.signal, + }); - if (!response.body) { - throw new Error("Response body is null"); - } + if (!response.ok && response.status !== 206) { + throw new Error(`HTTP error! status: ${response.status}`); + } - const flags = startByte > 0 ? "a" : "w"; - this.writeStream = fs.createWriteStream(filePath, { flags }); + this.parseFileSize(response, startByte); - const reader = response.body.getReader(); - const onChunk = (length: number) => { - this.bytesDownloaded += length; - this.updateSpeed(); - }; + if (!response.body) { + throw new Error("Response body is null"); + } - const readableStream = new Readable({ - async read() { - try { - const { done, value } = await reader.read(); + const flags = startByte > 0 ? "a" : "w"; + this.writeStream = fs.createWriteStream(filePath, { flags }); + const readableStream = this.createReadableStream(response.body.getReader()); + await pipeline(readableStream, this.writeStream); + + this.status = "complete"; + this.downloadSpeed = 0; + logger.log("[JsHttpDownloader] Download complete"); + } + + private createReadableStream( + reader: ReadableStreamDefaultReader + ): Readable { + const onChunk = (length: number) => { + this.bytesDownloaded += length; + this.updateSpeed(); + }; + + return new Readable({ + read() { + reader + .read() + .then(({ done, value }) => { if (done) { this.push(null); return; } - onChunk(value.length); this.push(Buffer.from(value)); - } catch (err) { - if ((err as Error).name === "AbortError") { + }) + .catch((err: Error) => { + if (err.name === "AbortError") { this.push(null); } else { - this.destroy(err as Error); + this.destroy(err); } - } - }, - }); + }); + }, + }); + } - 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 handleDownloadError(err: Error): void { + if (err.name === "AbortError") { + logger.log("[JsHttpDownloader] Download aborted"); + this.status = "paused"; + } else { + logger.error("[JsHttpDownloader] Download error:", err); + this.status = "error"; + throw err; } } @@ -232,7 +277,7 @@ export class JsHttpDownloader { 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); From 81b3ad36122f4fd6d44b61bf251485d5e142ecf9 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 18:05:05 +0200 Subject: [PATCH 05/20] refactor: update download ID extraction and improve optional chaining in download services --- src/main/services/download/download-manager.ts | 16 ++++++++-------- src/main/services/download/js-http-downloader.ts | 2 +- .../download/js-multi-link-downloader.ts | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 8573e116..dac9d6ff 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -450,9 +450,9 @@ export class DownloadManager { } | null> { switch (download.downloader) { case Downloader.Gofile: { - const id = download.uri.split("/").pop()!; + const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id); + const downloadLink = await GofileApi.getDownloadLink(id as string); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -462,8 +462,8 @@ export class DownloadManager { }; } case Downloader.PixelDrain: { - const id = download.uri.split("/").pop()!; - const downloadUrl = await PixelDrainApi.getDownloadUrl(id); + const id = download.uri.split("/").pop(); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id as string); return { url: downloadUrl, @@ -576,9 +576,9 @@ export class DownloadManager { switch (download.downloader) { case Downloader.Gofile: { - const id = download.uri.split("/").pop()!; + const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id); + const downloadLink = await GofileApi.getDownloadLink(id as string); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -592,8 +592,8 @@ export class DownloadManager { }; } case Downloader.PixelDrain: { - const id = download.uri.split("/").pop()!; - const downloadUrl = await PixelDrainApi.getDownloadUrl(id); + const id = download.uri.split("/").pop(); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id as string); return { action: "start", diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index f4769e6a..5f0af779 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -134,7 +134,7 @@ export class JsHttpDownloader { ): Promise { const response = await fetch(url, { headers: requestHeaders, - signal: this.abortController!.signal, + signal: this.abortController?.signal, }); if (!response.ok && response.status !== 206) { diff --git a/src/main/services/download/js-multi-link-downloader.ts b/src/main/services/download/js-multi-link-downloader.ts index 05d80fd6..0d105b6d 100644 --- a/src/main/services/download/js-multi-link-downloader.ts +++ b/src/main/services/download/js-multi-link-downloader.ts @@ -61,7 +61,7 @@ export class JsMultiLinkDownloader { }); const status = this.downloader.getDownloadStatus(); - if (status && status.status === "complete") { + if (status?.status === "complete") { this.completedDownloads.push({ name: status.folderName, size: status.fileSize, @@ -105,7 +105,7 @@ export class JsMultiLinkDownloader { }); const status = this.downloader.getDownloadStatus(); - if (status && status.status === "complete") { + if (status?.status === "complete") { this.completedDownloads.push({ name: status.folderName, size: status.fileSize, From 2b3a8bf6b661631da8f4d74eae3345e49000c5ba Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 18:11:22 +0200 Subject: [PATCH 06/20] refactor: replace type assertions with non-null assertions for download ID in DownloadManager --- src/main/services/download/download-manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index dac9d6ff..497bc326 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -452,7 +452,7 @@ export class DownloadManager { case Downloader.Gofile: { const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id as string); + const downloadLink = await GofileApi.getDownloadLink(id!); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -463,7 +463,7 @@ export class DownloadManager { } case Downloader.PixelDrain: { const id = download.uri.split("/").pop(); - const downloadUrl = await PixelDrainApi.getDownloadUrl(id as string); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); return { url: downloadUrl, @@ -578,7 +578,7 @@ export class DownloadManager { case Downloader.Gofile: { const id = download.uri.split("/").pop(); const token = await GofileApi.authorize(); - const downloadLink = await GofileApi.getDownloadLink(id as string); + const downloadLink = await GofileApi.getDownloadLink(id!); await GofileApi.checkDownloadUrl(downloadLink); return { @@ -593,7 +593,7 @@ export class DownloadManager { } case Downloader.PixelDrain: { const id = download.uri.split("/").pop(); - const downloadUrl = await PixelDrainApi.getDownloadUrl(id as string); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); return { action: "start", From ca2f70aede9fe15b26602ee62b41a4188db8c9ba Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 18:50:36 +0200 Subject: [PATCH 07/20] refactor: enhance filename extraction and handling in download services --- .../services/download/download-manager.ts | 31 ++++++++++ .../services/download/js-http-downloader.ts | 58 ++++++++++++++++--- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 497bc326..715fe290 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -454,34 +454,52 @@ export class DownloadManager { const token = await GofileApi.authorize(); const downloadLink = await GofileApi.getDownloadLink(id!); await GofileApi.checkDownloadUrl(downloadLink); + const filename = + this.extractFilename(download.uri, downloadLink) || + this.extractFilename(downloadLink); return { url: downloadLink, savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, headers: { Cookie: `accountToken=${token}` }, }; } case Downloader.PixelDrain: { const id = download.uri.split("/").pop(); const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + const filename = + this.extractFilename(download.uri, downloadUrl) || + this.extractFilename(downloadUrl); return { url: downloadUrl, savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, }; } case Downloader.Qiwi: { const downloadUrl = await QiwiApi.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, }; } case Downloader.Datanodes: { const downloadUrl = await DatanodesApi.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, }; } case Downloader.Buzzheavier: { @@ -516,18 +534,27 @@ export class DownloadManager { } case Downloader.Mediafire: { const downloadUrl = await MediafireApi.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, }; } case Downloader.RealDebrid: { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); + const filename = + this.extractFilename(download.uri, downloadUrl) || + this.extractFilename(downloadUrl); return { url: downloadUrl, savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, }; } case Downloader.TorBox: { @@ -545,10 +572,14 @@ export class DownloadManager { download.uri ); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); + const filename = + this.extractFilename(download.uri, downloadUrl) || + this.extractFilename(downloadUrl); return { url: downloadUrl, savePath: download.downloadPath, + filename: filename ? this.sanitizeFilename(filename) : undefined, }; } case Downloader.VikingFile: { diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 5f0af779..e5596baf 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -50,7 +50,7 @@ export class JsHttpDownloader { this.isDownloading = true; const { url, savePath, filename, headers = {} } = options; - const { filePath, startByte } = this.prepareDownloadPath( + const { filePath, startByte, usedFallback } = this.prepareDownloadPath( savePath, filename, url @@ -58,7 +58,14 @@ export class JsHttpDownloader { const requestHeaders = this.buildRequestHeaders(headers, startByte); try { - await this.executeDownload(url, requestHeaders, filePath, startByte); + await this.executeDownload( + url, + requestHeaders, + filePath, + startByte, + savePath, + usedFallback + ); } catch (err) { this.handleDownloadError(err as Error); } finally { @@ -71,9 +78,10 @@ export class JsHttpDownloader { savePath: string, filename: string | undefined, url: string - ): { filePath: string; startByte: number } { - const resolvedFilename = - filename || this.extractFilename(url) || "download"; + ): { filePath: string; startByte: number; usedFallback: boolean } { + const extractedFilename = filename || this.extractFilename(url); + const usedFallback = !extractedFilename; + const resolvedFilename = extractedFilename || "download"; this.folderName = resolvedFilename; const filePath = path.join(savePath, resolvedFilename); @@ -90,7 +98,7 @@ export class JsHttpDownloader { } this.resetSpeedTracking(); - return { filePath, startByte }; + return { filePath, startByte, usedFallback }; } private buildRequestHeaders( @@ -130,7 +138,9 @@ export class JsHttpDownloader { url: string, requestHeaders: Record, filePath: string, - startByte: number + startByte: number, + savePath: string, + usedFallback: boolean ): Promise { const response = await fetch(url, { headers: requestHeaders, @@ -143,12 +153,25 @@ export class JsHttpDownloader { this.parseFileSize(response, startByte); + // If we used "download" fallback, try to get filename from Content-Disposition + let actualFilePath = filePath; + if (usedFallback && startByte === 0) { + const headerFilename = this.parseContentDisposition(response); + if (headerFilename) { + actualFilePath = path.join(savePath, headerFilename); + this.folderName = headerFilename; + logger.log( + `[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}` + ); + } + } + if (!response.body) { throw new Error("Response body is null"); } const flags = startByte > 0 ? "a" : "w"; - this.writeStream = fs.createWriteStream(filePath, { flags }); + this.writeStream = fs.createWriteStream(actualFilePath, { flags }); const readableStream = this.createReadableStream(response.body.getReader()); await pipeline(readableStream, this.writeStream); @@ -158,6 +181,25 @@ export class JsHttpDownloader { logger.log("[JsHttpDownloader] Download complete"); } + private parseContentDisposition(response: Response): string | undefined { + const header = response.headers.get("content-disposition"); + if (!header) return undefined; + + // Try to extract filename from Content-Disposition header + // Formats: attachment; filename="file.zip" or attachment; filename=file.zip + const filenameMatch = /filename\*?=['"]?(?:UTF-8'')?([^"';\n]+)['"]?/i.exec( + header + ); + if (filenameMatch?.[1]) { + try { + return decodeURIComponent(filenameMatch[1].trim()); + } catch { + return filenameMatch[1].trim(); + } + } + return undefined; + } + private createReadableStream( reader: ReadableStreamDefaultReader ): Readable { From 027761a1b53f46757c1ca12d3da82de71a84950c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 19:18:42 +0200 Subject: [PATCH 08/20] refactor: update cancelDownload method to conditionally delete file based on parameter --- src/main/services/download/js-http-downloader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index e5596baf..563d8a6c 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -259,7 +259,7 @@ export class JsHttpDownloader { } } - cancelDownload(): void { + cancelDownload(deleteFile = true): void { if (this.abortController) { logger.log("[JsHttpDownloader] Cancelling download"); this.abortController.abort(); @@ -267,7 +267,7 @@ export class JsHttpDownloader { this.cleanup(); - if (this.currentOptions) { + if (deleteFile && this.currentOptions && this.status !== "complete") { const filePath = path.join(this.currentOptions.savePath, this.folderName); if (fs.existsSync(filePath)) { try { From a7c82de4a722d067d1b8c331d6ccc0ef2b98430a Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 19:59:52 +0200 Subject: [PATCH 09/20] refactor: enhance download management by prioritizing interrupted downloads and improving error logging --- src/main/main.ts | 57 +++++++++++++++++-- .../services/download/js-http-downloader.ts | 26 +++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 12f5ea26..35f205e5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -18,6 +18,7 @@ import { DeckyPlugin, DownloadSourcesChecker, WSClient, + logger, } from "@main/services"; import { migrateDownloadSources } from "./helpers/migrate-download-sources"; @@ -73,18 +74,47 @@ export const loadState = async () => { return orderBy(games, "timestamp", "desc"); }); - downloads.forEach((download) => { + let interruptedDownload = null; + + for (const download of downloads) { + const downloadKey = levelKeys.game(download.shop, download.objectId); + + // Reset extracting state if (download.extracting) { - downloadsSublevel.put(levelKeys.game(download.shop, download.objectId), { + await downloadsSublevel.put(downloadKey, { ...download, extracting: false, }); } - }); - const [nextItemOnQueue] = downloads.filter((game) => game.queued); + // Find interrupted active download (download that was running when app closed) + // Mark it as paused but remember it for auto-resume + if (download.status === "active" && !interruptedDownload) { + interruptedDownload = download; + await downloadsSublevel.put(downloadKey, { + ...download, + status: "paused", + }); + } else if (download.status === "active") { + // Mark other active downloads as paused + await downloadsSublevel.put(downloadKey, { + ...download, + status: "paused", + }); + } + } - const downloadsToSeed = downloads.filter( + // Re-fetch downloads after status updates + const updatedDownloads = await downloadsSublevel + .values() + .all() + .then((games) => orderBy(games, "timestamp", "desc")); + + // Prioritize interrupted download, then queued downloads + const downloadToResume = + interruptedDownload ?? updatedDownloads.find((game) => game.queued); + + const downloadsToSeed = updatedDownloads.filter( (game) => game.shouldSeed && game.downloader === Downloader.Torrent && @@ -92,7 +122,22 @@ export const loadState = async () => { game.uri !== null ); - await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); + // For torrents or if JS downloader is disabled, use Python RPC + const isTorrent = downloadToResume?.downloader === Downloader.Torrent; + const useJsDownloader = + userPreferences?.useNativeHttpDownloader && !isTorrent; + + if (useJsDownloader && downloadToResume) { + // Start Python RPC for seeding only, then resume HTTP download with JS + await DownloadManager.startRPC(undefined, downloadsToSeed); + await DownloadManager.startDownload(downloadToResume).catch((err) => { + // If resume fails, just log it - user can manually retry + logger.error("Failed to auto-resume download:", err); + }); + } else { + // Use Python RPC for everything (torrent or fallback) + await DownloadManager.startRPC(downloadToResume, downloadsToSeed); + } startMainLoop(); diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 563d8a6c..44a3af49 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -147,6 +147,32 @@ export class JsHttpDownloader { signal: this.abortController?.signal, }); + // Handle 416 Range Not Satisfiable - existing file is larger than server file + // This happens when downloading same game from different source + if (response.status === 416 && startByte > 0) { + logger.log( + "[JsHttpDownloader] Range not satisfiable, deleting existing file and restarting" + ); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + this.bytesDownloaded = 0; + this.resetSpeedTracking(); + + // Retry without Range header + const headersWithoutRange = { ...requestHeaders }; + delete headersWithoutRange["Range"]; + + return this.executeDownload( + url, + headersWithoutRange, + filePath, + 0, + savePath, + usedFallback + ); + } + if (!response.ok && response.status !== 206) { throw new Error(`HTTP error! status: ${response.status}`); } From a2e866317da4a75b71d053780b7c59322f41c576 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Tue, 6 Jan 2026 20:01:15 +0200 Subject: [PATCH 10/20] refactor: update interruptedDownload type to Download | null for improved type safety --- src/main/main.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 35f205e5..778b0c6c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,7 +2,7 @@ import { downloadsSublevel } from "./level/sublevels/downloads"; import { orderBy } from "lodash-es"; import { Downloader } from "@shared"; import { levelKeys, db } from "./level"; -import type { UserPreferences } from "@types"; +import type { Download, UserPreferences } from "@types"; import { SystemPath, CommonRedistManager, @@ -74,7 +74,7 @@ export const loadState = async () => { return orderBy(games, "timestamp", "desc"); }); - let interruptedDownload = null; + let interruptedDownload: Download | null = null; for (const download of downloads) { const downloadKey = levelKeys.game(download.shop, download.objectId); From c67b27565796dacb18539da21c47adecb6d58a66 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 7 Jan 2026 17:26:29 +0200 Subject: [PATCH 11/20] refactor: improve download initiation and error handling in DownloadManager --- .../events/torrenting/start-game-download.ts | 14 +++++- .../services/download/download-manager.ts | 47 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index e44ba936..a110e3ae 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -85,8 +85,18 @@ const startGameDownload = async ( }; try { - await DownloadManager.startDownload(download).then(() => { - return downloadsSublevel.put(gameKey, download); + // Save download to DB immediately so UI can show it + await downloadsSublevel.put(gameKey, download); + + // Start download asynchronously (don't await) to avoid blocking UI + // This is especially important for Gofile/Mediafire which make API calls + DownloadManager.startDownload(download).catch((err) => { + logger.error("Failed to start download after save:", err); + // Update download status to error + downloadsSublevel.put(gameKey, { + ...download, + status: "error", + }); }); const updatedGame = await gamesSublevel.get(gameKey); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 715fe290..8852b850 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -770,21 +770,66 @@ export class DownloadManager { if (useJsDownloader && isHttp) { logger.log("[DownloadManager] Using JS HTTP downloader"); + this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + + // Get download options (this includes API calls for Gofile/Mediafire) + // We still await this to catch errors, but start actual download async const options = await this.getJsDownloadOptions(download); if (!options) { throw new Error("Failed to get download options for JS downloader"); } + // Try to get file size from HEAD request before starting download + // This ensures UI shows file size immediately instead of waiting + try { + const headResponse = await fetch(options.url, { + method: "HEAD", + headers: options.headers, + }); + + if (headResponse.ok) { + const contentLength = headResponse.headers.get("content-length"); + if (contentLength) { + const fileSize = Number.parseInt(contentLength, 10); + const downloadId = this.downloadingGameId; + const currentDownload = await downloadsSublevel.get(downloadId); + if (currentDownload) { + await downloadsSublevel.put(downloadId, { + ...currentDownload, + fileSize, + }); + logger.log( + `[DownloadManager] Pre-fetched file size: ${fileSize} bytes` + ); + } + } + } + } catch { + // If HEAD request fails, continue anyway - file size will be known from actual download + logger.log( + "[DownloadManager] Could not pre-fetch file size, will get from download response" + ); + } + + // Start download asynchronously (don't await) so UI returns immediately this.jsDownloader = new JsHttpDownloader(); this.usingJsDownloader = true; - this.downloadingGameId = levelKeys.game(download.shop, download.objectId); + // Start download in background this.jsDownloader.startDownload(options).catch((err) => { logger.error("[DownloadManager] JS download error:", err); this.usingJsDownloader = false; this.jsDownloader = null; }); + + // Poll status immediately after a short delay to get file size from response headers + // This ensures UI shows file size quickly instead of waiting for watchDownloads loop (2s interval) + setTimeout(() => { + this.getDownloadStatusFromJs().catch(() => { + // Ignore errors - status will be updated by watchDownloads loop + }); + }, 500); } else { logger.log("[DownloadManager] Using Python RPC downloader"); const payload = await this.getDownloadPayload(download); From ed3cce160fd4f982d5e191e08347086d8a98320d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 7 Jan 2026 17:32:46 +0200 Subject: [PATCH 12/20] refactor: adjust file size handling in DownloadManager to ensure accurate download status updates --- src/main/services/download/download-manager.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 8852b850..647aa035 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -156,10 +156,13 @@ export class DownloadManager { const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } = status; + const finalFileSize = + fileSize && fileSize > 0 ? fileSize : download.fileSize; + const updatedDownload = { ...download, bytesDownloaded, - fileSize, + fileSize: finalFileSize, progress, folderName, status: @@ -176,7 +179,11 @@ export class DownloadManager { numPeers: 0, numSeeds: 0, downloadSpeed, - timeRemaining: calculateETA(fileSize, bytesDownloaded, downloadSpeed), + timeRemaining: calculateETA( + finalFileSize ?? 0, + bytesDownloaded, + downloadSpeed + ), isDownloadingMetadata: false, isCheckingFiles: false, progress, From ed044d797f8fd20ca7e0660e15189fbddfb8e05e Mon Sep 17 00:00:00 2001 From: Moyasee Date: Wed, 7 Jan 2026 20:35:43 +0200 Subject: [PATCH 13/20] refactor: streamline download preparation and status handling in DownloadManager --- .../events/torrenting/start-game-download.ts | 14 +- .../services/download/download-manager.ts | 127 +++++++++--------- src/renderer/src/features/download-slice.ts | 11 +- .../src/pages/downloads/download-group.tsx | 13 +- 4 files changed, 83 insertions(+), 82 deletions(-) diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index a110e3ae..e44ba936 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -85,18 +85,8 @@ const startGameDownload = async ( }; try { - // Save download to DB immediately so UI can show it - await downloadsSublevel.put(gameKey, download); - - // Start download asynchronously (don't await) to avoid blocking UI - // This is especially important for Gofile/Mediafire which make API calls - DownloadManager.startDownload(download).catch((err) => { - logger.error("Failed to start download after save:", err); - // Update download status to error - downloadsSublevel.put(gameKey, { - ...download, - status: "error", - }); + await DownloadManager.startDownload(download).then(() => { + return downloadsSublevel.put(gameKey, download); }); const updatedGame = await gamesSublevel.get(gameKey); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 647aa035..adb42e08 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -35,6 +35,7 @@ export class DownloadManager { private static downloadingGameId: string | null = null; private static jsDownloader: JsHttpDownloader | null = null; private static usingJsDownloader = false; + private static isPreparingDownload = false; private static extractFilename( url: string, @@ -142,13 +143,37 @@ export class DownloadManager { } private static async getDownloadStatusFromJs(): Promise { - if (!this.jsDownloader || !this.downloadingGameId) return null; + if (!this.downloadingGameId) return null; + + const downloadId = this.downloadingGameId; + + // Return a "preparing" status while fetching download options + if (this.isPreparingDownload) { + try { + const download = await downloadsSublevel.get(downloadId); + if (!download) return null; + + return { + numPeers: 0, + numSeeds: 0, + downloadSpeed: 0, + timeRemaining: -1, + isDownloadingMetadata: true, // Use this to indicate "preparing" + isCheckingFiles: false, + progress: 0, + gameId: downloadId, + download, + }; + } catch { + return null; + } + } + + if (!this.jsDownloader) 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; @@ -156,13 +181,14 @@ export class DownloadManager { const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } = status; - const finalFileSize = - fileSize && fileSize > 0 ? fileSize : download.fileSize; + // Only update fileSize in database if we actually know it (> 0) + // Otherwise keep the existing value to avoid showing "0 B" + const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize; const updatedDownload = { ...download, bytesDownloaded, - fileSize: finalFileSize, + fileSize: effectiveFileSize, progress, folderName, status: @@ -180,7 +206,7 @@ export class DownloadManager { numSeeds: 0, downloadSpeed, timeRemaining: calculateETA( - finalFileSize ?? 0, + effectiveFileSize ?? 0, bytesDownloaded, downloadSpeed ), @@ -420,7 +446,7 @@ export class DownloadManager { this.jsDownloader.cancelDownload(); this.jsDownloader = null; this.usingJsDownloader = false; - } else { + } else if (!this.isPreparingDownload) { await PythonRPC.rpc .post("/action", { action: "cancel", game_id: downloadKey }) .catch((err) => logger.error("Failed to cancel game download", err)); @@ -430,6 +456,8 @@ export class DownloadManager { WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.webContents.send("on-download-progress", null); this.downloadingGameId = null; + this.isPreparingDownload = false; + this.usingJsDownloader = false; } } @@ -774,74 +802,45 @@ export class DownloadManager { static async startDownload(download: Download) { const useJsDownloader = await this.shouldUseJsDownloader(); const isHttp = this.isHttpDownloader(download.downloader); + const downloadId = levelKeys.game(download.shop, download.objectId); if (useJsDownloader && isHttp) { logger.log("[DownloadManager] Using JS HTTP downloader"); - this.downloadingGameId = levelKeys.game(download.shop, download.objectId); - // Get download options (this includes API calls for Gofile/Mediafire) - // We still await this to catch errors, but start actual download async - const options = await this.getJsDownloadOptions(download); - - if (!options) { - throw new Error("Failed to get download options for JS downloader"); - } - - // Try to get file size from HEAD request before starting download - // This ensures UI shows file size immediately instead of waiting - try { - const headResponse = await fetch(options.url, { - method: "HEAD", - headers: options.headers, - }); - - if (headResponse.ok) { - const contentLength = headResponse.headers.get("content-length"); - if (contentLength) { - const fileSize = Number.parseInt(contentLength, 10); - const downloadId = this.downloadingGameId; - const currentDownload = await downloadsSublevel.get(downloadId); - if (currentDownload) { - await downloadsSublevel.put(downloadId, { - ...currentDownload, - fileSize, - }); - logger.log( - `[DownloadManager] Pre-fetched file size: ${fileSize} bytes` - ); - } - } - } - } catch { - // If HEAD request fails, continue anyway - file size will be known from actual download - logger.log( - "[DownloadManager] Could not pre-fetch file size, will get from download response" - ); - } - - // Start download asynchronously (don't await) so UI returns immediately - this.jsDownloader = new JsHttpDownloader(); + // Set preparing state immediately so UI knows download is starting + this.downloadingGameId = downloadId; + this.isPreparingDownload = true; this.usingJsDownloader = true; - // Start download in background - this.jsDownloader.startDownload(options).catch((err) => { - logger.error("[DownloadManager] JS download error:", err); - this.usingJsDownloader = false; - this.jsDownloader = null; - }); + try { + const options = await this.getJsDownloadOptions(download); - // Poll status immediately after a short delay to get file size from response headers - // This ensures UI shows file size quickly instead of waiting for watchDownloads loop (2s interval) - setTimeout(() => { - this.getDownloadStatusFromJs().catch(() => { - // Ignore errors - status will be updated by watchDownloads loop + if (!options) { + this.isPreparingDownload = false; + this.usingJsDownloader = false; + this.downloadingGameId = null; + throw new Error("Failed to get download options for JS downloader"); + } + + this.jsDownloader = new JsHttpDownloader(); + this.isPreparingDownload = false; + + this.jsDownloader.startDownload(options).catch((err) => { + logger.error("[DownloadManager] JS download error:", err); + this.usingJsDownloader = false; + this.jsDownloader = null; }); - }, 500); + } catch (err) { + this.isPreparingDownload = false; + this.usingJsDownloader = false; + this.downloadingGameId = null; + throw err; + } } 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.downloadingGameId = downloadId; this.usingJsDownloader = false; } } diff --git a/src/renderer/src/features/download-slice.ts b/src/renderer/src/features/download-slice.ts index f70421c0..702b1f6e 100644 --- a/src/renderer/src/features/download-slice.ts +++ b/src/renderer/src/features/download-slice.ts @@ -31,11 +31,16 @@ export const downloadSlice = createSlice({ reducers: { setLastPacket: (state, action: PayloadAction) => { state.lastPacket = action.payload; - if (!state.gameId && action.payload) state.gameId = action.payload.gameId; + + // Ensure payload exists and has a valid gameId before accessing + const payload = action.payload; + if (!state.gameId && payload?.gameId) { + state.gameId = payload.gameId; + } // Track peak speed and speed history atomically when packet arrives - if (action.payload?.gameId && action.payload.downloadSpeed != null) { - const { gameId, downloadSpeed } = action.payload; + if (payload?.gameId && payload.downloadSpeed != null) { + const { gameId, downloadSpeed } = payload; // Update peak speed if this is higher const currentPeak = state.peakSpeeds[gameId] || 0; diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 6a22148a..68b9b0b1 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -613,11 +613,18 @@ export function DownloadGroup({ const download = game.download!; const isGameDownloading = isGameDownloadingMap[game.id]; - if (download.fileSize != null) return formatBytes(download.fileSize); - - if (lastPacket?.download.fileSize && isGameDownloading) + // Check lastPacket first for most up-to-date size during active downloads + if ( + isGameDownloading && + lastPacket?.download.fileSize && + lastPacket.download.fileSize > 0 + ) return formatBytes(lastPacket.download.fileSize); + // Then check the stored download size (must be > 0 to be valid) + if (download.fileSize != null && download.fileSize > 0) + return formatBytes(download.fileSize); + return "N/A"; }; From 562e30eecf749f580dbc0a54eea296071fea1b4d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 10 Jan 2026 18:49:31 +0200 Subject: [PATCH 14/20] refactor: add cancel download confirmation modal and enhance download management in DownloadGroup --- src/locales/en/translation.json | 3 + src/main/main.ts | 7 +- .../services/download/download-manager.ts | 18 +- .../src/pages/downloads/download-group.tsx | 296 +++++++++++------- 4 files changed, 199 insertions(+), 125 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c317a52b..02215a9f 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -404,6 +404,9 @@ "completed": "Completed", "removed": "Not downloaded", "cancel": "Cancel", + "cancel_download": "Cancel download", + "cancel_download_description": "Are you sure you want to cancel this download? This will delete all downloaded files.", + "keep_downloading": "Keep downloading", "filter": "Filter downloaded games", "remove": "Remove", "downloading_metadata": "Downloading metadata…", diff --git a/src/main/main.ts b/src/main/main.ts index 778b0c6c..fb7536a8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -34,7 +34,9 @@ export const loadState = async () => { await import("./events"); - if (!userPreferences?.useNativeHttpDownloader) { + // Only spawn aria2 if user explicitly disabled native HTTP downloader + // Default is to use native HTTP downloader (aria2 is opt-in) + if (userPreferences?.useNativeHttpDownloader === false) { Aria2.spawn(); } @@ -124,8 +126,9 @@ export const loadState = async () => { // For torrents or if JS downloader is disabled, use Python RPC const isTorrent = downloadToResume?.downloader === Downloader.Torrent; + // Default to true - native HTTP downloader is enabled by default const useJsDownloader = - userPreferences?.useNativeHttpDownloader && !isTorrent; + (userPreferences?.useNativeHttpDownloader ?? true) && !isTorrent; if (useJsDownloader && downloadToResume) { // Start Python RPC for seeding only, then resume HTTP download with JS diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index adb42e08..cfb2ba10 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -111,7 +111,8 @@ export class DownloadManager { levelKeys.userPreferences, { valueEncoding: "json" } ); - return userPreferences?.useNativeHttpDownloader ?? false; + // Default to true - native HTTP downloader is enabled by default (opt-out) + return userPreferences?.useNativeHttpDownloader ?? true; } private static isHttpDownloader(downloader: Downloader): boolean { @@ -483,6 +484,9 @@ export class DownloadManager { filename?: string; headers?: Record; } | null> { + // If resuming and we already have a folderName, use it to ensure we find the partial file + const resumingFilename = download.folderName || undefined; + switch (download.downloader) { case Downloader.Gofile: { const id = download.uri.split("/").pop(); @@ -490,6 +494,7 @@ export class DownloadManager { const downloadLink = await GofileApi.getDownloadLink(id!); await GofileApi.checkDownloadUrl(downloadLink); const filename = + resumingFilename || this.extractFilename(download.uri, downloadLink) || this.extractFilename(downloadLink); @@ -504,6 +509,7 @@ export class DownloadManager { const id = download.uri.split("/").pop(); const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -516,6 +522,7 @@ export class DownloadManager { case Downloader.Qiwi: { const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -528,6 +535,7 @@ export class DownloadManager { case Downloader.Datanodes: { const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -543,6 +551,7 @@ export class DownloadManager { ); const directUrl = await BuzzheavierApi.getDirectLink(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, directUrl) || this.extractFilename(directUrl); @@ -558,6 +567,7 @@ export class DownloadManager { ); const directUrl = await FuckingFastApi.getDirectLink(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, directUrl) || this.extractFilename(directUrl); @@ -570,6 +580,7 @@ export class DownloadManager { case Downloader.Mediafire: { const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -583,6 +594,7 @@ export class DownloadManager { const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -599,7 +611,7 @@ export class DownloadManager { return { url, savePath: download.downloadPath, - filename: name, + filename: resumingFilename || name, }; } case Downloader.Hydra: { @@ -608,6 +620,7 @@ export class DownloadManager { ); if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); @@ -623,6 +636,7 @@ export class DownloadManager { ); const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); const filename = + resumingFilename || this.extractFilename(download.uri, downloadUrl) || this.extractFilename(downloadUrl); diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 68b9b0b1..1bb8c76d 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -1,6 +1,6 @@ import type { GameShop, LibraryGame, SeedingStatus } from "@types"; -import { Badge, Button } from "@renderer/components"; +import { Badge, Button, ConfirmationModal } from "@renderer/components"; import { formatDownloadProgress, buildGameDetailsPath, @@ -219,7 +219,7 @@ interface HeroDownloadViewProps { calculateETA: () => string; pauseDownload: (shop: GameShop, objectId: string) => void; resumeDownload: (shop: GameShop, objectId: string) => void; - cancelDownload: (shop: GameShop, objectId: string) => void; + onCancelClick: (shop: GameShop, objectId: string) => void; t: (key: string) => string; } @@ -238,7 +238,7 @@ function HeroDownloadView({ calculateETA, pauseDownload, resumeDownload, - cancelDownload, + onCancelClick, t, }: Readonly) { const navigate = useNavigate(); @@ -353,7 +353,7 @@ function HeroDownloadView({ )} - -
+
    + {downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => { + return ( +
  • -
    -
    - - {DOWNLOADER_NAME[Number(game.download!.downloader)]} - -
    -
    - {extraction?.visibleId === game.id ? ( - - {t("extracting")} ( - {Math.round(extraction.progress * 100)}%) - - ) : ( - - - {size} - - )} - {game.download?.progress === 1 && seeding && ( - - {t("seeding")} - - )} + +
    + +
    +
    + + {DOWNLOADER_NAME[Number(game.download!.downloader)]} + +
    +
    + {extraction?.visibleId === game.id ? ( + + {t("extracting")} ( + {Math.round(extraction.progress * 100)}%) + + ) : ( + + + {size} + + )} + {game.download?.progress === 1 && seeding && ( + + {t("seeding")} + + )} +
    -
    - {isQueuedGroup && ( -
    - - {formatDownloadProgress(progress)} - -
    -
    + {isQueuedGroup && ( +
    + + {formatDownloadProgress(progress)} + +
    +
    +
    -
    - )} + )} -
    - {game.download?.progress === 1 && ( - - )} - {isQueuedGroup && game.download?.progress !== 1 && ( - - )} - - - -
    -
  • - ); - })} -
-
+
+ {game.download?.progress === 1 && ( + + )} + {isQueuedGroup && game.download?.progress !== 1 && ( + + )} + + + +
+ + ); + })} + + + ); } From da0ae54b6042854fc66bba103fb7614264e53d8c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 10 Jan 2026 19:47:55 +0200 Subject: [PATCH 15/20] refactor: update cancel download confirmation text and enhance error handling in JsHttpDownloader --- src/locales/en/translation.json | 7 ++++--- src/main/services/download/js-http-downloader.ts | 6 +++++- src/renderer/src/pages/downloads/download-group.tsx | 4 ++-- src/renderer/src/pages/notifications/notifications.scss | 2 ++ 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 02215a9f..b7178c81 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -404,9 +404,10 @@ "completed": "Completed", "removed": "Not downloaded", "cancel": "Cancel", - "cancel_download": "Cancel download", - "cancel_download_description": "Are you sure you want to cancel this download? This will delete all downloaded files.", - "keep_downloading": "Keep downloading", + "cancel_download": "Cancel download?", + "cancel_download_description": "Are you sure you want to cancel this download? All downloaded files will be deleted.", + "keep_downloading": "No, keep downloading", + "yes_cancel": "Yes, cancel", "filter": "Filter downloaded games", "remove": "Remove", "downloading_metadata": "Downloading metadata…", diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 44a3af49..c90c1a95 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -258,7 +258,11 @@ export class JsHttpDownloader { } private handleDownloadError(err: Error): void { - if (err.name === "AbortError") { + // Handle abort/cancellation errors - these are expected when user pauses/cancels + if ( + err.name === "AbortError" || + (err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE" + ) { logger.log("[JsHttpDownloader] Download aborted"); this.status = "paused"; } else { diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 1bb8c76d..018bdc8d 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -844,7 +844,7 @@ export function DownloadGroup({ visible={cancelModalVisible} title={t("cancel_download")} descriptionText={t("cancel_download_description")} - confirmButtonLabel={t("cancel")} + confirmButtonLabel={t("yes_cancel")} cancelButtonLabel={t("keep_downloading")} onConfirm={handleConfirmCancel} onClose={handleCancelModalClose} @@ -877,7 +877,7 @@ export function DownloadGroup({ visible={cancelModalVisible} title={t("cancel_download")} descriptionText={t("cancel_download_description")} - confirmButtonLabel={t("cancel")} + confirmButtonLabel={t("yes_cancel")} cancelButtonLabel={t("keep_downloading")} onConfirm={handleConfirmCancel} onClose={handleCancelModalClose} diff --git a/src/renderer/src/pages/notifications/notifications.scss b/src/renderer/src/pages/notifications/notifications.scss index 20fbc343..f858f577 100644 --- a/src/renderer/src/pages/notifications/notifications.scss +++ b/src/renderer/src/pages/notifications/notifications.scss @@ -91,6 +91,7 @@ display: flex; flex-direction: column; gap: globals.$spacing-unit; + padding-bottom: calc(globals.$spacing-unit * 3); } &__empty { @@ -134,5 +135,6 @@ display: flex; justify-content: center; padding: calc(globals.$spacing-unit * 2); + padding-bottom: calc(globals.$spacing-unit * 3); } } From 5e4e03a958eb4dc045ea0e7d5e1cbc3f5213db73 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sat, 10 Jan 2026 20:11:20 +0200 Subject: [PATCH 16/20] refactor: enhance download management by adding filename resolution and extraction handling in DownloadManage --- .../services/download/download-manager.ts | 609 +++++++++++------- 1 file changed, 363 insertions(+), 246 deletions(-) diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index cfb2ba10..7f5fc304 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -75,6 +75,34 @@ export class DownloadManager { return filename.replaceAll(/[<>:"/\\|?*]/g, "_"); } + private static resolveFilename( + resumingFilename: string | undefined, + originalUrl: string, + downloadUrl: string + ): string | undefined { + if (resumingFilename) return resumingFilename; + + const extracted = + this.extractFilename(originalUrl, downloadUrl) || + this.extractFilename(downloadUrl); + + return extracted ? this.sanitizeFilename(extracted) : undefined; + } + + private static buildDownloadOptions( + url: string, + savePath: string, + filename: string | undefined, + headers?: Record + ) { + return { + url, + savePath, + filename, + headers, + }; + } + private static createDownloadPayload( directUrl: string, originalUrl: string, @@ -286,100 +314,127 @@ export class DownloadManager { public static async watchDownloads() { const status = await this.getDownloadStatus(); + if (!status) return; - if (status) { - const { gameId, progress } = status; + const { gameId, progress } = status; + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameId), + gamesSublevel.get(gameId), + ]); - const [download, game] = await Promise.all([ - downloadsSublevel.get(gameId), - gamesSublevel.get(gameId), - ]); + if (!download || !game) return; - if (!download || !game) return; + this.sendProgressUpdate(progress, status, game); - const userPreferences = await db.get( - levelKeys.userPreferences, - { valueEncoding: "json" } + if (progress === 1) { + await this.handleDownloadCompletion(download, game, gameId); + } + } + + private static sendProgressUpdate( + progress: number, + status: DownloadProgress, + game: any + ) { + if (WindowManager.mainWindow) { + WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); + WindowManager.mainWindow.webContents.send( + "on-download-progress", + structuredClone({ ...status, game }) + ); + } + } + + private static async handleDownloadCompletion( + download: Download, + game: any, + gameId: string + ) { + publishDownloadCompleteNotification(game); + + const userPreferences = await db.get( + levelKeys.userPreferences, + { valueEncoding: "json" } + ); + + await this.updateDownloadStatus( + download, + gameId, + userPreferences?.seedAfterDownloadComplete + ); + + if (download.automaticallyExtract) { + this.handleExtraction(download, game); + } + + await this.processNextQueuedDownload(); + } + + private static async updateDownloadStatus( + download: Download, + gameId: string, + shouldSeed?: boolean + ) { + const shouldExtract = download.automaticallyExtract; + + if (shouldSeed && download.downloader === Downloader.Torrent) { + await downloadsSublevel.put(gameId, { + ...download, + status: "seeding", + shouldSeed: true, + queued: false, + extracting: shouldExtract, + }); + } else { + await downloadsSublevel.put(gameId, { + ...download, + status: "complete", + shouldSeed: false, + queued: false, + extracting: shouldExtract, + }); + this.cancelDownload(gameId); + } + } + + private static handleExtraction(download: Download, game: any) { + const gameFilesManager = new GameFilesManager(game.shop, game.objectId); + + if ( + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => + download.folderName?.endsWith(ext) + ) + ) { + gameFilesManager.extractDownloadedFile(); + } else if (download.folderName) { + gameFilesManager + .extractFilesInDirectory( + path.join(download.downloadPath, download.folderName) + ) + .then(() => gameFilesManager.setExtractionComplete()); + } + } + + private static async processNextQueuedDownload() { + const downloads = await downloadsSublevel + .values() + .all() + .then((games) => + sortBy( + games.filter((game) => game.status === "paused" && game.queued), + "timestamp", + "DESC" + ) ); - if (WindowManager.mainWindow && download) { - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - WindowManager.mainWindow.webContents.send( - "on-download-progress", - structuredClone({ ...status, game }) - ); - } + const [nextItemOnQueue] = downloads; - const shouldExtract = download.automaticallyExtract; - - if (progress === 1 && download) { - publishDownloadCompleteNotification(game); - - if ( - userPreferences?.seedAfterDownloadComplete && - download.downloader === Downloader.Torrent - ) { - await downloadsSublevel.put(gameId, { - ...download, - status: "seeding", - shouldSeed: true, - queued: false, - extracting: shouldExtract, - }); - } else { - await downloadsSublevel.put(gameId, { - ...download, - status: "complete", - shouldSeed: false, - queued: false, - extracting: shouldExtract, - }); - - this.cancelDownload(gameId); - } - - if (shouldExtract) { - const gameFilesManager = new GameFilesManager( - game.shop, - game.objectId - ); - - if ( - FILE_EXTENSIONS_TO_EXTRACT.some((ext) => - download.folderName?.endsWith(ext) - ) - ) { - gameFilesManager.extractDownloadedFile(); - } else if (download.folderName) { - gameFilesManager - .extractFilesInDirectory( - path.join(download.downloadPath, download.folderName) - ) - .then(() => gameFilesManager.setExtractionComplete()); - } - } - - const downloads = await downloadsSublevel - .values() - .all() - .then((games) => - sortBy( - games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "DESC" - ) - ); - - const [nextItemOnQueue] = downloads; - - if (nextItemOnQueue) { - this.resumeDownload(nextItemOnQueue); - } else { - this.downloadingGameId = null; - this.usingJsDownloader = false; - this.jsDownloader = null; - } - } + if (nextItemOnQueue) { + this.resumeDownload(nextItemOnQueue); + } else { + this.downloadingGameId = null; + this.usingJsDownloader = false; + this.jsDownloader = null; } } @@ -484,173 +539,235 @@ export class DownloadManager { filename?: string; headers?: Record; } | null> { - // If resuming and we already have a folderName, use it to ensure we find the partial file const resumingFilename = download.folderName || undefined; 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); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadLink) || - this.extractFilename(downloadLink); - - return { - url: downloadLink, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - headers: { Cookie: `accountToken=${token}` }, - }; - } - case Downloader.PixelDrain: { - const id = download.uri.split("/").pop(); - const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.Qiwi: { - const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.Datanodes: { - const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.Buzzheavier: { - logger.log( - `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` - ); - const directUrl = await BuzzheavierApi.getDirectLink(download.uri); - const filename = - resumingFilename || - 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 = - resumingFilename || - 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); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.RealDebrid: { - const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.TorBox: { - const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); - if (!url) return null; - - return { - url, - savePath: download.downloadPath, - filename: resumingFilename || name, - }; - } - case Downloader.Hydra: { - const downloadUrl = await HydraDebridClient.getDownloadUrl( - download.uri - ); - if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } - case Downloader.VikingFile: { - logger.log( - `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` - ); - const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); - const filename = - resumingFilename || - this.extractFilename(download.uri, downloadUrl) || - this.extractFilename(downloadUrl); - - return { - url: downloadUrl, - savePath: download.downloadPath, - filename: filename ? this.sanitizeFilename(filename) : undefined, - }; - } + case Downloader.Gofile: + return this.getGofileDownloadOptions(download, resumingFilename); + case Downloader.PixelDrain: + return this.getPixelDrainDownloadOptions(download, resumingFilename); + case Downloader.Qiwi: + return this.getQiwiDownloadOptions(download, resumingFilename); + case Downloader.Datanodes: + return this.getDatanodesDownloadOptions(download, resumingFilename); + case Downloader.Buzzheavier: + return this.getBuzzheavierDownloadOptions(download, resumingFilename); + case Downloader.FuckingFast: + return this.getFuckingFastDownloadOptions(download, resumingFilename); + case Downloader.Mediafire: + return this.getMediafireDownloadOptions(download, resumingFilename); + case Downloader.RealDebrid: + return this.getRealDebridDownloadOptions(download, resumingFilename); + case Downloader.TorBox: + return this.getTorBoxDownloadOptions(download, resumingFilename); + case Downloader.Hydra: + return this.getHydraDownloadOptions(download, resumingFilename); + case Downloader.VikingFile: + return this.getVikingFileDownloadOptions(download, resumingFilename); default: return null; } } + private static async getGofileDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const id = download.uri.split("/").pop(); + const token = await GofileApi.authorize(); + const downloadLink = await GofileApi.getDownloadLink(id!); + await GofileApi.checkDownloadUrl(downloadLink); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadLink + ); + return this.buildDownloadOptions( + downloadLink, + download.downloadPath, + filename, + { Cookie: `accountToken=${token}` } + ); + } + + private static async getPixelDrainDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const id = download.uri.split("/").pop(); + const downloadUrl = await PixelDrainApi.getDownloadUrl(id!); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getQiwiDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await QiwiApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getDatanodesDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getBuzzheavierDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}` + ); + const directUrl = await BuzzheavierApi.getDirectLink(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + directUrl + ); + return this.buildDownloadOptions( + directUrl, + download.downloadPath, + filename + ); + } + + private static async getFuckingFastDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing FuckingFast download for URI: ${download.uri}` + ); + const directUrl = await FuckingFastApi.getDirectLink(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + directUrl + ); + return this.buildDownloadOptions( + directUrl, + download.downloadPath, + filename + ); + } + + private static async getMediafireDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await MediafireApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getRealDebridDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getTorBoxDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const { name, url } = await TorBoxClient.getDownloadInfo(download.uri); + if (!url) return null; + return this.buildDownloadOptions( + url, + download.downloadPath, + resumingFilename || name + ); + } + + private static async getHydraDownloadOptions( + download: Download, + resumingFilename?: string + ) { + const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri); + if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + + private static async getVikingFileDownloadOptions( + download: Download, + resumingFilename?: string + ) { + logger.log( + `[DownloadManager] Processing VikingFile download for URI: ${download.uri}` + ); + const downloadUrl = await VikingFileApi.getDownloadUrl(download.uri); + const filename = this.resolveFilename( + resumingFilename, + download.uri, + downloadUrl + ); + return this.buildDownloadOptions( + downloadUrl, + download.downloadPath, + filename + ); + } + private static async getDownloadPayload(download: Download) { const downloadId = levelKeys.game(download.shop, download.objectId); From 467b27baa3fa4500a790f0500b95b338b8c842b2 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 11 Jan 2026 15:36:16 +0200 Subject: [PATCH 17/20] refactor: remove unused JsMultiLinkDownloader and ensure aria2 spawning on startup --- src/main/main.ts | 6 +- src/main/services/download/index.ts | 1 - .../download/js-multi-link-downloader.ts | 201 ------------------ 3 files changed, 1 insertion(+), 207 deletions(-) delete mode 100644 src/main/services/download/js-multi-link-downloader.ts diff --git a/src/main/main.ts b/src/main/main.ts index fb7536a8..e06d67ed 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -34,11 +34,7 @@ export const loadState = async () => { await import("./events"); - // Only spawn aria2 if user explicitly disabled native HTTP downloader - // Default is to use native HTTP downloader (aria2 is opt-in) - if (userPreferences?.useNativeHttpDownloader === false) { - Aria2.spawn(); - } + Aria2.spawn(); if (userPreferences?.realDebridApiToken) { RealDebridClient.authorize(userPreferences.realDebridApiToken); diff --git a/src/main/services/download/index.ts b/src/main/services/download/index.ts index 6a5c3236..cccd8bd4 100644 --- a/src/main/services/download/index.ts +++ b/src/main/services/download/index.ts @@ -2,4 +2,3 @@ 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-multi-link-downloader.ts b/src/main/services/download/js-multi-link-downloader.ts deleted file mode 100644 index 0d105b6d..00000000 --- a/src/main/services/download/js-multi-link-downloader.ts +++ /dev/null @@ -1,201 +0,0 @@ -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 === "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 === "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; - } -} From 2e152d321e29ab074ec80c8529952a0bc8b6935c Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 11 Jan 2026 16:00:39 +0200 Subject: [PATCH 18/20] refactor: remove HttpMultiLinkDownloader and update download handling logic --- python_rpc/http_multi_link_downloader.py | 151 ----------------------- python_rpc/main.py | 45 +------ 2 files changed, 3 insertions(+), 193 deletions(-) delete mode 100644 python_rpc/http_multi_link_downloader.py diff --git a/python_rpc/http_multi_link_downloader.py b/python_rpc/http_multi_link_downloader.py deleted file mode 100644 index 3968d77c..00000000 --- a/python_rpc/http_multi_link_downloader.py +++ /dev/null @@ -1,151 +0,0 @@ -import aria2p -from aria2p.client import ClientException as DownloadNotFound - -class HttpMultiLinkDownloader: - def __init__(self): - self.downloads = [] - self.completed_downloads = [] - self.total_size = None - self.aria2 = aria2p.API( - aria2p.Client( - host="http://localhost", - port=6800, - secret="" - ) - ) - - def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None): - """Add multiple URLs to download queue with same options""" - options = {"dir": save_path} - if header: - options["header"] = header - if out: - options["out"] = out - - # Clear any existing downloads first - self.cancel_download() - self.completed_downloads = [] - self.total_size = total_size - - for url in urls: - try: - added_downloads = self.aria2.add(url, options=options) - self.downloads.extend(added_downloads) - except Exception as e: - print(f"Error adding download for URL {url}: {str(e)}") - - def pause_download(self): - """Pause all active downloads""" - if self.downloads: - try: - self.aria2.pause(self.downloads) - except Exception as e: - print(f"Error pausing downloads: {str(e)}") - - def cancel_download(self): - """Cancel and remove all downloads""" - if self.downloads: - try: - # First try to stop the downloads - self.aria2.remove(self.downloads) - except Exception as e: - print(f"Error removing downloads: {str(e)}") - finally: - # Clear the downloads list regardless of success/failure - self.downloads = [] - self.completed_downloads = [] - - def get_download_status(self): - """Get status for all tracked downloads, auto-remove completed/failed ones""" - if not self.downloads and not self.completed_downloads: - return [] - - total_completed = 0 - current_download_speed = 0 - active_downloads = [] - to_remove = [] - - # First calculate sizes from completed downloads - for completed in self.completed_downloads: - total_completed += completed['size'] - - # Then check active downloads - for download in self.downloads: - try: - current_download = self.aria2.get_download(download.gid) - - # Skip downloads that are not properly initialized - if not current_download or not current_download.files: - to_remove.append(download) - continue - - # Add to completed size and speed calculations - total_completed += current_download.completed_length - current_download_speed += current_download.download_speed - - # If download is complete, move it to completed_downloads - if current_download.status == 'complete': - self.completed_downloads.append({ - 'name': current_download.name, - 'size': current_download.total_length - }) - to_remove.append(download) - else: - active_downloads.append({ - 'name': current_download.name, - 'size': current_download.total_length, - 'completed': current_download.completed_length, - 'speed': current_download.download_speed - }) - - except DownloadNotFound: - to_remove.append(download) - continue - except Exception as e: - print(f"Error getting download status: {str(e)}") - continue - - # Clean up completed/removed downloads from active list - for download in to_remove: - try: - if download in self.downloads: - self.downloads.remove(download) - except ValueError: - pass - - # Return aggregate status - if self.total_size or active_downloads or self.completed_downloads: - # Use the first active download's name as the folder name, or completed if none active - folder_name = None - if active_downloads: - folder_name = active_downloads[0]['name'] - elif self.completed_downloads: - folder_name = self.completed_downloads[0]['name'] - - if folder_name and '/' in folder_name: - folder_name = folder_name.split('/')[0] - - # Use provided total size if available, otherwise sum from downloads - total_size = self.total_size - if not total_size: - total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads) - - # Calculate completion status based on total downloaded vs total size - is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences - - # If all downloads are complete, clear the completed_downloads list to prevent status updates - if is_complete: - self.completed_downloads = [] - - return [{ - 'folderName': folder_name, - 'fileSize': total_size, - 'progress': total_completed / total_size if total_size > 0 else 0, - 'downloadSpeed': current_download_speed, - 'numPeers': 0, - 'numSeeds': 0, - 'status': 'complete' if is_complete else 'active', - 'bytesDownloaded': total_completed, - }] - - return [] \ No newline at end of file diff --git a/python_rpc/main.py b/python_rpc/main.py index 99dd0d8c..295d4eff 100644 --- a/python_rpc/main.py +++ b/python_rpc/main.py @@ -3,7 +3,6 @@ import sys, json, urllib.parse, psutil from torrent_downloader import TorrentDownloader from http_downloader import HttpDownloader from profile_image_processor import ProfileImageProcessor -from http_multi_link_downloader import HttpMultiLinkDownloader import libtorrent as lt app = Flask(__name__) @@ -25,15 +24,7 @@ if start_download_payload: initial_download = json.loads(urllib.parse.unquote(start_download_payload)) downloading_game_id = initial_download['game_id'] - if isinstance(initial_download['url'], list): - # Handle multiple URLs using HttpMultiLinkDownloader - http_multi_downloader = HttpMultiLinkDownloader() - downloads[initial_download['game_id']] = http_multi_downloader - try: - http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out")) - except Exception as e: - print("Error starting multi-link download", e) - elif initial_download['url'].startswith('magnet'): + if initial_download['url'].startswith('magnet'): torrent_downloader = TorrentDownloader(torrent_session) downloads[initial_download['game_id']] = torrent_downloader try: @@ -78,14 +69,6 @@ def status(): if not status: return jsonify(None) - if isinstance(status, list): - if not status: # Empty list - return jsonify(None) - - # For multi-link downloader, use the aggregated status - # The status will already be aggregated by the HttpMultiLinkDownloader - return jsonify(status[0]), 200 - return jsonify(status), 200 @app.route("/seed-status", methods=["GET"]) @@ -104,21 +87,7 @@ def seed_status(): if not response: continue - if isinstance(response, list): - # For multi-link downloader, check if all files are complete - if response and all(item['status'] == 'complete' for item in response): - seed_status.append({ - 'gameId': game_id, - 'status': 'complete', - 'folderName': response[0]['folderName'], - 'fileSize': sum(item['fileSize'] for item in response), - 'bytesDownloaded': sum(item['bytesDownloaded'] for item in response), - 'downloadSpeed': 0, - 'numPeers': 0, - 'numSeeds': 0, - 'progress': 1.0 - }) - elif response.get('status') == 5: # Original torrent seeding check + if response.get('status') == 5: # Torrent seeding check seed_status.append({ 'gameId': game_id, **response, @@ -180,15 +149,7 @@ def action(): existing_downloader = downloads.get(game_id) - if isinstance(url, list): - # Handle multiple URLs using HttpMultiLinkDownloader - if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader): - existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out')) - else: - http_multi_downloader = HttpMultiLinkDownloader() - downloads[game_id] = http_multi_downloader - http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out')) - elif url.startswith('magnet'): + if url.startswith('magnet'): if existing_downloader and isinstance(existing_downloader, TorrentDownloader): existing_downloader.start_download(url, data['save_path']) else: From 9298d9aa09ed781588ad8feba5280c8d752edeb7 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 11 Jan 2026 17:50:16 +0200 Subject: [PATCH 19/20] fix: enable native HTTP downloader in settings --- src/renderer/src/pages/settings/settings-general.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index 466cfc98..fd1dd034 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -53,7 +53,7 @@ export function SettingsGeneral() { achievementSoundVolume: 15, language: "", customStyles: window.localStorage.getItem("customStyles") || "", - useNativeHttpDownloader: false, + useNativeHttpDownloader: true, }); const [languageOptions, setLanguageOptions] = useState([]); @@ -133,7 +133,7 @@ export function SettingsGeneral() { userPreferences.friendStartGameNotificationsEnabled ?? true, language: language ?? "en", useNativeHttpDownloader: - userPreferences.useNativeHttpDownloader ?? false, + userPreferences.useNativeHttpDownloader ?? true, })); } }, [userPreferences, defaultDownloadsPath]); From dba8f9fb227450a5e1d1918ea85fd8c9d399f11d Mon Sep 17 00:00:00 2001 From: Moyasee Date: Sun, 11 Jan 2026 19:13:51 +0200 Subject: [PATCH 20/20] feat: add disabled hint for HTTP downloader setting during active downloads and update z-index for error message --- src/locales/en/translation.json | 3 ++- .../src/pages/settings/settings-general.scss | 7 +++++++ .../src/pages/settings/settings-general.tsx | 13 +++++++++++++ src/renderer/src/scss/globals.scss | 2 +- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4b5ac89b..bf6d26ff 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -600,7 +600,8 @@ "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", "downloads": "Downloads", - "use_native_http_downloader": "Use native HTTP downloader (experimental)" + "use_native_http_downloader": "Use native HTTP downloader (experimental)", + "cannot_change_downloader_while_downloading": "Cannot change this setting while a download is in progress" }, "notifications": { "download_complete": "Download complete", diff --git a/src/renderer/src/pages/settings/settings-general.scss b/src/renderer/src/pages/settings/settings-general.scss index 8a6a0ac1..9f9d698f 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -18,6 +18,13 @@ align-self: flex-start; } + &__disabled-hint { + font-size: 13px; + color: globals.$muted-color; + margin-top: calc(globals.$spacing-unit * -1); + font-style: italic; + } + &__volume-control { display: flex; flex-direction: column; diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index fd1dd034..52dd43f8 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -37,6 +37,12 @@ export function SettingsGeneral() { (state) => state.userPreferences.value ); + const lastPacket = useAppSelector((state) => state.download.lastPacket); + const hasActiveDownload = + lastPacket !== null && + lastPacket.progress < 1 && + !lastPacket.isDownloadingMetadata; + const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false); const [installingCommonRedist, setInstallingCommonRedist] = useState(false); @@ -256,6 +262,7 @@ export function SettingsGeneral() { handleChange({ useNativeHttpDownloader: !form.useNativeHttpDownloader, @@ -263,6 +270,12 @@ export function SettingsGeneral() { } /> + {hasActiveDownload && ( +

+ {t("cannot_change_downloader_while_downloading")} +

+ )} +

{t("notifications")}