Compare commits

...

13 Commits

Author SHA1 Message Date
Moyasee
ed044d797f refactor: streamline download preparation and status handling in DownloadManager 2026-01-07 20:35:43 +02:00
Moyasee
ed3cce160f refactor: adjust file size handling in DownloadManager to ensure accurate download status updates 2026-01-07 17:32:46 +02:00
Moyasee
c67b275657 refactor: improve download initiation and error handling in DownloadManager 2026-01-07 17:26:29 +02:00
Moyasee
a2e866317d refactor: update interruptedDownload type to Download | null for improved type safety 2026-01-06 20:01:15 +02:00
Moyasee
a7c82de4a7 refactor: enhance download management by prioritizing interrupted downloads and improving error logging 2026-01-06 19:59:52 +02:00
Moyasee
027761a1b5 refactor: update cancelDownload method to conditionally delete file based on parameter 2026-01-06 19:18:42 +02:00
Moyasee
ca2f70aede refactor: enhance filename extraction and handling in download services 2026-01-06 18:50:36 +02:00
Moyasee
2b3a8bf6b6 refactor: replace type assertions with non-null assertions for download ID in DownloadManager 2026-01-06 18:11:22 +02:00
Moyasee
81b3ad3612 refactor: update download ID extraction and improve optional chaining in download services 2026-01-06 18:05:05 +02:00
Moyasee
8f477072ba refactor: improve error handling and download path preparation in JsHttpDownloader 2026-01-06 17:56:46 +02:00
Moyasee
569700e85c refactor: streamline download status updates in DownloadManager 2026-01-06 17:47:12 +02:00
Moyasee
4975f2def9 refactor: optimize chunk handling in JsHttpDownloader 2026-01-06 17:42:42 +02:00
Moyasee
77af7509ac feat: implement native HTTP downloader option and enhance download management 2026-01-06 17:41:05 +02:00
10 changed files with 1024 additions and 69 deletions

View File

@@ -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",

View File

@@ -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,
@@ -18,6 +18,7 @@ import {
DeckyPlugin,
DownloadSourcesChecker,
WSClient,
logger,
} from "@main/services";
import { migrateDownloadSources } from "./helpers/migrate-download-sources";
@@ -33,7 +34,9 @@ export const loadState = async () => {
await import("./events");
Aria2.spawn();
if (!userPreferences?.useNativeHttpDownloader) {
Aria2.spawn();
}
if (userPreferences?.realDebridApiToken) {
RealDebridClient.authorize(userPreferences.realDebridApiToken);
@@ -71,18 +74,47 @@ export const loadState = async () => {
return orderBy(games, "timestamp", "desc");
});
downloads.forEach((download) => {
let interruptedDownload: Download | null = 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 &&
@@ -90,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();

View File

@@ -8,7 +8,6 @@ import {
DatanodesApi,
MediafireApi,
PixelDrainApi,
VikingFileApi,
} from "../hosters";
import { PythonRPC } from "../python-rpc";
import {
@@ -18,17 +17,25 @@ 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 isPreparingDownload = false;
private static extractFilename(
url: string,
@@ -52,7 +59,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 +106,18 @@ export class DownloadManager {
};
}
private static async shouldUseJsDownloader(): Promise<boolean> {
const userPreferences = await db.get<string, UserPreferences | null>(
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 +142,87 @@ export class DownloadManager {
}
}
private static async getDownloadStatus() {
private static async getDownloadStatusFromJs(): Promise<DownloadProgress | 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;
try {
const download = await downloadsSublevel.get(downloadId);
if (!download) return null;
const { progress, downloadSpeed, bytesDownloaded, fileSize, folderName } =
status;
// 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: effectiveFileSize,
progress,
folderName,
status:
status.status === "complete"
? ("complete" as const)
: ("active" as const),
};
if (status.status === "active" || status.status === "complete") {
await downloadsSublevel.put(downloadId, updatedDownload);
}
return {
numPeers: 0,
numSeeds: 0,
downloadSpeed,
timeRemaining: calculateETA(
effectiveFileSize ?? 0,
bytesDownloaded,
downloadSpeed
),
isDownloadingMetadata: false,
isCheckingFiles: false,
progress,
gameId: downloadId,
download: updatedDownload,
};
} catch (err) {
logger.error("[DownloadManager] Error getting JS download status:", err);
return null;
}
}
private static async getDownloadStatusFromRpc(): Promise<DownloadProgress | null> {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
@@ -151,28 +250,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 +271,18 @@ export class DownloadManager {
gameId: downloadId,
download,
} as DownloadProgress;
} catch (err) {
} catch {
return null;
}
}
private static async getDownloadStatus(): Promise<DownloadProgress | null> {
if (this.usingJsDownloader) {
return this.getDownloadStatusFromJs();
}
return this.getDownloadStatusFromRpc();
}
public static async watchDownloads() {
const status = await this.getDownloadStatus();
@@ -213,7 +305,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 +375,8 @@ export class DownloadManager {
this.resumeDownload(nextItemOnQueue);
} else {
this.downloadingGameId = null;
this.usingJsDownloader = false;
this.jsDownloader = null;
}
}
}
@@ -324,12 +418,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,14 +441,23 @@ 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 if (!this.isPreparingDownload) {
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);
WindowManager.mainWindow?.webContents.send("on-download-progress", null);
this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
}
}
@@ -369,6 +477,166 @@ export class DownloadManager {
});
}
private static async getJsDownloadOptions(download: Download): Promise<{
url: string;
savePath: string;
filename?: string;
headers?: Record<string, string>;
} | 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);
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: {
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);
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: {
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);
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: {
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 +786,62 @@ 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);
const downloadId = levelKeys.game(download.shop, download.objectId);
if (useJsDownloader && isHttp) {
logger.log("[DownloadManager] Using JS HTTP downloader");
// Set preparing state immediately so UI knows download is starting
this.downloadingGameId = downloadId;
this.isPreparingDownload = true;
this.usingJsDownloader = true;
try {
const options = await this.getJsDownloadOptions(download);
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;
});
} 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 = downloadId;
this.usingJsDownloader = false;
}
}
}

View File

@@ -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";

View File

@@ -0,0 +1,376 @@
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<string, string>;
}
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<void> {
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 { filePath, startByte, usedFallback } = this.prepareDownloadPath(
savePath,
filename,
url
);
const requestHeaders = this.buildRequestHeaders(headers, startByte);
try {
await this.executeDownload(
url,
requestHeaders,
filePath,
startByte,
savePath,
usedFallback
);
} 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; usedFallback: boolean } {
const extractedFilename = filename || this.extractFilename(url);
const usedFallback = !extractedFilename;
const resolvedFilename = extractedFilename || "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}`);
}
this.resetSpeedTracking();
return { filePath, startByte, usedFallback };
}
private buildRequestHeaders(
headers: Record<string, string>,
startByte: number
): Record<string, string> {
const requestHeaders: Record<string, string> = { ...headers };
if (startByte > 0) {
requestHeaders["Range"] = `bytes=${startByte}-`;
}
return requestHeaders;
}
private resetSpeedTracking(): void {
this.lastSpeedUpdate = Date.now();
this.bytesAtLastSpeedUpdate = this.bytesDownloaded;
this.downloadSpeed = 0;
}
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");
if (contentLength) {
this.fileSize = startByte + Number.parseInt(contentLength, 10);
}
}
private async executeDownload(
url: string,
requestHeaders: Record<string, string>,
filePath: string,
startByte: number,
savePath: string,
usedFallback: boolean
): Promise<void> {
const response = await fetch(url, {
headers: requestHeaders,
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}`);
}
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(actualFilePath, { 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 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<Uint8Array>
): 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: Error) => {
if (err.name === "AbortError") {
this.push(null);
} else {
this.destroy(err);
}
});
},
});
}
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;
}
}
private async resumeDownload(): Promise<void> {
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(deleteFile = true): void {
if (this.abortController) {
logger.log("[JsHttpDownloader] Cancelling download");
this.abortController.abort();
}
this.cleanup();
if (deleteFile && this.currentOptions && this.status !== "complete") {
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.at(-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;
}
}

View File

@@ -0,0 +1,201 @@
import { JsHttpDownloader, JsHttpDownloaderStatus } from "./js-http-downloader";
import { logger } from "../logger";
export interface JsMultiLinkDownloaderOptions {
urls: string[];
savePath: string;
headers?: Record<string, string>;
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<void> {
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<void> {
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<void> {
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;
}
}

View File

@@ -31,11 +31,16 @@ export const downloadSlice = createSlice({
reducers: {
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
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;

View File

@@ -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";
};

View File

@@ -53,6 +53,7 @@ export function SettingsGeneral() {
achievementSoundVolume: 15,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
useNativeHttpDownloader: false,
});
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
@@ -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() {
}))}
/>
<h2 className="settings-general__section-title">{t("downloads")}</h2>
<CheckboxField
label={t("use_native_http_downloader")}
checked={form.useNativeHttpDownloader}
onChange={() =>
handleChange({
useNativeHttpDownloader: !form.useNativeHttpDownloader,
})
}
/>
<h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField

View File

@@ -128,6 +128,7 @@ export interface UserPreferences {
autoplayGameTrailers?: boolean;
hideToTrayOnGameStart?: boolean;
enableNewDownloadOptionsBadges?: boolean;
useNativeHttpDownloader?: boolean;
}
export interface ScreenState {