diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 431df932..c9094117 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,17 +22,6 @@ jobs: - name: Install dependencies run: yarn - - name: Install Python - uses: actions/setup-python@v5 - with: - python-version: 3.9 - - - name: Install dependencies - run: pip install -r requirements.txt - - - name: Build with cx_Freeze - run: python torrent-client/setup.py build - - name: Build Linux if: matrix.os == 'ubuntu-latest' run: yarn build:linux diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c51d9ea6..4eee0aad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,17 +24,6 @@ jobs: - name: Install dependencies run: yarn - - name: Install Python - uses: actions/setup-python@v5 - with: - python-version: 3.9 - - - name: Install dependencies - run: pip install -r requirements.txt - - - name: Build with cx_Freeze - run: python torrent-client/setup.py build - - name: Build Linux if: matrix.os == 'ubuntu-latest' run: yarn build:linux diff --git a/.gitignore b/.gitignore index 69af659f..1cd10467 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .vscode node_modules -hydra-download-manager +aria2* fastlist.exe __pycache__ dist diff --git a/electron-builder.yml b/electron-builder.yml index 1dbac52a..b9a4acc6 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -3,7 +3,6 @@ productName: Hydra directories: buildResources: build extraResources: - - hydra-download-manager - hydra.db - fastlist.exe - seeds diff --git a/hydra.db b/hydra.db index 4522e1ae..0015965f 100644 Binary files a/hydra.db and b/hydra.db differ diff --git a/package.json b/package.json index 6cb748ac..97944d50 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@reduxjs/toolkit": "^2.2.3", "@vanilla-extract/css": "^1.14.2", "@vanilla-extract/recipes": "^0.5.2", + "aria2": "^4.1.2", "auto-launch": "^5.0.6", "axios": "^1.6.8", "better-sqlite3": "^9.5.0", diff --git a/src/main/declaration.d.ts b/src/main/declaration.d.ts new file mode 100644 index 00000000..ac2675a3 --- /dev/null +++ b/src/main/declaration.d.ts @@ -0,0 +1,80 @@ +declare module "aria2" { + export type Aria2Status = + | "active" + | "waiting" + | "paused" + | "error" + | "complete" + | "removed"; + + export interface StatusResponse { + gid: string; + status: Aria2Status; + totalLength: string; + completedLength: string; + uploadLength: string; + bitfield: string; + downloadSpeed: string; + uploadSpeed: string; + infoHash?: string; + numSeeders?: string; + seeder?: boolean; + pieceLength: string; + numPieces: string; + connections: string; + errorCode?: string; + errorMessage?: string; + followedBy?: string[]; + following: string; + belongsTo: string; + dir: string; + files: { + path: string; + length: string; + completedLength: string; + selected: string; + }[]; + bittorrent?: { + announceList: string[][]; + comment: string; + creationDate: string; + mode: "single" | "multi"; + info: { + name: string; + verifiedLength: string; + verifyIntegrityPending: string; + }; + }; + } + + export default class Aria2 { + constructor(options: any); + open: () => Promise; + call( + method: "addUri", + uris: string[], + options: { dir: string } + ): Promise; + call( + method: "tellStatus", + gid: string, + keys?: string[] + ): Promise; + call(method: "pause", gid: string): Promise; + call(method: "forcePause", gid: string): Promise; + call(method: "unpause", gid: string): Promise; + call(method: "remove", gid: string): Promise; + call(method: "forceRemove", gid: string): Promise; + call(method: "pauseAll"): Promise; + call(method: "forcePauseAll"): Promise; + listNotifications: () => [ + "onDownloadStart", + "onDownloadPause", + "onDownloadStop", + "onDownloadComplete", + "onDownloadError", + "onBtDownloadComplete", + ]; + on: (event: string, callback: (params: any) => void) => void; + } +} diff --git a/src/main/entity/game.entity.ts b/src/main/entity/game.entity.ts index 91e19ea6..fd168f51 100644 --- a/src/main/entity/game.entity.ts +++ b/src/main/entity/game.entity.ts @@ -10,7 +10,8 @@ import { import { Repack } from "./repack.entity"; import type { GameShop } from "@types"; -import { Downloader, GameStatus } from "@shared"; +import { Downloader } from "@shared"; +import type { Aria2Status } from "aria2"; @Entity("game") export class Game { @@ -42,7 +43,7 @@ export class Game { shop: GameShop; @Column("text", { nullable: true }) - status: GameStatus | null; + status: Aria2Status | null; @Column("int", { default: Downloader.Torrent }) downloader: Downloader; @@ -53,9 +54,6 @@ export class Game { @Column("float", { default: 0 }) progress: number; - @Column("float", { default: 0 }) - fileVerificationProgress: number; - @Column("int", { default: 0 }) bytesDownloaded: number; diff --git a/src/main/events/library/delete-game-folder.ts b/src/main/events/library/delete-game-folder.ts index 954367a0..adfafefb 100644 --- a/src/main/events/library/delete-game-folder.ts +++ b/src/main/events/library/delete-game-folder.ts @@ -1,7 +1,6 @@ import path from "node:path"; import fs from "node:fs"; -import { GameStatus } from "@shared"; import { gameRepository } from "@main/repository"; import { getDownloadsPath } from "../helpers/get-downloads-path"; @@ -15,7 +14,7 @@ const deleteGameFolder = async ( const game = await gameRepository.findOne({ where: { id: gameId, - status: GameStatus.Cancelled, + status: "removed", isDeleted: false, }, }); diff --git a/src/main/events/library/get-library.ts b/src/main/events/library/get-library.ts index 2374c497..4fd4e254 100644 --- a/src/main/events/library/get-library.ts +++ b/src/main/events/library/get-library.ts @@ -2,7 +2,6 @@ import { gameRepository } from "@main/repository"; import { searchRepacks } from "../helpers/search-games"; import { registerEvent } from "../register-event"; -import { GameStatus } from "@shared"; import { sortBy } from "lodash-es"; const getLibrary = async () => @@ -24,7 +23,7 @@ const getLibrary = async () => ...game, repacks: searchRepacks(game.title), })), - (game) => (game.status !== GameStatus.Cancelled ? 0 : 1) + (game) => (game.status !== "removed" ? 0 : 1) ) ); diff --git a/src/main/events/library/remove-game.ts b/src/main/events/library/remove-game.ts index 57b10b37..54bf66b8 100644 --- a/src/main/events/library/remove-game.ts +++ b/src/main/events/library/remove-game.ts @@ -1,6 +1,5 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; -import { GameStatus } from "@shared"; const removeGame = async ( _event: Electron.IpcMainInvokeEvent, @@ -9,7 +8,7 @@ const removeGame = async ( await gameRepository.update( { id: gameId, - status: GameStatus.Cancelled, + status: "removed", }, { status: null, diff --git a/src/main/events/torrenting/cancel-game-download.ts b/src/main/events/torrenting/cancel-game-download.ts index 18d29fde..3c9a0715 100644 --- a/src/main/events/torrenting/cancel-game-download.ts +++ b/src/main/events/torrenting/cancel-game-download.ts @@ -1,53 +1,25 @@ import { gameRepository } from "@main/repository"; import { registerEvent } from "../register-event"; -import { WindowManager } from "@main/services"; -import { In } from "typeorm"; import { DownloadManager } from "@main/services"; -import { GameStatus } from "@shared"; const cancelGameDownload = async ( _event: Electron.IpcMainInvokeEvent, gameId: number ) => { - const game = await gameRepository.findOne({ - where: { + await DownloadManager.cancelDownload(gameId); + + await gameRepository.update( + { id: gameId, - isDeleted: false, - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - GameStatus.Paused, - GameStatus.Seeding, - GameStatus.Finished, - ]), }, - }); - - if (!game) return; - DownloadManager.cancelDownload(); - - await gameRepository - .update( - { - id: game.id, - }, - { - status: GameStatus.Cancelled, - bytesDownloaded: 0, - progress: 0, - } - ) - .then((result) => { - if ( - game.status !== GameStatus.Paused && - game.status !== GameStatus.Seeding - ) { - if (result.affected) WindowManager.mainWindow?.setProgressBar(-1); - } - }); + { + status: "removed", + bytesDownloaded: 0, + progress: 0, + } + ); }; registerEvent("cancelGameDownload", cancelGameDownload); diff --git a/src/main/events/torrenting/pause-game-download.ts b/src/main/events/torrenting/pause-game-download.ts index ceda70cc..f9ed1102 100644 --- a/src/main/events/torrenting/pause-game-download.ts +++ b/src/main/events/torrenting/pause-game-download.ts @@ -1,30 +1,13 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; -import { In } from "typeorm"; -import { DownloadManager, WindowManager } from "@main/services"; -import { GameStatus } from "@shared"; +import { DownloadManager } from "@main/services"; const pauseGameDownload = async ( _event: Electron.IpcMainInvokeEvent, gameId: number ) => { - DownloadManager.pauseDownload(); - - await gameRepository - .update( - { - id: gameId, - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - ]), - }, - { status: GameStatus.Paused } - ) - .then((result) => { - if (result.affected) WindowManager.mainWindow?.setProgressBar(-1); - }); + await DownloadManager.pauseDownload(); + await gameRepository.update({ id: gameId }, { status: "paused" }); }; registerEvent("pauseGameDownload", pauseGameDownload); diff --git a/src/main/events/torrenting/resume-game-download.ts b/src/main/events/torrenting/resume-game-download.ts index 6982d895..51a81996 100644 --- a/src/main/events/torrenting/resume-game-download.ts +++ b/src/main/events/torrenting/resume-game-download.ts @@ -1,9 +1,7 @@ import { registerEvent } from "../register-event"; import { gameRepository } from "../../repository"; -import { getDownloadsPath } from "../helpers/get-downloads-path"; -import { In } from "typeorm"; + import { DownloadManager } from "@main/services"; -import { GameStatus } from "@shared"; const resumeGameDownload = async ( _event: Electron.IpcMainInvokeEvent, @@ -18,31 +16,13 @@ const resumeGameDownload = async ( }); if (!game) return; - DownloadManager.pauseDownload(); - if (game.status === GameStatus.Paused) { - const downloadsPath = game.downloadPath ?? (await getDownloadsPath()); + if (game.status === "paused") { + await DownloadManager.pauseDownload(); - DownloadManager.resumeDownload(gameId); + await gameRepository.update({ status: "active" }, { status: "paused" }); - await gameRepository.update( - { - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - ]), - }, - { status: GameStatus.Paused } - ); - - await gameRepository.update( - { id: game.id }, - { - status: GameStatus.Downloading, - downloadPath: downloadsPath, - } - ); + await DownloadManager.resumeDownload(gameId); } }; diff --git a/src/main/events/torrenting/start-game-download.ts b/src/main/events/torrenting/start-game-download.ts index f94d0999..62bce369 100644 --- a/src/main/events/torrenting/start-game-download.ts +++ b/src/main/events/torrenting/start-game-download.ts @@ -8,9 +8,8 @@ import { registerEvent } from "../register-event"; import type { GameShop } from "@types"; import { getFileBase64, getSteamAppAsset } from "@main/helpers"; -import { In } from "typeorm"; import { DownloadManager } from "@main/services"; -import { Downloader, GameStatus } from "@shared"; +import { Downloader } from "@shared"; import { stateManager } from "@main/state-manager"; const startGameDownload = async ( @@ -42,19 +41,9 @@ const startGameDownload = async ( }), ]); - if (!repack || game?.status === GameStatus.Downloading) return; - DownloadManager.pauseDownload(); + if (!repack || game?.status === "active") return; - await gameRepository.update( - { - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - ]), - }, - { status: GameStatus.Paused } - ); + await gameRepository.update({ status: "active" }, { status: "paused" }); if (game) { await gameRepository.update( @@ -62,17 +51,17 @@ const startGameDownload = async ( id: game.id, }, { - status: GameStatus.DownloadingMetadata, - downloadPath: downloadPath, + status: "active", + downloadPath, downloader, repack: { id: repackId }, isDeleted: false, } ); - DownloadManager.downloadGame(game.id); + await DownloadManager.startDownload(game.id); - game.status = GameStatus.DownloadingMetadata; + game.status = "active"; return game; } else { @@ -91,7 +80,7 @@ const startGameDownload = async ( objectID, downloader, shop: gameShop, - status: GameStatus.Downloading, + status: "active", downloadPath, repack: { id: repackId }, }) @@ -105,7 +94,7 @@ const startGameDownload = async ( return result; }); - DownloadManager.downloadGame(createdGame.id); + DownloadManager.startDownload(createdGame.id); const { repack: _, ...rest } = createdGame; diff --git a/src/main/main.ts b/src/main/main.ts index e03a6ab8..a9f0ed19 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13,17 +13,15 @@ import { repackRepository, userPreferencesRepository, } from "./repository"; -import { TorrentDownloader } from "./services"; import { Repack, UserPreferences } from "./entity"; import { Notification } from "electron"; import { t } from "i18next"; -import { GameStatus } from "@shared"; -import { In } from "typeorm"; import fs from "node:fs"; import path from "node:path"; import { RealDebridClient } from "./services/real-debrid"; import { orderBy } from "lodash-es"; import { SteamGame } from "@types"; +import { Not } from "typeorm"; startProcessWatcher(); @@ -72,7 +70,7 @@ const checkForNewRepacks = async (userPreferences: UserPreferences | null) => { }; const loadState = async (userPreferences: UserPreferences | null) => { - const repacks = await repackRepository.find({ + const repacks = repackRepository.find({ order: { createdAt: "desc", }, @@ -82,7 +80,7 @@ const loadState = async (userPreferences: UserPreferences | null) => { fs.readFileSync(path.join(seedsPath, "steam-games.json"), "utf-8") ) as SteamGame[]; - stateManager.setValue("repacks", repacks); + stateManager.setValue("repacks", await repacks); stateManager.setValue("steamGames", orderBy(steamGames, ["name"], "asc")); import("./events"); @@ -90,22 +88,19 @@ const loadState = async (userPreferences: UserPreferences | null) => { if (userPreferences?.realDebridApiToken) await RealDebridClient.authorize(userPreferences?.realDebridApiToken); + await DownloadManager.connect(); + const game = await gameRepository.findOne({ where: { - status: In([ - GameStatus.Downloading, - GameStatus.DownloadingMetadata, - GameStatus.CheckingFiles, - ]), + status: "active", + progress: Not(1), isDeleted: false, }, relations: { repack: true }, }); - await TorrentDownloader.startClient(); - if (game) { - DownloadManager.resumeDownload(game.id); + DownloadManager.startDownload(game.id); } }; diff --git a/src/main/services/download-manager.ts b/src/main/services/download-manager.ts index e345835a..94e19835 100644 --- a/src/main/services/download-manager.ts +++ b/src/main/services/download-manager.ts @@ -1,13 +1,156 @@ -import { gameRepository } from "@main/repository"; +import Aria2, { StatusResponse } from "aria2"; +import { spawn } from "node:child_process"; -import type { Game } from "@main/entity"; +import { gameRepository, userPreferencesRepository } from "@main/repository"; + +import path from "node:path"; +import { WindowManager } from "./window-manager"; +import { RealDebridClient } from "./real-debrid"; +import { Notification } from "electron"; +import { t } from "i18next"; import { Downloader } from "@shared"; - -import { writePipe } from "./fifo"; -import { RealDebridDownloader } from "./downloaders"; +import { DownloadProgress } from "@types"; export class DownloadManager { - private static gameDownloading: Game; + private static downloads = new Map(); + + private static gid: string | null = null; + private static gameId: number | null = null; + + private static aria2 = new Aria2({}); + + static async connect() { + const binary = path.join( + __dirname, + "..", + "..", + "aria2-1.37.0-win-64bit-build1", + "aria2c" + ); + + spawn(binary, ["--enable-rpc", "--rpc-listen-all"], { stdio: "inherit" }); + + await this.aria2.open(); + this.attachListener(); + } + + private static getETA(status: StatusResponse) { + const remainingBytes = + Number(status.totalLength) - Number(status.completedLength); + const speed = Number(status.downloadSpeed); + + if (remainingBytes >= 0 && speed > 0) { + return (remainingBytes / speed) * 1000; + } + + return -1; + } + + static async publishNotification() { + const userPreferences = await userPreferencesRepository.findOne({ + where: { id: 1 }, + }); + + if (userPreferences?.downloadNotificationsEnabled && this.gameId) { + const game = await this.getGame(this.gameId); + + new Notification({ + title: t("download_complete", { + ns: "notifications", + lng: userPreferences.language, + }), + body: t("game_ready_to_install", { + ns: "notifications", + lng: userPreferences.language, + title: game?.title, + }), + }).show(); + } + } + + private static getFolderName(status: StatusResponse) { + if (status.bittorrent?.info) return status.bittorrent.info.name; + return ""; + } + + private static async attachListener() { + while (true) { + try { + if (!this.gid || !this.gameId) { + continue; + } + + const status = await this.aria2.call("tellStatus", this.gid); + + const downloadingMetadata = + status.bittorrent && !status.bittorrent?.info; + + if (status.followedBy?.length) { + this.gid = status.followedBy[0]; + this.downloads.set(this.gameId, this.gid); + continue; + } + + const progress = + Number(status.completedLength) / Number(status.totalLength); + + await gameRepository.update( + { id: this.gameId }, + { + progress: + isNaN(progress) || downloadingMetadata ? undefined : progress, + bytesDownloaded: Number(status.completedLength), + fileSize: Number(status.totalLength), + status: status.status, + folderName: this.getFolderName(status), + } + ); + + const game = await gameRepository.findOne({ + where: { id: this.gameId, isDeleted: false }, + relations: { repack: true }, + }); + + if (progress === 1 && game && !downloadingMetadata) { + await this.publishNotification(); + /* + Only cancel bittorrent downloads to stop seeding + */ + if (status.bittorrent) { + await this.cancelDownload(game.id); + } else { + this.clearCurrentDownload(); + } + } + + if (WindowManager.mainWindow && game) { + WindowManager.mainWindow.setProgressBar( + progress === 1 || downloadingMetadata ? -1 : progress, + { mode: downloadingMetadata ? "indeterminate" : "normal" } + ); + + const payload = { + progress, + bytesDownloaded: Number(status.completedLength), + fileSize: Number(status.totalLength), + numPeers: Number(status.connections), + numSeeds: Number(status.numSeeders ?? 0), + downloadSpeed: Number(status.downloadSpeed), + timeRemaining: this.getETA(status), + downloadingMetadata: !!downloadingMetadata, + game, + } as DownloadProgress; + + WindowManager.mainWindow.webContents.send( + "on-download-progress", + JSON.parse(JSON.stringify(payload)) + ); + } + } finally { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } static async getGame(gameId: number) { return gameRepository.findOne({ @@ -18,59 +161,80 @@ export class DownloadManager { }); } - static async cancelDownload() { - if ( - this.gameDownloading && - this.gameDownloading.downloader === Downloader.Torrent - ) { - writePipe.write({ action: "cancel" }); - } else { - RealDebridDownloader.destroy(); + private static clearCurrentDownload() { + if (this.gameId) { + this.downloads.delete(this.gameId); + this.gid = null; + this.gameId = null; + } + } + + static async cancelDownload(gameId: number) { + const gid = this.downloads.get(gameId); + + if (gid) { + await this.aria2.call("remove", gid); + + if (this.gid === gid) { + this.clearCurrentDownload(); + + WindowManager.mainWindow?.setProgressBar(-1); + } else { + this.downloads.delete(gameId); + } } } static async pauseDownload() { - if ( - this.gameDownloading && - this.gameDownloading.downloader === Downloader.Torrent - ) { - writePipe.write({ action: "pause" }); - } else { - RealDebridDownloader.destroy(); + if (this.gid) { + await this.aria2.call("forcePause", this.gid); + this.gid = null; + this.gameId = null; + + WindowManager.mainWindow?.setProgressBar(-1); } } static async resumeDownload(gameId: number) { - const game = await this.getGame(gameId); + await this.aria2.call("forcePauseAll"); - if (game!.downloader === Downloader.Torrent) { - writePipe.write({ - action: "start", - game_id: game!.id, - magnet: game!.repack.magnet, - save_path: game!.downloadPath, - }); + if (this.downloads.has(gameId)) { + const gid = this.downloads.get(gameId)!; + await this.aria2.call("unpause", gid); + + this.gid = gid; + this.gameId = gameId; } else { - RealDebridDownloader.startDownload(game!); + return this.startDownload(gameId); } - - this.gameDownloading = game!; } - static async downloadGame(gameId: number) { - const game = await this.getGame(gameId); + static async startDownload(gameId: number) { + await this.aria2.call("forcePauseAll"); - if (game!.downloader === Downloader.Torrent) { - writePipe.write({ - action: "start", - game_id: game!.id, - magnet: game!.repack.magnet, - save_path: game!.downloadPath, - }); - } else { - RealDebridDownloader.startDownload(game!); + const game = await this.getGame(gameId)!; + + if (game) { + const options = { + dir: game.downloadPath!, + }; + + if (game.downloader === Downloader.RealDebrid) { + const downloadUrl = decodeURIComponent( + await RealDebridClient.getDownloadUrl(game) + ); + + this.gid = await this.aria2.call("addUri", [downloadUrl], options); + } else { + this.gid = await this.aria2.call( + "addUri", + [game.repack.magnet], + options + ); + } + + this.gameId = gameId; + this.downloads.set(gameId, this.gid); } - - this.gameDownloading = game!; } } diff --git a/src/main/services/downloaders/downloader.ts b/src/main/services/downloaders/downloader.ts deleted file mode 100644 index 14440676..00000000 --- a/src/main/services/downloaders/downloader.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { t } from "i18next"; -import { Notification } from "electron"; - -import { Game } from "@main/entity"; - -import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; - -import { WindowManager } from "../window-manager"; -import type { TorrentUpdate } from "./torrent.downloader"; - -import { GameStatus } from "@shared"; -import { gameRepository, userPreferencesRepository } from "@main/repository"; - -interface DownloadStatus { - numPeers?: number; - numSeeds?: number; - downloadSpeed?: number; - timeRemaining?: number; -} - -export class Downloader { - static getGameProgress(game: Game) { - if (game.status === GameStatus.CheckingFiles) - return game.fileVerificationProgress; - - return game.progress; - } - - static async updateGameProgress( - gameId: number, - gameUpdate: QueryDeepPartialEntity, - downloadStatus: DownloadStatus - ) { - await gameRepository.update({ id: gameId }, gameUpdate); - - const game = await gameRepository.findOne({ - where: { id: gameId, isDeleted: false }, - relations: { repack: true }, - }); - - if (game?.progress === 1) { - const userPreferences = await userPreferencesRepository.findOne({ - where: { id: 1 }, - }); - - if (userPreferences?.downloadNotificationsEnabled) { - new Notification({ - title: t("download_complete", { - ns: "notifications", - lng: userPreferences.language, - }), - body: t("game_ready_to_install", { - ns: "notifications", - lng: userPreferences.language, - title: game?.title, - }), - }).show(); - } - } - - if (WindowManager.mainWindow && game) { - const progress = this.getGameProgress(game); - WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); - - WindowManager.mainWindow.webContents.send( - "on-download-progress", - JSON.parse( - JSON.stringify({ - ...({ - progress: gameUpdate.progress, - bytesDownloaded: gameUpdate.bytesDownloaded, - fileSize: gameUpdate.fileSize, - gameId, - numPeers: downloadStatus.numPeers, - numSeeds: downloadStatus.numSeeds, - downloadSpeed: downloadStatus.downloadSpeed, - timeRemaining: downloadStatus.timeRemaining, - } as TorrentUpdate), - game, - }) - ) - ); - } - } -} diff --git a/src/main/services/downloaders/index.ts b/src/main/services/downloaders/index.ts deleted file mode 100644 index cd742107..00000000 --- a/src/main/services/downloaders/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./real-debrid.downloader"; -export * from "./torrent.downloader"; diff --git a/src/main/services/downloaders/real-debrid.downloader.ts b/src/main/services/downloaders/real-debrid.downloader.ts deleted file mode 100644 index 8a44f934..00000000 --- a/src/main/services/downloaders/real-debrid.downloader.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Game } from "@main/entity"; -import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; -import path from "node:path"; -import fs from "node:fs"; -import EasyDL from "easydl"; -import { GameStatus } from "@shared"; -// import { fullArchive } from "node-7z-archive"; - -import { Downloader } from "./downloader"; -import { RealDebridClient } from "../real-debrid"; - -export class RealDebridDownloader extends Downloader { - private static download: EasyDL; - private static downloadSize = 0; - - private static getEta(bytesDownloaded: number, speed: number) { - const remainingBytes = this.downloadSize - bytesDownloaded; - - if (remainingBytes >= 0 && speed > 0) { - return (remainingBytes / speed) * 1000; - } - - return 1; - } - - private static createFolderIfNotExists(path: string) { - if (!fs.existsSync(path)) { - fs.mkdirSync(path); - } - } - - // private static async startDecompression( - // rarFile: string, - // dest: string, - // game: Game - // ) { - // await fullArchive(rarFile, dest); - - // const updatePayload: QueryDeepPartialEntity = { - // status: GameStatus.Finished, - // }; - - // await this.updateGameProgress(game.id, updatePayload, {}); - // } - - static destroy() { - if (this.download) { - this.download.destroy(); - } - } - - static async startDownload(game: Game) { - if (this.download) this.download.destroy(); - const downloadUrl = decodeURIComponent( - await RealDebridClient.getDownloadUrl(game) - ); - - const filename = path.basename(downloadUrl); - const folderName = path.basename(filename, path.extname(filename)); - - const downloadPath = path.join(game.downloadPath!, folderName); - this.createFolderIfNotExists(downloadPath); - - this.download = new EasyDL(downloadUrl, path.join(downloadPath, filename)); - - const metadata = await this.download.metadata(); - - this.downloadSize = metadata.size; - - const updatePayload: QueryDeepPartialEntity = { - status: GameStatus.Downloading, - fileSize: metadata.size, - folderName, - }; - - const downloadStatus = { - timeRemaining: Number.POSITIVE_INFINITY, - }; - - await this.updateGameProgress(game.id, updatePayload, downloadStatus); - - this.download.on("progress", async ({ total }) => { - const updatePayload: QueryDeepPartialEntity = { - status: GameStatus.Downloading, - progress: Math.min(0.99, total.percentage / 100), - bytesDownloaded: total.bytes, - }; - - const downloadStatus = { - downloadSpeed: total.speed, - timeRemaining: this.getEta(total.bytes ?? 0, total.speed ?? 0), - }; - - await this.updateGameProgress(game.id, updatePayload, downloadStatus); - }); - - this.download.on("end", async () => { - const updatePayload: QueryDeepPartialEntity = { - status: GameStatus.Finished, - progress: 1, - }; - - await this.updateGameProgress(game.id, updatePayload, { - timeRemaining: 0, - }); - - /* This has to be improved */ - // this.startDecompression( - // path.join(downloadPath, filename), - // downloadPath, - // game - // ); - }); - } -} diff --git a/src/main/services/downloaders/torrent.downloader.ts b/src/main/services/downloaders/torrent.downloader.ts deleted file mode 100644 index d5e039a8..00000000 --- a/src/main/services/downloaders/torrent.downloader.ts +++ /dev/null @@ -1,156 +0,0 @@ -import path from "node:path"; -import cp from "node:child_process"; -import fs from "node:fs"; -import { app, dialog } from "electron"; -import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"; - -import { Game } from "@main/entity"; -import { GameStatus } from "@shared"; -import { Downloader } from "./downloader"; -import { readPipe, writePipe } from "../fifo"; - -const binaryNameByPlatform: Partial> = { - darwin: "hydra-download-manager", - linux: "hydra-download-manager", - win32: "hydra-download-manager.exe", -}; - -enum TorrentState { - CheckingFiles = 1, - DownloadingMetadata = 2, - Downloading = 3, - Finished = 4, - Seeding = 5, -} - -export interface TorrentUpdate { - gameId: number; - progress: number; - downloadSpeed: number; - timeRemaining: number; - numPeers: number; - numSeeds: number; - status: TorrentState; - folderName: string; - fileSize: number; - bytesDownloaded: number; -} - -export const BITTORRENT_PORT = "5881"; - -export class TorrentDownloader extends Downloader { - private static messageLength = 1024 * 2; - - public static async attachListener() { - // eslint-disable-next-line no-constant-condition - while (true) { - const buffer = readPipe.socket?.read(this.messageLength); - - if (buffer === null) { - await new Promise((resolve) => setTimeout(resolve, 100)); - continue; - } - - const message = Buffer.from( - buffer.slice(0, buffer.indexOf(0x00)) - ).toString("utf-8"); - - try { - const payload = JSON.parse(message) as TorrentUpdate; - - const updatePayload: QueryDeepPartialEntity = { - bytesDownloaded: payload.bytesDownloaded, - status: this.getTorrentStateName(payload.status), - }; - - if (payload.status === TorrentState.CheckingFiles) { - updatePayload.fileVerificationProgress = payload.progress; - } else { - if (payload.folderName) { - updatePayload.folderName = payload.folderName; - updatePayload.fileSize = payload.fileSize; - } - } - - if ( - [TorrentState.Downloading, TorrentState.Seeding].includes( - payload.status - ) - ) { - updatePayload.progress = payload.progress; - } - - this.updateGameProgress(payload.gameId, updatePayload, { - numPeers: payload.numPeers, - numSeeds: payload.numSeeds, - downloadSpeed: payload.downloadSpeed, - timeRemaining: payload.timeRemaining, - }); - } finally { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - } - - public static startClient() { - return new Promise((resolve) => { - const commonArgs = [ - BITTORRENT_PORT, - writePipe.socketPath, - readPipe.socketPath, - ]; - - if (app.isPackaged) { - const binaryName = binaryNameByPlatform[process.platform]!; - const binaryPath = path.join( - process.resourcesPath, - "hydra-download-manager", - binaryName - ); - - if (!fs.existsSync(binaryPath)) { - dialog.showErrorBox( - "Fatal", - "Hydra download manager binary not found. Please check if it has been removed by Windows Defender." - ); - - app.quit(); - } - - cp.spawn(binaryPath, commonArgs, { - stdio: "inherit", - windowsHide: true, - }); - } else { - const scriptPath = path.join( - __dirname, - "..", - "..", - "torrent-client", - "main.py" - ); - - cp.spawn("python3", [scriptPath, ...commonArgs], { - stdio: "inherit", - }); - } - - Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then( - async () => { - this.attachListener(); - resolve(null); - } - ); - }); - } - - private static getTorrentStateName(state: TorrentState) { - if (state === TorrentState.CheckingFiles) return GameStatus.CheckingFiles; - if (state === TorrentState.Downloading) return GameStatus.Downloading; - if (state === TorrentState.DownloadingMetadata) - return GameStatus.DownloadingMetadata; - if (state === TorrentState.Finished) return GameStatus.Finished; - if (state === TorrentState.Seeding) return GameStatus.Seeding; - return null; - } -} diff --git a/src/main/services/fifo.ts b/src/main/services/fifo.ts deleted file mode 100644 index 866232cc..00000000 --- a/src/main/services/fifo.ts +++ /dev/null @@ -1,38 +0,0 @@ -import path from "node:path"; -import net from "node:net"; -import crypto from "node:crypto"; -import os from "node:os"; - -export class FIFO { - public socket: null | net.Socket = null; - public socketPath = this.generateSocketFilename(); - - private generateSocketFilename() { - const hash = crypto.randomBytes(16).toString("hex"); - - if (process.platform === "win32") { - return "\\\\.\\pipe\\" + hash; - } - - return path.join(os.tmpdir(), hash); - } - - public write(data: any) { - if (!this.socket) return; - this.socket.write(Buffer.from(JSON.stringify(data))); - } - - public createPipe() { - return new Promise((resolve) => { - const server = net.createServer((socket) => { - this.socket = socket; - resolve(null); - }); - - server.listen(this.socketPath); - }); - } -} - -export const writePipe = new FIFO(); -export const readPipe = new FIFO(); diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 4b13d38d..4808736d 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -5,8 +5,6 @@ export * from "./steam-250"; export * from "./steam-grid"; export * from "./update-resolver"; export * from "./window-manager"; -export * from "./fifo"; -export * from "./downloaders"; export * from "./download-manager"; export * from "./how-long-to-beat"; export * from "./process-watcher"; diff --git a/src/preload/index.ts b/src/preload/index.ts index 6a209787..0e397a4a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,7 +5,7 @@ import { contextBridge, ipcRenderer } from "electron"; import type { CatalogueCategory, GameShop, - TorrentProgress, + DownloadProgress, UserPreferences, } from "@types"; @@ -32,10 +32,10 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("pauseGameDownload", gameId), resumeGameDownload: (gameId: number) => ipcRenderer.invoke("resumeGameDownload", gameId), - onDownloadProgress: (cb: (value: TorrentProgress) => void) => { + onDownloadProgress: (cb: (value: DownloadProgress) => void) => { const listener = ( _event: Electron.IpcRendererEvent, - value: TorrentProgress + value: DownloadProgress ) => cb(value); ipcRenderer.on("on-download-progress", listener); return () => ipcRenderer.removeListener("on-download-progress", listener); diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index da95f292..adb2a613 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -19,7 +19,6 @@ import { setUserPreferences, toggleDraggingDisabled, } from "@renderer/features"; -import { GameStatusHelper } from "@shared"; document.body.classList.add(themeClass); @@ -54,7 +53,7 @@ export function App({ children }: AppProps) { useEffect(() => { const unsubscribe = window.electron.onDownloadProgress( (downloadProgress) => { - if (GameStatusHelper.isReady(downloadProgress.game.status)) { + if (downloadProgress.game.progress === 1) { clearDownload(); updateLibrary(); return; diff --git a/src/renderer/src/components/backdrop/backdrop.css.ts b/src/renderer/src/components/backdrop/backdrop.css.ts index 0a7b61bb..3b8cc4e2 100644 --- a/src/renderer/src/components/backdrop/backdrop.css.ts +++ b/src/renderer/src/components/backdrop/backdrop.css.ts @@ -43,5 +43,11 @@ export const backdrop = recipe({ backgroundColor: "rgba(0, 0, 0, 0)", }, }, + windows: { + true: { + // SPACING_UNIT * 3 + title bar spacing + paddingTop: `${SPACING_UNIT * 3 + 35}px`, + }, + }, }, }); diff --git a/src/renderer/src/components/backdrop/backdrop.tsx b/src/renderer/src/components/backdrop/backdrop.tsx index 5852d59d..f498e664 100644 --- a/src/renderer/src/components/backdrop/backdrop.tsx +++ b/src/renderer/src/components/backdrop/backdrop.tsx @@ -7,6 +7,13 @@ export interface BackdropProps { export function Backdrop({ isClosing = false, children }: BackdropProps) { return ( -
{children}
+
+ {children} +
); } diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 44d125cd..310f31b4 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -7,17 +7,16 @@ import { vars } from "../../theme.css"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { VERSION_CODENAME } from "@renderer/constants"; -import { GameStatus, GameStatusHelper } from "@shared"; export function BottomPanel() { const { t } = useTranslation("bottom_panel"); const navigate = useNavigate(); - const { game, progress, downloadSpeed, eta } = useDownload(); + const { lastPacket, progress, downloadSpeed, eta } = useDownload(); const isGameDownloading = - game && GameStatusHelper.isDownloading(game.status ?? null); + lastPacket?.game && lastPacket?.game.status === "active"; const [version, setVersion] = useState(""); @@ -27,17 +26,8 @@ export function BottomPanel() { const status = useMemo(() => { if (isGameDownloading) { - if (game.status === GameStatus.DownloadingMetadata) - return t("downloading_metadata", { title: game.title }); - - if (game.status === GameStatus.CheckingFiles) - return t("checking_files", { - title: game.title, - percentage: progress, - }); - return t("downloading", { - title: game?.title, + title: lastPacket?.game.title, percentage: progress, eta, speed: downloadSpeed, @@ -45,7 +35,7 @@ export function BottomPanel() { } return t("no_downloads_in_progress"); - }, [t, isGameDownloading, game, progress, eta, downloadSpeed]); + }, [t, isGameDownloading, lastPacket?.game, progress, eta, downloadSpeed]); return (