mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 22:06:17 +00:00
Compare commits
17 Commits
v3.8.0
...
5e4e03a958
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4e03a958 | ||
|
|
da0ae54b60 | ||
|
|
562e30eecf | ||
|
|
ed044d797f | ||
|
|
ed3cce160f | ||
|
|
c67b275657 | ||
|
|
a2e866317d | ||
|
|
a7c82de4a7 | ||
|
|
027761a1b5 | ||
|
|
ca2f70aede | ||
|
|
2b3a8bf6b6 | ||
|
|
81b3ad3612 | ||
|
|
8f477072ba | ||
|
|
569700e85c | ||
|
|
4975f2def9 | ||
|
|
77af7509ac | ||
|
|
7e7390885e |
@@ -404,6 +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? 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…",
|
||||
@@ -594,7 +598,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",
|
||||
|
||||
@@ -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,11 @@ export const loadState = async () => {
|
||||
|
||||
await import("./events");
|
||||
|
||||
Aria2.spawn();
|
||||
// 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();
|
||||
}
|
||||
|
||||
if (userPreferences?.realDebridApiToken) {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
@@ -71,18 +76,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 +124,23 @@ 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;
|
||||
// Default to true - native HTTP downloader is enabled by default
|
||||
const useJsDownloader =
|
||||
(userPreferences?.useNativeHttpDownloader ?? true) && !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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -68,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<string, string>
|
||||
) {
|
||||
return {
|
||||
url,
|
||||
savePath,
|
||||
filename,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
private static createDownloadPayload(
|
||||
directUrl: string,
|
||||
originalUrl: string,
|
||||
@@ -99,6 +134,19 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
|
||||
private static async shouldUseJsDownloader(): Promise<boolean> {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
// Default to true - native HTTP downloader is enabled by default (opt-out)
|
||||
return userPreferences?.useNativeHttpDownloader ?? true;
|
||||
}
|
||||
|
||||
private static isHttpDownloader(downloader: Downloader): boolean {
|
||||
return downloader !== Downloader.Torrent;
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
@@ -123,7 +171,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 +279,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,105 +300,141 @@ 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();
|
||||
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<string, UserPreferences | null>(
|
||||
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<string, UserPreferences | null>(
|
||||
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",
|
||||
JSON.parse(JSON.stringify({ ...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;
|
||||
}
|
||||
}
|
||||
if (nextItemOnQueue) {
|
||||
this.resumeDownload(nextItemOnQueue);
|
||||
} else {
|
||||
this.downloadingGameId = null;
|
||||
this.usingJsDownloader = false;
|
||||
this.jsDownloader = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +474,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 +497,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 +533,241 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
private static async getJsDownloadOptions(download: Download): Promise<{
|
||||
url: string;
|
||||
savePath: string;
|
||||
filename?: string;
|
||||
headers?: Record<string, string>;
|
||||
} | null> {
|
||||
const resumingFilename = download.folderName || undefined;
|
||||
|
||||
switch (download.downloader) {
|
||||
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);
|
||||
|
||||
@@ -518,31 +917,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
380
src/main/services/download/js-http-downloader.ts
Normal file
380
src/main/services/download/js-http-downloader.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
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 {
|
||||
// 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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
201
src/main/services/download/js-multi-link-downloader.ts
Normal file
201
src/main/services/download/js-multi-link-downloader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function App() {
|
||||
|
||||
const { clearDownload, setLastPacket } = useDownload();
|
||||
|
||||
const wokwondersRef = useRef<WorkWondersSdk | null>(null);
|
||||
const workwondersRef = useRef<WorkWondersSdk | null>(null);
|
||||
|
||||
const {
|
||||
hasActiveSubscription,
|
||||
@@ -118,24 +118,25 @@ export function App() {
|
||||
|
||||
const setupWorkWonders = useCallback(
|
||||
async (token?: string, locale?: string) => {
|
||||
if (wokwondersRef.current) return;
|
||||
if (workwondersRef.current) return;
|
||||
|
||||
const possibleLocales = ["en", "pt", "ru"];
|
||||
|
||||
const parsedLocale =
|
||||
possibleLocales.find((l) => l === locale?.slice(0, 2)) ?? "en";
|
||||
|
||||
wokwondersRef.current = new WorkWondersSdk();
|
||||
await wokwondersRef.current.init({
|
||||
workwondersRef.current = new WorkWondersSdk();
|
||||
await workwondersRef.current.init({
|
||||
organization: "hydra",
|
||||
token,
|
||||
locale: parsedLocale,
|
||||
});
|
||||
|
||||
await wokwondersRef.current.initChangelogWidget();
|
||||
wokwondersRef.current.initChangelogWidgetMini();
|
||||
await workwondersRef.current.initChangelogWidget();
|
||||
workwondersRef.current.initChangelogWidgetMini();
|
||||
workwondersRef.current.initFeedbackWidget();
|
||||
},
|
||||
[wokwondersRef]
|
||||
[workwondersRef]
|
||||
);
|
||||
|
||||
const setupExternalResources = useCallback(async () => {
|
||||
@@ -232,7 +233,7 @@ export function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) contentRef.current.scrollTop = 0;
|
||||
wokwondersRef.current?.notifyUrlChange();
|
||||
workwondersRef.current?.notifyUrlChange();
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HeroDownloadViewProps>) {
|
||||
const navigate = useNavigate();
|
||||
@@ -353,7 +353,7 @@ function HeroDownloadView({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelDownload(game.shop, game.objectId)}
|
||||
onClick={() => onCancelClick(game.shop, game.objectId)}
|
||||
className="download-group__glass-btn"
|
||||
>
|
||||
<XCircleIcon size={14} />
|
||||
@@ -523,6 +523,13 @@ export function DownloadGroup({
|
||||
const [optimisticallyResumed, setOptimisticallyResumed] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [cancelModalVisible, setCancelModalVisible] = useState(false);
|
||||
const [gameToCancelShop, setGameToCancelShop] = useState<GameShop | null>(
|
||||
null
|
||||
);
|
||||
const [gameToCancelObjectId, setGameToCancelObjectId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const extractDominantColor = useCallback(
|
||||
async (imageUrl: string, gameId: string) => {
|
||||
@@ -613,11 +620,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";
|
||||
};
|
||||
|
||||
@@ -651,6 +665,27 @@ export function DownloadGroup({
|
||||
[updateLibrary]
|
||||
);
|
||||
|
||||
const handleCancelClick = useCallback((shop: GameShop, objectId: string) => {
|
||||
setGameToCancelShop(shop);
|
||||
setGameToCancelObjectId(objectId);
|
||||
setCancelModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmCancel = useCallback(async () => {
|
||||
if (gameToCancelShop && gameToCancelObjectId) {
|
||||
await cancelDownload(gameToCancelShop, gameToCancelObjectId);
|
||||
}
|
||||
setCancelModalVisible(false);
|
||||
setGameToCancelShop(null);
|
||||
setGameToCancelObjectId(null);
|
||||
}, [gameToCancelShop, gameToCancelObjectId, cancelDownload]);
|
||||
|
||||
const handleCancelModalClose = useCallback(() => {
|
||||
setCancelModalVisible(false);
|
||||
setGameToCancelShop(null);
|
||||
setGameToCancelObjectId(null);
|
||||
}, []);
|
||||
|
||||
const getGameActions = (game: LibraryGame): DropdownMenuItem[] => {
|
||||
const download = lastPacket?.download;
|
||||
const isGameDownloading = isGameDownloadingMap[game.id];
|
||||
@@ -721,7 +756,7 @@ export function DownloadGroup({
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
handleCancelClick(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
@@ -746,7 +781,7 @@ export function DownloadGroup({
|
||||
{
|
||||
label: t("cancel"),
|
||||
onClick: () => {
|
||||
cancelDownload(game.shop, game.objectId);
|
||||
handleCancelClick(game.shop, game.objectId);
|
||||
},
|
||||
icon: <XCircleIcon />,
|
||||
},
|
||||
@@ -804,136 +839,162 @@ export function DownloadGroup({
|
||||
const dominantColor = dominantColors[game.id] || "#fff";
|
||||
|
||||
return (
|
||||
<HeroDownloadView
|
||||
game={game}
|
||||
isGameDownloading={isGameDownloading}
|
||||
isGameExtracting={isGameExtracting}
|
||||
downloadSpeed={downloadSpeed}
|
||||
finalDownloadSize={finalDownloadSize}
|
||||
peakSpeed={peakSpeed}
|
||||
currentProgress={currentProgress}
|
||||
dominantColor={dominantColor}
|
||||
lastPacket={lastPacket}
|
||||
speedHistory={gameSpeedHistory}
|
||||
formatSpeed={formatSpeed}
|
||||
calculateETA={calculateETA}
|
||||
pauseDownload={pauseDownload}
|
||||
resumeDownload={resumeDownload}
|
||||
cancelDownload={cancelDownload}
|
||||
t={t}
|
||||
/>
|
||||
<>
|
||||
<ConfirmationModal
|
||||
visible={cancelModalVisible}
|
||||
title={t("cancel_download")}
|
||||
descriptionText={t("cancel_download_description")}
|
||||
confirmButtonLabel={t("yes_cancel")}
|
||||
cancelButtonLabel={t("keep_downloading")}
|
||||
onConfirm={handleConfirmCancel}
|
||||
onClose={handleCancelModalClose}
|
||||
/>
|
||||
<HeroDownloadView
|
||||
game={game}
|
||||
isGameDownloading={isGameDownloading}
|
||||
isGameExtracting={isGameExtracting}
|
||||
downloadSpeed={downloadSpeed}
|
||||
finalDownloadSize={finalDownloadSize}
|
||||
peakSpeed={peakSpeed}
|
||||
currentProgress={currentProgress}
|
||||
dominantColor={dominantColor}
|
||||
lastPacket={lastPacket}
|
||||
speedHistory={gameSpeedHistory}
|
||||
formatSpeed={formatSpeed}
|
||||
calculateETA={calculateETA}
|
||||
pauseDownload={pauseDownload}
|
||||
resumeDownload={resumeDownload}
|
||||
onCancelClick={handleCancelClick}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
||||
>
|
||||
<div className="download-group__header">
|
||||
<div className="download-group__header-title-group">
|
||||
<h2>{title}</h2>
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
<>
|
||||
<ConfirmationModal
|
||||
visible={cancelModalVisible}
|
||||
title={t("cancel_download")}
|
||||
descriptionText={t("cancel_download_description")}
|
||||
confirmButtonLabel={t("yes_cancel")}
|
||||
cancelButtonLabel={t("keep_downloading")}
|
||||
onConfirm={handleConfirmCancel}
|
||||
onClose={handleCancelModalClose}
|
||||
/>
|
||||
<div
|
||||
className={`download-group ${isQueuedGroup ? "download-group--queued" : ""} ${isCompletedGroup ? "download-group--completed" : ""}`}
|
||||
>
|
||||
<div className="download-group__header">
|
||||
<div className="download-group__header-title-group">
|
||||
<h2>{title}</h2>
|
||||
<h3 className="download-group__header-count">{library.length}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="download-group__simple-list">
|
||||
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
|
||||
return (
|
||||
<li key={game.id} className="download-group__simple-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
className="download-group__simple-thumbnail"
|
||||
>
|
||||
<img src={game.libraryImageUrl || ""} alt={game.title} />
|
||||
</button>
|
||||
|
||||
<div className="download-group__simple-info">
|
||||
<ul className="download-group__simple-list">
|
||||
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
|
||||
return (
|
||||
<li key={game.id} className="download-group__simple-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
className="download-group__simple-title-button"
|
||||
className="download-group__simple-thumbnail"
|
||||
>
|
||||
<h3 className="download-group__simple-title">{game.title}</h3>
|
||||
<img src={game.libraryImageUrl || ""} alt={game.title} />
|
||||
</button>
|
||||
<div className="download-group__simple-meta">
|
||||
<div className="download-group__simple-meta-row">
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="download-group__simple-meta-row">
|
||||
{extraction?.visibleId === game.id ? (
|
||||
<span className="download-group__simple-extracting">
|
||||
{t("extracting")} (
|
||||
{Math.round(extraction.progress * 100)}%)
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__simple-size">
|
||||
<DownloadIcon size={14} />
|
||||
{size}
|
||||
</span>
|
||||
)}
|
||||
{game.download?.progress === 1 && seeding && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="download-group__simple-info">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(buildGameDetailsPath(game))}
|
||||
className="download-group__simple-title-button"
|
||||
>
|
||||
<h3 className="download-group__simple-title">
|
||||
{game.title}
|
||||
</h3>
|
||||
</button>
|
||||
<div className="download-group__simple-meta">
|
||||
<div className="download-group__simple-meta-row">
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="download-group__simple-meta-row">
|
||||
{extraction?.visibleId === game.id ? (
|
||||
<span className="download-group__simple-extracting">
|
||||
{t("extracting")} (
|
||||
{Math.round(extraction.progress * 100)}%)
|
||||
</span>
|
||||
) : (
|
||||
<span className="download-group__simple-size">
|
||||
<DownloadIcon size={14} />
|
||||
{size}
|
||||
</span>
|
||||
)}
|
||||
{game.download?.progress === 1 && seeding && (
|
||||
<span className="download-group__simple-seeding">
|
||||
{t("seeding")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isQueuedGroup && (
|
||||
<div className="download-group__simple-progress">
|
||||
<span className="download-group__simple-progress-text">
|
||||
{formatDownloadProgress(progress)}
|
||||
</span>
|
||||
<div className="download-group__progress-bar download-group__progress-bar--small">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
{isQueuedGroup && (
|
||||
<div className="download-group__simple-progress">
|
||||
<span className="download-group__simple-progress-text">
|
||||
{formatDownloadProgress(progress)}
|
||||
</span>
|
||||
<div className="download-group__progress-bar download-group__progress-bar--small">
|
||||
<div
|
||||
className="download-group__progress-fill"
|
||||
style={{
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: "#fff",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="download-group__simple-actions">
|
||||
{game.download?.progress === 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => openGameInstaller(game.shop, game.objectId)}
|
||||
disabled={isGameDeleting(game.id)}
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{isQueuedGroup && game.download?.progress !== 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__simple-menu-btn"
|
||||
tooltip={t("resume")}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button
|
||||
theme="outline"
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="download-group__simple-actions">
|
||||
{game.download?.progress === 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() =>
|
||||
openGameInstaller(game.shop, game.objectId)
|
||||
}
|
||||
disabled={isGameDeleting(game.id)}
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<PlayIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
{isQueuedGroup && game.download?.progress !== 1 && (
|
||||
<Button
|
||||
theme="primary"
|
||||
onClick={() => resumeDownload(game.shop, game.objectId)}
|
||||
className="download-group__simple-menu-btn"
|
||||
tooltip={t("resume")}
|
||||
>
|
||||
<DownloadIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu align="end" items={getGameActions(game)}>
|
||||
<Button
|
||||
theme="outline"
|
||||
className="download-group__simple-menu-btn"
|
||||
>
|
||||
<ThreeBarsIcon />
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface UserPreferences {
|
||||
autoplayGameTrailers?: boolean;
|
||||
hideToTrayOnGameStart?: boolean;
|
||||
enableNewDownloadOptionsBadges?: boolean;
|
||||
useNativeHttpDownloader?: boolean;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
|
||||
Reference in New Issue
Block a user