diff --git a/package.json b/package.json index 275a63da..5ab8ba46 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,7 @@ "sound-play": "^1.1.0", "steam-shortcut-editor": "https://github.com/hydralauncher/steam-shortcut-editor", "sudo-prompt": "^9.2.1", - "tar": "^7.5.2", + "tar": "^7.5.4", "tough-cookie": "^5.1.1", "user-agents": "^1.1.387", "uuid": "^13.0.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a63eb860..3a9cd44d 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -185,6 +185,7 @@ "repacks_modal_description": "Choose the repack you want to download", "select_folder_hint": "To change the default folder, go to the <0>Settings", "download_now": "Download now", + "add_to_queue": "Add to queue", "loading": "Loading...", "no_shop_details": "Could not retrieve shop details.", "download_options": "Download options", @@ -447,7 +448,9 @@ "yes": "Yes", "no": "No", "network": "NETWORK", - "peak": "PEAK" + "peak": "PEAK", + "move_up": "Move up", + "move_down": "Move down" }, "settings": { "downloads_path": "Downloads path", diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index b9cef25b..1bfa36e0 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -15,14 +15,7 @@ const deleteGameFolder = async ( const downloadKey = levelKeys.game(shop, objectId); const download = await downloadsSublevel.get(downloadKey); - if (!download?.folderName) return; - - const folderPath = path.join( - download.downloadPath ?? (await getDownloadsPath()), - download.folderName - ); - - const metaPath = `${folderPath}.meta`; + if (!download) return; const deleteFile = async (filePath: string, isDirectory = false) => { if (fs.existsSync(filePath)) { @@ -47,8 +40,18 @@ const deleteGameFolder = async ( } }; - await deleteFile(folderPath, true); - await deleteFile(metaPath); + if (download.folderName) { + const folderPath = path.join( + download.downloadPath ?? (await getDownloadsPath()), + download.folderName + ); + + const metaPath = `${folderPath}.meta`; + + await deleteFile(folderPath, true); + await deleteFile(metaPath); + } + await downloadsSublevel.del(downloadKey); }; diff --git a/src/main/events/library/get-game-installer-action-type.ts b/src/main/events/library/get-game-installer-action-type.ts index 2e58968a..7d49c642 100644 --- a/src/main/events/library/get-game-installer-action-type.ts +++ b/src/main/events/library/get-game-installer-action-type.ts @@ -22,7 +22,6 @@ const getGameInstallerActionType = async ( ); if (!fs.existsSync(gamePath)) { - await downloadsSublevel.del(downloadKey); return "open-folder"; } diff --git a/src/main/events/library/open-game-installer.ts b/src/main/events/library/open-game-installer.ts index 9cf1d978..57feeb87 100644 --- a/src/main/events/library/open-game-installer.ts +++ b/src/main/events/library/open-game-installer.ts @@ -38,7 +38,6 @@ const openGameInstaller = async ( ); if (!fs.existsSync(gamePath)) { - await downloadsSublevel.del(downloadKey); return true; } diff --git a/src/main/events/torrenting/add-game-to-queue.ts b/src/main/events/torrenting/add-game-to-queue.ts new file mode 100644 index 00000000..f57cfaf6 --- /dev/null +++ b/src/main/events/torrenting/add-game-to-queue.ts @@ -0,0 +1,78 @@ +import { registerEvent } from "../register-event"; +import type { Download, StartGameDownloadPayload } from "@types"; +import { DownloadManager, HydraApi, logger } from "@main/services"; +import { createGame } from "@main/services/library-sync"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { parseBytes } from "@shared"; +import { handleDownloadError, prepareGameEntry } from "@main/helpers"; + +const addGameToQueue = async ( + _event: Electron.IpcMainInvokeEvent, + payload: StartGameDownloadPayload +) => { + const { + objectId, + title, + shop, + downloadPath, + downloader, + uri, + automaticallyExtract, + fileSize, + } = payload; + + const gameKey = levelKeys.game(shop, objectId); + + const download: Download = { + shop, + objectId, + status: "paused", + progress: 0, + bytesDownloaded: 0, + downloadPath, + downloader, + uri, + folderName: null, + fileSize: parseBytes(fileSize ?? null), + shouldSeed: false, + timestamp: Date.now(), + queued: true, + extracting: false, + automaticallyExtract, + extractionProgress: 0, + }; + + try { + await DownloadManager.validateDownloadUrl(download); + } catch (err: unknown) { + logger.error("Failed to validate download URL for queue", err); + return handleDownloadError(err, downloader); + } + + await prepareGameEntry({ gameKey, title, objectId, shop }); + + try { + await downloadsSublevel.put(gameKey, download); + + const updatedGame = await gamesSublevel.get(gameKey); + + await Promise.all([ + createGame(updatedGame!).catch(() => {}), + HydraApi.post(`/games/${shop}/${objectId}/download`, null, { + needsAuth: false, + }).catch(() => {}), + ]); + + return { ok: true }; + } catch (err: unknown) { + logger.error("Failed to add game to queue", err); + + if (err instanceof Error) { + return { ok: false, error: err.message }; + } + + return { ok: false }; + } +}; + +registerEvent("addGameToQueue", addGameToQueue); diff --git a/src/main/events/torrenting/index.ts b/src/main/events/torrenting/index.ts index 408ecf17..143135da 100644 --- a/src/main/events/torrenting/index.ts +++ b/src/main/events/torrenting/index.ts @@ -1,3 +1,4 @@ +import "./add-game-to-queue"; import "./cancel-game-download"; import "./check-debrid-availability"; import "./pause-game-download"; @@ -5,3 +6,4 @@ import "./pause-game-seed"; import "./resume-game-download"; import "./resume-game-seed"; import "./start-game-download"; +import "./update-download-queue-position"; diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index e44ba936..181dd532 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -2,14 +2,8 @@ import { registerEvent } from "../register-event"; import type { Download, StartGameDownloadPayload } from "@types"; import { DownloadManager, HydraApi, logger } from "@main/services"; import { createGame } from "@main/services/library-sync"; -import { Downloader, DownloadError } from "@shared"; -import { - downloadsSublevel, - gamesShopAssetsSublevel, - gamesSublevel, - levelKeys, -} from "@main/level"; -import { AxiosError } from "axios"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { handleDownloadError, prepareGameEntry } from "@main/helpers"; const startGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -38,30 +32,7 @@ const startGameDownload = async ( } } - const game = await gamesSublevel.get(gameKey); - const gameAssets = await gamesShopAssetsSublevel.get(gameKey); - - await downloadsSublevel.del(gameKey); - - if (game) { - await gamesSublevel.put(gameKey, { - ...game, - isDeleted: false, - }); - } else { - await gamesSublevel.put(gameKey, { - title, - iconUrl: gameAssets?.iconUrl ?? null, - libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, - logoImageUrl: gameAssets?.logoImageUrl ?? null, - objectId, - shop, - remoteId: null, - playTimeInMilliseconds: 0, - lastTimePlayed: null, - isDeleted: false, - }); - } + await prepareGameEntry({ gameKey, title, objectId, shop }); await DownloadManager.cancelDownload(gameKey); @@ -101,68 +72,7 @@ const startGameDownload = async ( return { ok: true }; } catch (err: unknown) { logger.error("Failed to start download", err); - - if (err instanceof AxiosError) { - if (err.response?.status === 429 && downloader === Downloader.Gofile) { - return { ok: false, error: DownloadError.GofileQuotaExceeded }; - } - - if ( - err.response?.status === 403 && - downloader === Downloader.RealDebrid - ) { - return { - ok: false, - error: DownloadError.RealDebridAccountNotAuthorized, - }; - } - - if (downloader === Downloader.TorBox) { - return { ok: false, error: err.response?.data?.detail }; - } - } - - if (err instanceof Error) { - if (downloader === Downloader.Buzzheavier) { - if (err.message.includes("Rate limit")) { - return { - ok: false, - error: "Buzzheavier: Rate limit exceeded", - }; - } - if ( - err.message.includes("not found") || - err.message.includes("deleted") - ) { - return { - ok: false, - error: "Buzzheavier: File not found", - }; - } - } - - if (downloader === Downloader.FuckingFast) { - if (err.message.includes("Rate limit")) { - return { - ok: false, - error: "FuckingFast: Rate limit exceeded", - }; - } - if ( - err.message.includes("not found") || - err.message.includes("deleted") - ) { - return { - ok: false, - error: "FuckingFast: File not found", - }; - } - } - - return { ok: false, error: err.message }; - } - - return { ok: false }; + return handleDownloadError(err, downloader); } }; diff --git a/src/main/events/torrenting/update-download-queue-position.ts b/src/main/events/torrenting/update-download-queue-position.ts new file mode 100644 index 00000000..5672d63d --- /dev/null +++ b/src/main/events/torrenting/update-download-queue-position.ts @@ -0,0 +1,67 @@ +import { registerEvent } from "../register-event"; +import { downloadsSublevel, levelKeys } from "@main/level"; +import { GameShop } from "@types"; +import { orderBy } from "lodash-es"; + +const updateDownloadQueuePosition = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string, + direction: "up" | "down" +) => { + const gameKey = levelKeys.game(shop, objectId); + + const download = await downloadsSublevel.get(gameKey); + + if (!download || !download.queued || download.status !== "paused") { + return false; + } + + const allDownloads = await downloadsSublevel.values().all(); + + const queuedDownloads = orderBy( + allDownloads.filter((d) => d.status === "paused" && d.queued), + "timestamp", + "desc" + ); + + const currentIndex = queuedDownloads.findIndex( + (d) => d.shop === shop && d.objectId === objectId + ); + + if (currentIndex === -1) { + return false; + } + + const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= queuedDownloads.length) { + return false; + } + + const currentDownload = queuedDownloads[currentIndex]; + const adjacentDownload = queuedDownloads[targetIndex]; + + const currentKey = levelKeys.game( + currentDownload.shop, + currentDownload.objectId + ); + const adjacentKey = levelKeys.game( + adjacentDownload.shop, + adjacentDownload.objectId + ); + + const tempTimestamp = currentDownload.timestamp; + await downloadsSublevel.put(currentKey, { + ...currentDownload, + timestamp: adjacentDownload.timestamp, + }); + await downloadsSublevel.put(adjacentKey, { + ...adjacentDownload, + timestamp: tempTimestamp, + }); + + return true; +}; + +registerEvent("updateDownloadQueuePosition", updateDownloadQueuePosition); diff --git a/src/main/helpers/download-error-handler.ts b/src/main/helpers/download-error-handler.ts new file mode 100644 index 00000000..ad39d263 --- /dev/null +++ b/src/main/helpers/download-error-handler.ts @@ -0,0 +1,51 @@ +import { AxiosError } from "axios"; +import { Downloader, DownloadError } from "@shared"; + +export const handleDownloadError = ( + err: unknown, + downloader: Downloader +): { ok: false; error?: string } => { + if (err instanceof AxiosError) { + if (err.response?.status === 429 && downloader === Downloader.Gofile) { + return { ok: false, error: DownloadError.GofileQuotaExceeded }; + } + + if (err.response?.status === 403 && downloader === Downloader.RealDebrid) { + return { ok: false, error: DownloadError.RealDebridAccountNotAuthorized }; + } + + if (downloader === Downloader.TorBox) { + return { ok: false, error: err.response?.data?.detail }; + } + } + + if (err instanceof Error) { + if (downloader === Downloader.Buzzheavier) { + if (err.message.includes("Rate limit")) { + return { ok: false, error: "Buzzheavier: Rate limit exceeded" }; + } + if ( + err.message.includes("not found") || + err.message.includes("deleted") + ) { + return { ok: false, error: "Buzzheavier: File not found" }; + } + } + + if (downloader === Downloader.FuckingFast) { + if (err.message.includes("Rate limit")) { + return { ok: false, error: "FuckingFast: Rate limit exceeded" }; + } + if ( + err.message.includes("not found") || + err.message.includes("deleted") + ) { + return { ok: false, error: "FuckingFast: File not found" }; + } + } + + return { ok: false, error: err.message }; + } + + return { ok: false }; +}; diff --git a/src/main/helpers/download-game-helper.ts b/src/main/helpers/download-game-helper.ts new file mode 100644 index 00000000..fc779351 --- /dev/null +++ b/src/main/helpers/download-game-helper.ts @@ -0,0 +1,45 @@ +import { + downloadsSublevel, + gamesShopAssetsSublevel, + gamesSublevel, +} from "@main/level"; +import type { GameShop } from "@types"; + +interface PrepareGameEntryParams { + gameKey: string; + title: string; + objectId: string; + shop: GameShop; +} + +export const prepareGameEntry = async ({ + gameKey, + title, + objectId, + shop, +}: PrepareGameEntryParams): Promise => { + const game = await gamesSublevel.get(gameKey); + const gameAssets = await gamesShopAssetsSublevel.get(gameKey); + + await downloadsSublevel.del(gameKey); + + if (game) { + await gamesSublevel.put(gameKey, { + ...game, + isDeleted: false, + }); + } else { + await gamesSublevel.put(gameKey, { + title, + iconUrl: gameAssets?.iconUrl ?? null, + libraryHeroImageUrl: gameAssets?.libraryHeroImageUrl ?? null, + logoImageUrl: gameAssets?.logoImageUrl ?? null, + objectId, + shop, + remoteId: null, + playTimeInMilliseconds: 0, + lastTimePlayed: null, + isDeleted: false, + }); + } +}; diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index d77c4add..69fc87c6 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -95,3 +95,5 @@ export const getThemeSoundPath = ( export * from "./reg-parser"; export * from "./launch-game"; +export * from "./download-error-handler"; +export * from "./download-game-helper"; diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index e66d04b6..71daf2d6 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -21,7 +21,7 @@ import { RealDebridClient } from "./real-debrid"; import path from "node:path"; import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; -import { sortBy } from "lodash-es"; +import { orderBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; import { GameFilesManager } from "../game-files-manager"; import { HydraDebridClient } from "./hydra-debrid"; @@ -323,7 +323,8 @@ export class DownloadManager { this.sendProgressUpdate(progress, status, game); - if (progress === 1) { + const isComplete = progress === 1 || download.status === "complete"; + if (isComplete) { await this.handleDownloadCompletion(download, game, gameId); } } @@ -422,10 +423,10 @@ export class DownloadManager { .values() .all() .then((games) => - sortBy( + orderBy( games.filter((game) => game.status === "paused" && game.queued), - "timestamp", - "DESC" + ["timestamp"], + ["desc"] ) ); @@ -499,18 +500,20 @@ export class DownloadManager { } static async cancelDownload(downloadKey = this.downloadingGameId) { - 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)); - } + const isActiveDownload = downloadKey === this.downloadingGameId; + + if (isActiveDownload) { + 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; @@ -932,6 +935,20 @@ export class DownloadManager { } } + static async validateDownloadUrl(download: Download): Promise { + const useJsDownloader = await this.shouldUseJsDownloader(); + const isHttp = this.isHttpDownloader(download.downloader); + + if (useJsDownloader && isHttp) { + const options = await this.getJsDownloadOptions(download); + if (!options) { + throw new Error("Failed to validate download URL"); + } + } else if (isHttp) { + await this.getDownloadPayload(download); + } + } + static async startDownload(download: Download) { const useJsDownloader = await this.shouldUseJsDownloader(); const isHttp = this.isHttpDownloader(download.downloader); diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index c90c1a95..b488300b 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -320,10 +320,17 @@ export class JsHttpDownloader { return null; } + let progress = 0; + if (this.status === "complete") { + progress = 1; + } else if (this.fileSize > 0) { + progress = this.bytesDownloaded / this.fileSize; + } + return { folderName: this.folderName, fileSize: this.fileSize, - progress: this.fileSize > 0 ? this.bytesDownloaded / this.fileSize : 0, + progress, downloadSpeed: this.downloadSpeed, numPeers: 0, numSeeds: 0, diff --git a/src/preload/index.ts b/src/preload/index.ts index d2b4baf4..82a390d9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,8 @@ contextBridge.exposeInMainWorld("electron", { /* Torrenting */ startGameDownload: (payload: StartGameDownloadPayload) => ipcRenderer.invoke("startGameDownload", payload), + addGameToQueue: (payload: StartGameDownloadPayload) => + ipcRenderer.invoke("addGameToQueue", payload), cancelGameDownload: (shop: GameShop, objectId: string) => ipcRenderer.invoke("cancelGameDownload", shop, objectId), pauseGameDownload: (shop: GameShop, objectId: string) => @@ -37,6 +39,17 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("pauseGameSeed", shop, objectId), resumeGameSeed: (shop: GameShop, objectId: string) => ipcRenderer.invoke("resumeGameSeed", shop, objectId), + updateDownloadQueuePosition: ( + shop: GameShop, + objectId: string, + direction: "up" | "down" + ) => + ipcRenderer.invoke( + "updateDownloadQueuePosition", + shop, + objectId, + direction + ), onDownloadProgress: (cb: (value: DownloadProgress | null) => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 4d494846..079669de 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -47,11 +47,19 @@ declare global { startGameDownload: ( payload: StartGameDownloadPayload ) => Promise<{ ok: boolean; error?: string }>; + addGameToQueue: ( + payload: StartGameDownloadPayload + ) => Promise<{ ok: boolean; error?: string }>; cancelGameDownload: (shop: GameShop, objectId: string) => Promise; pauseGameDownload: (shop: GameShop, objectId: string) => Promise; resumeGameDownload: (shop: GameShop, objectId: string) => Promise; pauseGameSeed: (shop: GameShop, objectId: string) => Promise; resumeGameSeed: (shop: GameShop, objectId: string) => Promise; + updateDownloadQueuePosition: ( + shop: GameShop, + objectId: string, + direction: "up" | "down" + ) => Promise; onDownloadProgress: ( cb: (value: DownloadProgress | null) => void ) => () => Electron.IpcRenderer; diff --git a/src/renderer/src/hooks/use-download.ts b/src/renderer/src/hooks/use-download.ts index f6cc071f..6405a85c 100644 --- a/src/renderer/src/hooks/use-download.ts +++ b/src/renderer/src/hooks/use-download.ts @@ -38,6 +38,14 @@ export function useDownload() { return response; }; + const addGameToQueue = async (payload: StartGameDownloadPayload) => { + const response = await window.electron.addGameToQueue(payload); + + if (response.ok) updateLibrary(); + + return response; + }; + const pauseDownload = async (shop: GameShop, objectId: string) => { await window.electron.pauseGameDownload(shop, objectId); await updateLibrary(); @@ -61,10 +69,16 @@ export function useDownload() { }; const cancelDownload = async (shop: GameShop, objectId: string) => { - await window.electron.cancelGameDownload(shop, objectId); - dispatch(clearDownload()); - updateLibrary(); + const gameId = `${shop}:${objectId}`; + const isActiveDownload = lastPacket?.gameId === gameId; + await window.electron.cancelGameDownload(shop, objectId); + + if (isActiveDownload) { + dispatch(clearDownload()); + } + + updateLibrary(); removeGameInstaller(shop, objectId); }; @@ -113,6 +127,7 @@ export function useDownload() { lastPacket, eta: calculateETA(), startDownload, + addGameToQueue, pauseDownload, resumeDownload, cancelDownload, diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 5b26f685..6a2dd478 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -26,6 +26,8 @@ import { DropdownMenuItem, } from "@renderer/components/dropdown-menu/dropdown-menu"; import { + ArrowDownIcon, + ArrowUpIcon, ClockIcon, ColumnsIcon, DownloadIcon, @@ -40,6 +42,44 @@ import { import { MoreVertical, Folder } from "lucide-react"; import { average } from "color.js"; +function hexToRgb(hex: string): [number, number, number] { + let h = hex.replace("#", ""); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + const r = Number.parseInt(h.substring(0, 2), 16) || 0; + const g = Number.parseInt(h.substring(2, 4), 16) || 0; + const b = Number.parseInt(h.substring(4, 6), 16) || 0; + return [r, g, b]; +} + +function isTooCloseRGB(a: string, b: string, threshold: number): boolean { + const [r1, g1, b1] = hexToRgb(a); + const [r2, g2, b2] = hexToRgb(b); + const distance = Math.sqrt( + Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2) + ); + return distance < threshold; +} + +const CHART_BACKGROUND_COLOR = "#1a1a1a"; +const COLOR_DISTANCE_THRESHOLD = 28; +const FALLBACK_CHART_COLOR = "#fff"; + +function pickChartColor(dominant?: string): string { + if (!dominant || typeof dominant !== "string" || !dominant.startsWith("#")) { + return FALLBACK_CHART_COLOR; + } + + if ( + isTooCloseRGB(dominant, CHART_BACKGROUND_COLOR, COLOR_DISTANCE_THRESHOLD) + ) { + return FALLBACK_CHART_COLOR; + } + + return dominant; +} + interface AnimatedPercentageProps { value: number; } @@ -442,6 +482,7 @@ export interface DownloadGroupProps { openDeleteGameModal: (shop: GameShop, objectId: string) => void; openGameInstaller: (shop: GameShop, objectId: string) => void; seedingStatus: SeedingStatus[]; + queuedGameIds?: string[]; } export function DownloadGroup({ @@ -450,6 +491,7 @@ export function DownloadGroup({ openDeleteGameModal, openGameInstaller, seedingStatus, + queuedGameIds = [], }: Readonly) { const { t } = useTranslation("downloads"); const { t: tGameDetails } = useTranslation("game_details"); @@ -690,6 +732,18 @@ export function DownloadGroup({ setGameToCancelObjectId(null); }, []); + const handleMoveInQueue = useCallback( + async (shop: GameShop, objectId: string, direction: "up" | "down") => { + await window.electron.updateDownloadQueuePosition( + shop, + objectId, + direction + ); + updateLibrary(); + }, + [updateLibrary] + ); + const getGameActions = (game: LibraryGame): DropdownMenuItem[] => { const download = lastPacket?.download; const isGameDownloading = isGameDownloadingMap[game.id]; @@ -765,7 +819,12 @@ export function DownloadGroup({ (download?.downloader === Downloader.TorBox && !userPreferences?.torBoxApiToken); - return [ + const queueIndex = queuedGameIds.indexOf(game.id); + const isFirstInQueue = queueIndex === 0; + const isLastInQueue = queueIndex === queuedGameIds.length - 1; + const isInQueue = queueIndex !== -1; + + const actions = [ { label: t("resume"), disabled: isResumeDisabled, @@ -774,6 +833,22 @@ export function DownloadGroup({ }, icon: , }, + { + label: t("move_up"), + show: isInQueue && !isFirstInQueue, + onClick: () => { + handleMoveInQueue(game.shop, game.objectId, "up"); + }, + icon: , + }, + { + label: t("move_down"), + show: isInQueue && !isLastInQueue, + onClick: () => { + handleMoveInQueue(game.shop, game.objectId, "down"); + }, + icon: , + }, { label: t("cancel"), onClick: () => { @@ -782,6 +857,8 @@ export function DownloadGroup({ icon: , }, ]; + + return actions.filter((action) => action.show !== false); }; const downloadInfo = useMemo( @@ -863,7 +940,7 @@ export function DownloadGroup({ currentProgress = lastPacket.progress; } - const dominantColor = dominantColors[game.id] || "#fff"; + const dominantColor = pickChartColor(dominantColors[game.id]); return ( <> diff --git a/src/renderer/src/pages/downloads/downloads.tsx b/src/renderer/src/pages/downloads/downloads.tsx index 10d817f1..620a71d2 100644 --- a/src/renderer/src/pages/downloads/downloads.tsx +++ b/src/renderer/src/pages/downloads/downloads.tsx @@ -103,18 +103,26 @@ export default function Downloads() { }; }, [library, lastPacket?.gameId, extraction?.visibleId]); + const queuedGameIds = useMemo( + () => libraryGroup.queued.map((game) => game.id), + [libraryGroup.queued] + ); + const downloadGroups = [ { title: t("download_in_progress"), library: libraryGroup.downloading, + queuedGameIds: [] as string[], }, { title: t("queued_downloads"), library: libraryGroup.queued, + queuedGameIds, }, { title: t("downloads_completed"), library: libraryGroup.complete, + queuedGameIds: [] as string[], }, ]; @@ -142,10 +150,11 @@ export default function Downloads() { ))} diff --git a/src/renderer/src/pages/game-details/game-details.tsx b/src/renderer/src/pages/game-details/game-details.tsx index 6bc28c10..af12ef0f 100644 --- a/src/renderer/src/pages/game-details/game-details.tsx +++ b/src/renderer/src/pages/game-details/game-details.tsx @@ -37,7 +37,7 @@ export default function GameDetails() { const fromRandomizer = searchParams.get("fromRandomizer"); const gameTitle = searchParams.get("title"); - const { startDownload } = useDownload(); + const { startDownload, addGameToQueue } = useDownload(); const { t } = useTranslation("game_details"); @@ -100,17 +100,30 @@ export default function GameDetails() { repack: GameRepack, downloader: Downloader, downloadPath: string, - automaticallyExtract: boolean + automaticallyExtract: boolean, + addToQueueOnly = false ) => { - const response = await startDownload({ - objectId: objectId!, - title: gameTitle, - downloader, - shop, - downloadPath, - uri: selectRepackUri(repack, downloader), - automaticallyExtract: automaticallyExtract, - }); + const response = addToQueueOnly + ? await addGameToQueue({ + objectId: objectId!, + title: gameTitle, + downloader, + shop, + downloadPath, + uri: selectRepackUri(repack, downloader), + automaticallyExtract: automaticallyExtract, + fileSize: repack.fileSize, + }) + : await startDownload({ + objectId: objectId!, + title: gameTitle, + downloader, + shop, + downloadPath, + uri: selectRepackUri(repack, downloader), + automaticallyExtract: automaticallyExtract, + fileSize: repack.fileSize, + }); if (response.ok) { await updateGame(); diff --git a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx index 0a2c6721..6f20e439 100644 --- a/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/download-settings-modal.tsx @@ -12,11 +12,17 @@ import { DownloadIcon, SyncIcon, CheckCircleFillIcon, + PlusIcon, } from "@primer/octicons-react"; import { Downloader, formatBytes, getDownloadersForUri } from "@shared"; import type { GameRepack } from "@types"; import { DOWNLOADER_NAME } from "@renderer/constants"; -import { useAppSelector, useFeature, useToast } from "@renderer/hooks"; +import { + useAppSelector, + useDownload, + useFeature, + useToast, +} from "@renderer/hooks"; import { motion } from "framer-motion"; import { Tooltip } from "react-tooltip"; import { RealDebridInfoModal } from "./real-debrid-info-modal"; @@ -29,7 +35,8 @@ export interface DownloadSettingsModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string, - automaticallyExtract: boolean + automaticallyExtract: boolean, + addToQueueOnly?: boolean ) => Promise<{ ok: boolean; error?: string }>; repack: GameRepack | null; } @@ -46,8 +53,11 @@ export function DownloadSettingsModal({ (state) => state.userPreferences.value ); + const { lastPacket } = useDownload(); const { showErrorToast } = useToast(); + const hasActiveDownload = lastPacket !== null; + const [diskFreeSpace, setDiskFreeSpace] = useState(null); const [selectedPath, setSelectedPath] = useState(""); const [downloadStarting, setDownloadStarting] = useState(false); @@ -220,7 +230,8 @@ export function DownloadSettingsModal({ repack, selectedDownloader!, selectedPath, - automaticExtractionEnabled + automaticExtractionEnabled, + hasActiveDownload ); if (response.ok) { @@ -456,6 +467,11 @@ export function DownloadSettingsModal({ {t("loading")} + ) : hasActiveDownload ? ( + <> + + {t("add_to_queue")} + ) : ( <> diff --git a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx index 1a1132f1..a805c2ad 100644 --- a/src/renderer/src/pages/game-details/modals/repacks-modal.tsx +++ b/src/renderer/src/pages/game-details/modals/repacks-modal.tsx @@ -39,7 +39,8 @@ export interface RepacksModalProps { repack: GameRepack, downloader: Downloader, downloadPath: string, - automaticallyExtract: boolean + automaticallyExtract: boolean, + addToQueueOnly?: boolean ) => Promise<{ ok: boolean; error?: string }>; onClose: () => void; } diff --git a/src/shared/index.ts b/src/shared/index.ts index 5da36bd9..4ab56405 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -51,6 +51,25 @@ export const formatBytes = (bytes: number): string => { return `${Math.trunc(formatedByte * 10) / 10} ${FORMAT[base]}`; }; +export const parseBytes = (sizeString: string | null): number | null => { + if (!sizeString) return null; + + const regex = /^([\d.,]+)\s*([A-Za-z]+)$/; + const match = regex.exec(sizeString.trim()); + if (!match) return null; + + const value = Number.parseFloat(match[1].replaceAll(",", ".")); + const unit = match[2].toUpperCase(); + + if (Number.isNaN(value)) return null; + + const unitIndex = FORMAT.indexOf(unit); + if (unitIndex === -1) return null; + + const byteKBase = 1024; + return Math.round(value * Math.pow(byteKBase, unitIndex)); +}; + export const formatBytesToMbps = (bytesPerSecond: number): string => { const bitsPerSecond = bytesPerSecond * 8; const mbps = bitsPerSecond / (1024 * 1024); diff --git a/src/types/index.ts b/src/types/index.ts index 39fd0791..e2bb9fb8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -118,6 +118,7 @@ export interface StartGameDownloadPayload { downloadPath: string; downloader: Downloader; automaticallyExtract: boolean; + fileSize?: string | null; } export interface UserFriend { diff --git a/yarn.lock b/yarn.lock index 4bc4d101..74160dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8702,10 +8702,10 @@ tar@^6.0.5, tar@^6.1.11, tar@^6.1.12, tar@^6.2.1: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^7.5.2: - version "7.5.2" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc" - integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg== +tar@^7.5.4: + version "7.5.4" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.4.tgz#18b53b44f939a7e03ed874f1fafe17d29e306c81" + integrity sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0"